mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 16:38:19 +01:00
feat(federation): ✨ Implement Share federation support
Some checks failed
CodeQL Scan / Analyze (javascript-typescript) (push) Failing after 0s
Build Docker Images / lint (push) Failing after 6s
Build Docker Images / check (push) Failing after 7s
Build Docker Images / tests (push) Failing after 6s
Build Docker Images / build (server, Dockerfile, ${{ github.repository_owner }}/server) (push) Has been skipped
Build Docker Images / build (worker, Worker.Dockerfile, ${{ github.repository_owner }}/worker) (push) Has been skipped
Deploy Docs to GitHub Pages / build (push) Failing after 0s
Deploy Docs to GitHub Pages / Deploy (push) Has been skipped
Mirror to Codeberg / Mirror (push) Failing after 0s
Nix Build / check (push) Failing after 0s
Some checks failed
CodeQL Scan / Analyze (javascript-typescript) (push) Failing after 0s
Build Docker Images / lint (push) Failing after 6s
Build Docker Images / check (push) Failing after 7s
Build Docker Images / tests (push) Failing after 6s
Build Docker Images / build (server, Dockerfile, ${{ github.repository_owner }}/server) (push) Has been skipped
Build Docker Images / build (worker, Worker.Dockerfile, ${{ github.repository_owner }}/worker) (push) Has been skipped
Deploy Docs to GitHub Pages / build (push) Failing after 0s
Deploy Docs to GitHub Pages / Deploy (push) Has been skipped
Mirror to Codeberg / Mirror (push) Failing after 0s
Nix Build / check (push) Failing after 0s
This commit is contained in:
parent
ec69fc2ac0
commit
cd12ccd6c1
|
|
@ -93,7 +93,7 @@ Contributions are welcome! Please see the [CONTRIBUTING.md](CONTRIBUTING.md) fil
|
||||||
The following extensions are currently supported or being worked on:
|
The following extensions are currently supported or being worked on:
|
||||||
- `pub.versia:custom_emojis`: Custom emojis
|
- `pub.versia:custom_emojis`: Custom emojis
|
||||||
- `pub.versia:instance_messaging`: Instance Messaging
|
- `pub.versia:instance_messaging`: Instance Messaging
|
||||||
- `pub.versia:polls`: Polls
|
- `pub.versia:likes`: Likes
|
||||||
- `pub.versia:share`: Share
|
- `pub.versia:share`: Share
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,4 @@
|
||||||
import { RolePermission, Status as StatusSchema } from "@versia/client/schemas";
|
import { RolePermission, Status as StatusSchema } from "@versia/client/schemas";
|
||||||
import { Note } from "@versia/kit/db";
|
|
||||||
import { Notes } from "@versia/kit/tables";
|
|
||||||
import { randomUUIDv7 } from "bun";
|
|
||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import { describeRoute } from "hono-openapi";
|
import { describeRoute } from "hono-openapi";
|
||||||
import { resolver, validator } from "hono-openapi/zod";
|
import { resolver, validator } from "hono-openapi/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
@ -54,39 +50,9 @@ export default apiRoute((app) =>
|
||||||
const { user } = context.get("auth");
|
const { user } = context.get("auth");
|
||||||
const note = context.get("note");
|
const note = context.get("note");
|
||||||
|
|
||||||
const existingReblog = await Note.fromSql(
|
const reblog = await user.reblog(note, visibility);
|
||||||
and(
|
|
||||||
eq(Notes.authorId, user.id),
|
|
||||||
eq(Notes.reblogId, note.data.id),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingReblog) {
|
return context.json(await reblog.toApi(user), 200);
|
||||||
return context.json(await existingReblog.toApi(user), 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newReblog = await Note.insert({
|
|
||||||
id: randomUUIDv7(),
|
|
||||||
authorId: user.id,
|
|
||||||
reblogId: note.data.id,
|
|
||||||
visibility,
|
|
||||||
sensitive: false,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
applicationId: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Refetch the note *again* to get the proper value of .reblogged
|
|
||||||
const finalNewReblog = await Note.fromId(newReblog.id, user?.id);
|
|
||||||
|
|
||||||
if (!finalNewReblog) {
|
|
||||||
throw new Error("Failed to reblog");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (note.author.local && user.local) {
|
|
||||||
await note.author.notify("reblog", user, newReblog);
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.json(await finalNewReblog.toApi(user), 200);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import { RolePermission, Status as StatusSchema } from "@versia/client/schemas";
|
import { RolePermission, Status as StatusSchema } from "@versia/client/schemas";
|
||||||
import { Note } from "@versia/kit/db";
|
import { Note } from "@versia/kit/db";
|
||||||
import { Notes } from "@versia/kit/tables";
|
|
||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import { describeRoute } from "hono-openapi";
|
import { describeRoute } from "hono-openapi";
|
||||||
import { resolver } from "hono-openapi/zod";
|
import { resolver } from "hono-openapi/zod";
|
||||||
import { apiRoute, auth, withNoteParam } from "@/api";
|
import { apiRoute, auth, withNoteParam } from "@/api";
|
||||||
|
|
@ -42,22 +40,7 @@ export default apiRoute((app) =>
|
||||||
const { user } = context.get("auth");
|
const { user } = context.get("auth");
|
||||||
const note = context.get("note");
|
const note = context.get("note");
|
||||||
|
|
||||||
const existingReblog = await Note.fromSql(
|
await user.unreblog(note);
|
||||||
and(
|
|
||||||
eq(Notes.authorId, user.id),
|
|
||||||
eq(Notes.reblogId, note.data.id),
|
|
||||||
),
|
|
||||||
undefined,
|
|
||||||
user?.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!existingReblog) {
|
|
||||||
return context.json(await note.toApi(user), 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
await existingReblog.delete();
|
|
||||||
|
|
||||||
await user.federateToFollowers(existingReblog.deleteToVersia());
|
|
||||||
|
|
||||||
const newNote = await Note.fromId(note.data.id, user.id);
|
const newNote = await Note.fromId(note.data.id, user.id);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
enableRealRequests,
|
enableRealRequests,
|
||||||
mock,
|
mock,
|
||||||
} from "bun-bagel";
|
} from "bun-bagel";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { Instance } from "~/classes/database/instance";
|
import { Instance } from "~/classes/database/instance";
|
||||||
import { Note } from "~/classes/database/note";
|
import { Note } from "~/classes/database/note";
|
||||||
import { User } from "~/classes/database/user";
|
import { User } from "~/classes/database/user";
|
||||||
|
|
@ -19,6 +19,7 @@ import { fakeRequest } from "~/tests/utils";
|
||||||
const instanceUrl = new URL("https://versia.example.com");
|
const instanceUrl = new URL("https://versia.example.com");
|
||||||
const noteId = randomUUIDv7();
|
const noteId = randomUUIDv7();
|
||||||
const userId = randomUUIDv7();
|
const userId = randomUUIDv7();
|
||||||
|
const shareId = randomUUIDv7();
|
||||||
const userKeys = await User.generateKeys();
|
const userKeys = await User.generateKeys();
|
||||||
const privateKey = await crypto.subtle.importKey(
|
const privateKey = await crypto.subtle.importKey(
|
||||||
"pkcs8",
|
"pkcs8",
|
||||||
|
|
@ -167,4 +168,57 @@ describe("Inbox Tests", () => {
|
||||||
|
|
||||||
expect(note).not.toBeNull();
|
expect(note).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should correctly process Share", async () => {
|
||||||
|
const exampleRequest = new VersiaEntities.Share({
|
||||||
|
id: shareId,
|
||||||
|
created_at: "2025-04-18T10:32:01.427Z",
|
||||||
|
uri: new URL(`/shares/${shareId}`, instanceUrl).href,
|
||||||
|
type: "pub.versia:share/Share",
|
||||||
|
author: new URL(`/users/${userId}`, instanceUrl).href,
|
||||||
|
shared: new URL(`/notes/${noteId}`, instanceUrl).href,
|
||||||
|
});
|
||||||
|
|
||||||
|
const signedRequest = await sign(
|
||||||
|
privateKey,
|
||||||
|
new URL(exampleRequest.data.author),
|
||||||
|
new Request(inboxUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
"User-Agent": "Versia/1.0.0",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(exampleRequest.toJSON()),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await fakeRequest(inboxUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: signedRequest.headers,
|
||||||
|
body: signedRequest.body,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
const dbNote = await Note.fromSql(
|
||||||
|
eq(Notes.uri, new URL(`/notes/${noteId}`, instanceUrl).href),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!dbNote) {
|
||||||
|
throw new Error("DBNote not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if share was created in the database
|
||||||
|
const share = await Note.fromSql(
|
||||||
|
and(
|
||||||
|
eq(Notes.reblogId, dbNote.id),
|
||||||
|
eq(Notes.authorId, dbNote.data.authorId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(share).not.toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ export default apiRoute((app) =>
|
||||||
throw ApiError.noteNotFound();
|
throw ApiError.noteNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const replies = await Note.manyFromSql(
|
const quotes = await Note.manyFromSql(
|
||||||
and(
|
and(
|
||||||
eq(Notes.quotingId, note.id),
|
eq(Notes.quotingId, note.id),
|
||||||
inArray(Notes.visibility, ["public", "unlisted"]),
|
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||||
|
|
@ -77,7 +77,7 @@ export default apiRoute((app) =>
|
||||||
offset,
|
offset,
|
||||||
);
|
);
|
||||||
|
|
||||||
const replyCount = await db.$count(
|
const quoteCount = await db.$count(
|
||||||
Notes,
|
Notes,
|
||||||
and(
|
and(
|
||||||
eq(Notes.quotingId, note.id),
|
eq(Notes.quotingId, note.id),
|
||||||
|
|
@ -92,10 +92,10 @@ export default apiRoute((app) =>
|
||||||
config.http.base_url,
|
config.http.base_url,
|
||||||
).href,
|
).href,
|
||||||
last:
|
last:
|
||||||
replyCount > limit
|
quoteCount > limit
|
||||||
? new URL(
|
? new URL(
|
||||||
`/notes/${note.id}/quotes?offset=${
|
`/notes/${note.id}/quotes?offset=${
|
||||||
replyCount - limit
|
quoteCount - limit
|
||||||
}`,
|
}`,
|
||||||
config.http.base_url,
|
config.http.base_url,
|
||||||
).href
|
).href
|
||||||
|
|
@ -104,7 +104,7 @@ export default apiRoute((app) =>
|
||||||
config.http.base_url,
|
config.http.base_url,
|
||||||
).href,
|
).href,
|
||||||
next:
|
next:
|
||||||
offset + limit < replyCount
|
offset + limit < quoteCount
|
||||||
? new URL(
|
? new URL(
|
||||||
`/notes/${note.id}/quotes?offset=${
|
`/notes/${note.id}/quotes?offset=${
|
||||||
offset + limit
|
offset + limit
|
||||||
|
|
@ -121,8 +121,8 @@ export default apiRoute((app) =>
|
||||||
config.http.base_url,
|
config.http.base_url,
|
||||||
).href
|
).href
|
||||||
: null,
|
: null,
|
||||||
total: replyCount,
|
total: quoteCount,
|
||||||
items: replies.map((reply) => reply.getUri().href),
|
items: quotes.map((reply) => reply.getUri().href),
|
||||||
});
|
});
|
||||||
|
|
||||||
// If base_url uses https and request uses http, rewrite request to use https
|
// If base_url uses https and request uses http, rewrite request to use https
|
||||||
|
|
|
||||||
151
api/notes/[uuid]/shares.ts
Normal file
151
api/notes/[uuid]/shares.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
import { Status as StatusSchema } from "@versia/client/schemas";
|
||||||
|
import { db, Note } from "@versia/kit/db";
|
||||||
|
import { Notes } from "@versia/kit/tables";
|
||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import { describeRoute } from "hono-openapi";
|
||||||
|
import { resolver, validator } from "hono-openapi/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { apiRoute, handleZodError } from "@/api";
|
||||||
|
import { ApiError } from "~/classes/errors/api-error";
|
||||||
|
import { config } from "~/config.ts";
|
||||||
|
import * as VersiaEntities from "~/packages/sdk/entities";
|
||||||
|
import { URICollectionSchema } from "~/packages/sdk/schemas";
|
||||||
|
|
||||||
|
export default apiRoute((app) =>
|
||||||
|
app.get(
|
||||||
|
"/notes/:id/shares",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Retrieve all shares of a Versia Note.",
|
||||||
|
tags: ["Federation"],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Note shares",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(URICollectionSchema),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description:
|
||||||
|
"Entity not found, is remote, or the requester is not allowed to view it.",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(ApiError.zodSchema),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
"param",
|
||||||
|
z.object({
|
||||||
|
id: StatusSchema.shape.id,
|
||||||
|
}),
|
||||||
|
handleZodError,
|
||||||
|
),
|
||||||
|
validator(
|
||||||
|
"query",
|
||||||
|
z.object({
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(40),
|
||||||
|
offset: z.coerce.number().int().nonnegative().default(0),
|
||||||
|
}),
|
||||||
|
handleZodError,
|
||||||
|
),
|
||||||
|
async (context) => {
|
||||||
|
const { id } = context.req.valid("param");
|
||||||
|
const { limit, offset } = context.req.valid("query");
|
||||||
|
|
||||||
|
const note = await Note.fromSql(
|
||||||
|
and(
|
||||||
|
eq(Notes.id, id),
|
||||||
|
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!(note && (await note.isViewableByUser(null))) || note.remote) {
|
||||||
|
throw ApiError.noteNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const shares = await Note.manyFromSql(
|
||||||
|
and(
|
||||||
|
eq(Notes.reblogId, note.id),
|
||||||
|
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||||
|
),
|
||||||
|
undefined,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
);
|
||||||
|
|
||||||
|
const shareCount = await db.$count(
|
||||||
|
Notes,
|
||||||
|
and(
|
||||||
|
eq(Notes.reblogId, note.id),
|
||||||
|
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const uriCollection = new VersiaEntities.URICollection({
|
||||||
|
author: note.author.uri.href,
|
||||||
|
first: new URL(
|
||||||
|
`/notes/${note.id}/shares?offset=0`,
|
||||||
|
config.http.base_url,
|
||||||
|
).href,
|
||||||
|
last:
|
||||||
|
shareCount > limit
|
||||||
|
? new URL(
|
||||||
|
`/notes/${note.id}/shares?offset=${
|
||||||
|
shareCount - limit
|
||||||
|
}`,
|
||||||
|
config.http.base_url,
|
||||||
|
).href
|
||||||
|
: new URL(
|
||||||
|
`/notes/${note.id}/shares`,
|
||||||
|
config.http.base_url,
|
||||||
|
).href,
|
||||||
|
next:
|
||||||
|
offset + limit < shareCount
|
||||||
|
? new URL(
|
||||||
|
`/notes/${note.id}/shares?offset=${
|
||||||
|
offset + limit
|
||||||
|
}`,
|
||||||
|
config.http.base_url,
|
||||||
|
).href
|
||||||
|
: null,
|
||||||
|
previous:
|
||||||
|
offset - limit >= 0
|
||||||
|
? new URL(
|
||||||
|
`/notes/${note.id}/shares?offset=${
|
||||||
|
offset - limit
|
||||||
|
}`,
|
||||||
|
config.http.base_url,
|
||||||
|
).href
|
||||||
|
: null,
|
||||||
|
total: shareCount,
|
||||||
|
items: shares.map(
|
||||||
|
(share) =>
|
||||||
|
new URL(`/shares/${share.id}`, config.http.base_url)
|
||||||
|
.href,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// If base_url uses https and request uses http, rewrite request to use https
|
||||||
|
// This fixes reverse proxy errors
|
||||||
|
const reqUrl = new URL(context.req.url);
|
||||||
|
if (
|
||||||
|
config.http.base_url.protocol === "https:" &&
|
||||||
|
reqUrl.protocol === "http:"
|
||||||
|
) {
|
||||||
|
reqUrl.protocol = "https:";
|
||||||
|
}
|
||||||
|
|
||||||
|
const { headers } = await note.author.sign(
|
||||||
|
uriCollection,
|
||||||
|
reqUrl,
|
||||||
|
"GET",
|
||||||
|
);
|
||||||
|
|
||||||
|
return context.json(uriCollection, 200, headers.toJSON());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
79
api/shares/[uuid]/index.ts
Normal file
79
api/shares/[uuid]/index.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { Status as StatusSchema } from "@versia/client/schemas";
|
||||||
|
import { Note } from "@versia/kit/db";
|
||||||
|
import { Notes } from "@versia/kit/tables";
|
||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import { describeRoute } from "hono-openapi";
|
||||||
|
import { resolver, validator } from "hono-openapi/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { apiRoute, handleZodError } from "@/api";
|
||||||
|
import { ApiError } from "~/classes/errors/api-error";
|
||||||
|
import { config } from "~/config.ts";
|
||||||
|
import { ShareSchema } from "~/packages/sdk/schemas";
|
||||||
|
|
||||||
|
export default apiRoute((app) =>
|
||||||
|
app.get(
|
||||||
|
"/shares/:id",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Retrieve the Versia representation of a share.",
|
||||||
|
tags: ["Federation"],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Share",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(ShareSchema),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
description:
|
||||||
|
"Entity not found, is remote, or the requester is not allowed to view it.",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(ApiError.zodSchema),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
"param",
|
||||||
|
z.object({
|
||||||
|
id: StatusSchema.shape.id,
|
||||||
|
}),
|
||||||
|
handleZodError,
|
||||||
|
),
|
||||||
|
async (context) => {
|
||||||
|
const { id } = context.req.valid("param");
|
||||||
|
|
||||||
|
const note = await Note.fromSql(
|
||||||
|
and(
|
||||||
|
eq(Notes.id, id),
|
||||||
|
inArray(Notes.visibility, ["public", "unlisted"]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!(note && (await note.isViewableByUser(null))) || note.remote) {
|
||||||
|
throw ApiError.noteNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If base_url uses https and request uses http, rewrite request to use https
|
||||||
|
// This fixes reverse proxy errors
|
||||||
|
const reqUrl = new URL(context.req.url);
|
||||||
|
if (
|
||||||
|
config.http.base_url.protocol === "https:" &&
|
||||||
|
reqUrl.protocol === "http:"
|
||||||
|
) {
|
||||||
|
reqUrl.protocol = "https:";
|
||||||
|
}
|
||||||
|
|
||||||
|
const { headers } = await note.author.sign(
|
||||||
|
note.toVersiaShare(),
|
||||||
|
reqUrl,
|
||||||
|
"GET",
|
||||||
|
);
|
||||||
|
|
||||||
|
return context.json(note.toVersiaShare(), 200, headers.toJSON());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
@ -47,6 +47,8 @@ export default apiRoute((app) =>
|
||||||
extensions: [
|
extensions: [
|
||||||
"pub.versia:custom_emojis",
|
"pub.versia:custom_emojis",
|
||||||
"pub.versia:instance_messaging",
|
"pub.versia:instance_messaging",
|
||||||
|
"pub.versia:likes",
|
||||||
|
"pub.versia:shares",
|
||||||
],
|
],
|
||||||
versions: ["0.5.0"],
|
versions: ["0.5.0"],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -739,6 +739,10 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
||||||
`/notes/${status.id}/quotes`,
|
`/notes/${status.id}/quotes`,
|
||||||
config.http.base_url,
|
config.http.base_url,
|
||||||
).href,
|
).href,
|
||||||
|
"pub.versia:share/Shares": new URL(
|
||||||
|
`/notes/${status.id}/shares`,
|
||||||
|
config.http.base_url,
|
||||||
|
).href,
|
||||||
},
|
},
|
||||||
attachments: status.attachments.map(
|
attachments: status.attachments.map(
|
||||||
(attachment) =>
|
(attachment) =>
|
||||||
|
|
@ -780,6 +784,36 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public toVersiaShare(): VersiaEntities.Share {
|
||||||
|
if (!(this.data.reblogId && this.data.reblog)) {
|
||||||
|
throw new Error("Cannot share a non-reblogged note");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new VersiaEntities.Share({
|
||||||
|
type: "pub.versia:share/Share",
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
author: this.author.uri.href,
|
||||||
|
uri: new URL(`/shares/${this.id}`, config.http.base_url).href,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
shared: new Note(this.data.reblog as NoteTypeWithRelations).getUri()
|
||||||
|
.href,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public toVersiaUnshare(): VersiaEntities.Delete {
|
||||||
|
return new VersiaEntities.Delete({
|
||||||
|
type: "Delete",
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
author: User.getUri(
|
||||||
|
this.data.authorId,
|
||||||
|
this.data.author.uri ? new URL(this.data.author.uri) : null,
|
||||||
|
).href,
|
||||||
|
deleted_type: "pub.versia:share/Share",
|
||||||
|
deleted: new URL(`/shares/${this.id}`, config.http.base_url).href,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return all the ancestors of this post,
|
* Return all the ancestors of this post,
|
||||||
* i.e. all the posts that this post is a reply to
|
* i.e. all the posts that this post is a reply to
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import type {
|
||||||
Mention as MentionSchema,
|
Mention as MentionSchema,
|
||||||
RolePermission,
|
RolePermission,
|
||||||
Source,
|
Source,
|
||||||
|
Status as StatusSchema,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
import { db, Media, Notification, PushSubscription } from "@versia/kit/db";
|
import { db, Media, Notification, PushSubscription } from "@versia/kit/db";
|
||||||
import {
|
import {
|
||||||
|
|
@ -52,7 +53,7 @@ import { BaseInterface } from "./base.ts";
|
||||||
import { Emoji } from "./emoji.ts";
|
import { Emoji } from "./emoji.ts";
|
||||||
import { Instance } from "./instance.ts";
|
import { Instance } from "./instance.ts";
|
||||||
import { Like } from "./like.ts";
|
import { Like } from "./like.ts";
|
||||||
import type { Note } from "./note.ts";
|
import { Note } from "./note.ts";
|
||||||
import { Relationship } from "./relationship.ts";
|
import { Relationship } from "./relationship.ts";
|
||||||
import { Role } from "./role.ts";
|
import { Role } from "./role.ts";
|
||||||
|
|
||||||
|
|
@ -468,6 +469,123 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
.filter((x) => x !== null);
|
.filter((x) => x !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reblog a note.
|
||||||
|
*
|
||||||
|
* If the note is already reblogged, it will return the existing reblog. Also creates a notification for the author of the note.
|
||||||
|
* @param note The note to reblog
|
||||||
|
* @param visibility The visibility of the reblog
|
||||||
|
* @param uri The URI of the reblog, if it is remote
|
||||||
|
* @returns The reblog object created or the existing reblog
|
||||||
|
*/
|
||||||
|
public async reblog(
|
||||||
|
note: Note,
|
||||||
|
visibility: z.infer<typeof StatusSchema.shape.visibility>,
|
||||||
|
uri?: URL,
|
||||||
|
): Promise<Note> {
|
||||||
|
const existingReblog = await Note.fromSql(
|
||||||
|
and(eq(Notes.authorId, this.id), eq(Notes.reblogId, note.id)),
|
||||||
|
undefined,
|
||||||
|
this.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingReblog) {
|
||||||
|
return existingReblog;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newReblog = await Note.insert({
|
||||||
|
id: randomUUIDv7(),
|
||||||
|
authorId: this.id,
|
||||||
|
reblogId: note.id,
|
||||||
|
visibility,
|
||||||
|
sensitive: false,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
applicationId: null,
|
||||||
|
uri: uri?.href,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refetch the note *again* to get the proper value of .reblogged
|
||||||
|
const finalNewReblog = await Note.fromId(newReblog.id, this?.id);
|
||||||
|
|
||||||
|
if (!finalNewReblog) {
|
||||||
|
throw new Error("Failed to reblog");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note.author.local) {
|
||||||
|
// Notify the user that their post has been reblogged
|
||||||
|
await note.author.notify("reblog", this, finalNewReblog);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.local) {
|
||||||
|
const federatedUsers = await this.federateToFollowers(
|
||||||
|
finalNewReblog.toVersiaShare(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
note.remote &&
|
||||||
|
!federatedUsers.find((u) => u.id === note.author.id)
|
||||||
|
) {
|
||||||
|
await this.federateToUser(
|
||||||
|
finalNewReblog.toVersiaShare(),
|
||||||
|
note.author,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalNewReblog;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unreblog a note.
|
||||||
|
*
|
||||||
|
* If the note is not reblogged, it will return without doing anything. Also removes any notifications for this reblog.
|
||||||
|
* @param note The note to unreblog
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
public async unreblog(note: Note): Promise<void> {
|
||||||
|
const reblogToDelete = await Note.fromSql(
|
||||||
|
and(eq(Notes.authorId, this.id), eq(Notes.reblogId, note.id)),
|
||||||
|
undefined,
|
||||||
|
this.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!reblogToDelete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await reblogToDelete.delete();
|
||||||
|
|
||||||
|
if (note.author.local) {
|
||||||
|
// Remove any eventual notifications for this reblog
|
||||||
|
await db
|
||||||
|
.delete(Notifications)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(Notifications.accountId, this.id),
|
||||||
|
eq(Notifications.type, "reblog"),
|
||||||
|
eq(Notifications.notifiedId, note.data.authorId),
|
||||||
|
eq(Notifications.noteId, note.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.local) {
|
||||||
|
const federatedUsers = await this.federateToFollowers(
|
||||||
|
reblogToDelete.toVersiaUnshare(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
note.remote &&
|
||||||
|
!federatedUsers.find((u) => u.id === note.author.id)
|
||||||
|
) {
|
||||||
|
await this.federateToUser(
|
||||||
|
reblogToDelete.toVersiaUnshare(),
|
||||||
|
note.author,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Like a note.
|
* Like a note.
|
||||||
*
|
*
|
||||||
|
|
@ -498,6 +616,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
await note.author.notify("favourite", this, note);
|
await note.author.notify("favourite", this, note);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.local) {
|
||||||
const federatedUsers = await this.federateToFollowers(
|
const federatedUsers = await this.federateToFollowers(
|
||||||
newLike.toVersia(),
|
newLike.toVersia(),
|
||||||
);
|
);
|
||||||
|
|
@ -508,6 +627,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
) {
|
) {
|
||||||
await this.federateToUser(newLike.toVersia(), note.author);
|
await this.federateToUser(newLike.toVersia(), note.author);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return newLike;
|
return newLike;
|
||||||
}
|
}
|
||||||
|
|
@ -535,7 +655,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
await likeToDelete.clearRelatedNotifications();
|
await likeToDelete.clearRelatedNotifications();
|
||||||
}
|
}
|
||||||
|
|
||||||
// User is local, federate the delete
|
if (this.local) {
|
||||||
const federatedUsers = await this.federateToFollowers(
|
const federatedUsers = await this.federateToFollowers(
|
||||||
likeToDelete.unlikeToVersia(this),
|
likeToDelete.unlikeToVersia(this),
|
||||||
);
|
);
|
||||||
|
|
@ -550,6 +670,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async notify(
|
public async notify(
|
||||||
type: "mention" | "follow_request" | "follow" | "favourite" | "reblog",
|
type: "mention" | "follow_request" | "follow" | "favourite" | "reblog",
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { Likes, Notes } from "@versia/kit/tables";
|
||||||
import type { SocketAddress } from "bun";
|
import type { SocketAddress } from "bun";
|
||||||
import { Glob } from "bun";
|
import { Glob } from "bun";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { matches } from "ip-matching";
|
import { matches } from "ip-matching";
|
||||||
import { isValidationError } from "zod-validation-error";
|
import { isValidationError } from "zod-validation-error";
|
||||||
import { sentry } from "@/sentry";
|
import { sentry } from "@/sentry";
|
||||||
|
|
@ -202,6 +202,9 @@ export class InboxProcessor {
|
||||||
.on(VersiaEntities.User, async (u) => {
|
.on(VersiaEntities.User, async (u) => {
|
||||||
await User.fromVersia(u);
|
await User.fromVersia(u);
|
||||||
})
|
})
|
||||||
|
.on(VersiaEntities.Share, async (s) =>
|
||||||
|
InboxProcessor.processShare(s),
|
||||||
|
)
|
||||||
.sort(() => {
|
.sort(() => {
|
||||||
throw new ApiError(400, "Unknown entity type");
|
throw new ApiError(400, "Unknown entity type");
|
||||||
});
|
});
|
||||||
|
|
@ -332,6 +335,29 @@ export class InboxProcessor {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles Share entity processing.
|
||||||
|
*
|
||||||
|
* @param {VersiaShare} share - The Share entity to process.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
private static async processShare(
|
||||||
|
share: VersiaEntities.Share,
|
||||||
|
): Promise<void> {
|
||||||
|
const author = await User.resolve(new URL(share.data.author));
|
||||||
|
const sharedNote = await Note.resolve(new URL(share.data.shared));
|
||||||
|
|
||||||
|
if (!author) {
|
||||||
|
throw new ApiError(404, "Author not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sharedNote) {
|
||||||
|
throw new ApiError(404, "Shared Note not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
await author.reblog(sharedNote, "public", new URL(share.data.uri));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles Delete entity processing.
|
* Handles Delete entity processing.
|
||||||
*
|
*
|
||||||
|
|
@ -394,6 +420,37 @@ export class InboxProcessor {
|
||||||
await like.delete();
|
await like.delete();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case "pub.versia:shares/Share": {
|
||||||
|
if (!author) {
|
||||||
|
throw new ApiError(404, "Author not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const reblog = await Note.fromSql(
|
||||||
|
and(eq(Notes.uri, toDelete), eq(Notes.authorId, author.id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!reblog) {
|
||||||
|
throw new ApiError(
|
||||||
|
404,
|
||||||
|
"Share not found or not owned by sender",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reblogged = await Note.fromId(
|
||||||
|
reblog.data.reblogId,
|
||||||
|
author.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!reblogged) {
|
||||||
|
throw new ApiError(
|
||||||
|
404,
|
||||||
|
"Share not found or not owned by sender",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await author.unreblog(reblogged);
|
||||||
|
return;
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
400,
|
400,
|
||||||
|
|
|
||||||
|
|
@ -31,4 +31,5 @@ export type KnownEntity =
|
||||||
| VersiaEntities.FollowReject
|
| VersiaEntities.FollowReject
|
||||||
| VersiaEntities.Unfollow
|
| VersiaEntities.Unfollow
|
||||||
| VersiaEntities.Delete
|
| VersiaEntities.Delete
|
||||||
| VersiaEntities.Like;
|
| VersiaEntities.Like
|
||||||
|
| VersiaEntities.Share;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue