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

This commit is contained in:
Jesse Wierzbinski 2025-05-02 12:48:47 +02:00
parent ec69fc2ac0
commit cd12ccd6c1
No known key found for this signature in database
12 changed files with 533 additions and 85 deletions

View file

@ -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

View file

@ -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);
}, },
), ),
); );

View file

@ -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);

View file

@ -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();
});
}); });

View file

@ -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
View 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());
},
),
);

View 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());
},
),
);

View file

@ -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"],
}, },

View file

@ -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

View file

@ -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",

View file

@ -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,

View file

@ -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;