diff --git a/README.md b/README.md index 630e245d..7125f440 100644 --- a/README.md +++ b/README.md @@ -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: - `pub.versia:custom_emojis`: Custom emojis - `pub.versia:instance_messaging`: Instance Messaging -- `pub.versia:polls`: Polls +- `pub.versia:likes`: Likes - `pub.versia:share`: Share ## API diff --git a/api/api/v1/statuses/[id]/reblog.ts b/api/api/v1/statuses/[id]/reblog.ts index c04f95f0..bd6185bd 100644 --- a/api/api/v1/statuses/[id]/reblog.ts +++ b/api/api/v1/statuses/[id]/reblog.ts @@ -1,8 +1,4 @@ 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 { resolver, validator } from "hono-openapi/zod"; import { z } from "zod"; @@ -54,39 +50,9 @@ export default apiRoute((app) => const { user } = context.get("auth"); const note = context.get("note"); - const existingReblog = await Note.fromSql( - and( - eq(Notes.authorId, user.id), - eq(Notes.reblogId, note.data.id), - ), - ); + const reblog = await user.reblog(note, visibility); - if (existingReblog) { - 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); + return context.json(await reblog.toApi(user), 200); }, ), ); diff --git a/api/api/v1/statuses/[id]/unreblog.ts b/api/api/v1/statuses/[id]/unreblog.ts index 6f04cb83..37f2d8eb 100644 --- a/api/api/v1/statuses/[id]/unreblog.ts +++ b/api/api/v1/statuses/[id]/unreblog.ts @@ -1,7 +1,5 @@ import { RolePermission, Status as StatusSchema } from "@versia/client/schemas"; import { Note } from "@versia/kit/db"; -import { Notes } from "@versia/kit/tables"; -import { and, eq } from "drizzle-orm"; import { describeRoute } from "hono-openapi"; import { resolver } from "hono-openapi/zod"; import { apiRoute, auth, withNoteParam } from "@/api"; @@ -42,22 +40,7 @@ export default apiRoute((app) => const { user } = context.get("auth"); const note = context.get("note"); - const existingReblog = await Note.fromSql( - 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()); + await user.unreblog(note); const newNote = await Note.fromId(note.data.id, user.id); diff --git a/api/inbox/index.test.ts b/api/inbox/index.test.ts index 61bd5013..670026e8 100644 --- a/api/inbox/index.test.ts +++ b/api/inbox/index.test.ts @@ -6,7 +6,7 @@ import { enableRealRequests, mock, } from "bun-bagel"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { Instance } from "~/classes/database/instance"; import { Note } from "~/classes/database/note"; import { User } from "~/classes/database/user"; @@ -19,6 +19,7 @@ import { fakeRequest } from "~/tests/utils"; const instanceUrl = new URL("https://versia.example.com"); const noteId = randomUUIDv7(); const userId = randomUUIDv7(); +const shareId = randomUUIDv7(); const userKeys = await User.generateKeys(); const privateKey = await crypto.subtle.importKey( "pkcs8", @@ -167,4 +168,57 @@ describe("Inbox Tests", () => { 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(); + }); }); diff --git a/api/notes/[uuid]/quotes.ts b/api/notes/[uuid]/quotes.ts index 3dd1a996..ec65d503 100644 --- a/api/notes/[uuid]/quotes.ts +++ b/api/notes/[uuid]/quotes.ts @@ -67,7 +67,7 @@ export default apiRoute((app) => throw ApiError.noteNotFound(); } - const replies = await Note.manyFromSql( + const quotes = await Note.manyFromSql( and( eq(Notes.quotingId, note.id), inArray(Notes.visibility, ["public", "unlisted"]), @@ -77,7 +77,7 @@ export default apiRoute((app) => offset, ); - const replyCount = await db.$count( + const quoteCount = await db.$count( Notes, and( eq(Notes.quotingId, note.id), @@ -92,10 +92,10 @@ export default apiRoute((app) => config.http.base_url, ).href, last: - replyCount > limit + quoteCount > limit ? new URL( `/notes/${note.id}/quotes?offset=${ - replyCount - limit + quoteCount - limit }`, config.http.base_url, ).href @@ -104,7 +104,7 @@ export default apiRoute((app) => config.http.base_url, ).href, next: - offset + limit < replyCount + offset + limit < quoteCount ? new URL( `/notes/${note.id}/quotes?offset=${ offset + limit @@ -121,8 +121,8 @@ export default apiRoute((app) => config.http.base_url, ).href : null, - total: replyCount, - items: replies.map((reply) => reply.getUri().href), + total: quoteCount, + items: quotes.map((reply) => reply.getUri().href), }); // If base_url uses https and request uses http, rewrite request to use https diff --git a/api/notes/[uuid]/shares.ts b/api/notes/[uuid]/shares.ts new file mode 100644 index 00000000..3bf131af --- /dev/null +++ b/api/notes/[uuid]/shares.ts @@ -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()); + }, + ), +); diff --git a/api/shares/[uuid]/index.ts b/api/shares/[uuid]/index.ts new file mode 100644 index 00000000..84023fd1 --- /dev/null +++ b/api/shares/[uuid]/index.ts @@ -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()); + }, + ), +); diff --git a/api/well-known/versia.ts b/api/well-known/versia.ts index aa04978a..7864fcce 100644 --- a/api/well-known/versia.ts +++ b/api/well-known/versia.ts @@ -47,6 +47,8 @@ export default apiRoute((app) => extensions: [ "pub.versia:custom_emojis", "pub.versia:instance_messaging", + "pub.versia:likes", + "pub.versia:shares", ], versions: ["0.5.0"], }, diff --git a/classes/database/note.ts b/classes/database/note.ts index b3b91f7f..b3ced792 100644 --- a/classes/database/note.ts +++ b/classes/database/note.ts @@ -739,6 +739,10 @@ export class Note extends BaseInterface { `/notes/${status.id}/quotes`, config.http.base_url, ).href, + "pub.versia:share/Shares": new URL( + `/notes/${status.id}/shares`, + config.http.base_url, + ).href, }, attachments: status.attachments.map( (attachment) => @@ -780,6 +784,36 @@ export class Note extends BaseInterface { }); } + 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, * i.e. all the posts that this post is a reply to diff --git a/classes/database/user.ts b/classes/database/user.ts index 72522494..a93b53b1 100644 --- a/classes/database/user.ts +++ b/classes/database/user.ts @@ -4,6 +4,7 @@ import type { Mention as MentionSchema, RolePermission, Source, + Status as StatusSchema, } from "@versia/client/schemas"; import { db, Media, Notification, PushSubscription } from "@versia/kit/db"; import { @@ -52,7 +53,7 @@ import { BaseInterface } from "./base.ts"; import { Emoji } from "./emoji.ts"; import { Instance } from "./instance.ts"; import { Like } from "./like.ts"; -import type { Note } from "./note.ts"; +import { Note } from "./note.ts"; import { Relationship } from "./relationship.ts"; import { Role } from "./role.ts"; @@ -468,6 +469,123 @@ export class User extends BaseInterface { .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, + uri?: URL, + ): Promise { + 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 { + 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. * @@ -498,15 +616,17 @@ export class User extends BaseInterface { await note.author.notify("favourite", this, note); } - const federatedUsers = await this.federateToFollowers( - newLike.toVersia(), - ); + if (this.local) { + const federatedUsers = await this.federateToFollowers( + newLike.toVersia(), + ); - if ( - note.remote && - !federatedUsers.find((u) => u.id === note.author.id) - ) { - await this.federateToUser(newLike.toVersia(), note.author); + if ( + note.remote && + !federatedUsers.find((u) => u.id === note.author.id) + ) { + await this.federateToUser(newLike.toVersia(), note.author); + } } return newLike; @@ -535,19 +655,20 @@ export class User extends BaseInterface { await likeToDelete.clearRelatedNotifications(); } - // User is local, federate the delete - const federatedUsers = await this.federateToFollowers( - likeToDelete.unlikeToVersia(this), - ); - - if ( - note.remote && - !federatedUsers.find((u) => u.id === note.author.id) - ) { - await this.federateToUser( + if (this.local) { + const federatedUsers = await this.federateToFollowers( likeToDelete.unlikeToVersia(this), - note.author, ); + + if ( + note.remote && + !federatedUsers.find((u) => u.id === note.author.id) + ) { + await this.federateToUser( + likeToDelete.unlikeToVersia(this), + note.author, + ); + } } } diff --git a/classes/inbox/processor.ts b/classes/inbox/processor.ts index d768f189..a0b8d6b3 100644 --- a/classes/inbox/processor.ts +++ b/classes/inbox/processor.ts @@ -4,7 +4,7 @@ import { Likes, Notes } from "@versia/kit/tables"; import type { SocketAddress } from "bun"; import { Glob } from "bun"; import chalk from "chalk"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { matches } from "ip-matching"; import { isValidationError } from "zod-validation-error"; import { sentry } from "@/sentry"; @@ -202,6 +202,9 @@ export class InboxProcessor { .on(VersiaEntities.User, async (u) => { await User.fromVersia(u); }) + .on(VersiaEntities.Share, async (s) => + InboxProcessor.processShare(s), + ) .sort(() => { 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} + */ + private static async processShare( + share: VersiaEntities.Share, + ): Promise { + 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. * @@ -394,6 +420,37 @@ export class InboxProcessor { await like.delete(); 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: { throw new ApiError( 400, diff --git a/types/api.ts b/types/api.ts index 444d4b4e..6ca3f549 100644 --- a/types/api.ts +++ b/types/api.ts @@ -31,4 +31,5 @@ export type KnownEntity = | VersiaEntities.FollowReject | VersiaEntities.Unfollow | VersiaEntities.Delete - | VersiaEntities.Like; + | VersiaEntities.Like + | VersiaEntities.Share;