diff --git a/bun.lockb b/bun.lockb index 4e044f58..d70cd385 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/database/entities/Status.ts b/database/entities/Status.ts index 293cd2d2..bf4c5548 100644 --- a/database/entities/Status.ts +++ b/database/entities/Status.ts @@ -59,8 +59,7 @@ export const isViewableByUser = (status: Status, user: User | null) => { export const fetchFromRemote = async (uri: string): Promise => { // Check if already in database - - const existingStatus: StatusWithRelations | null = + /* const existingStatus: StatusWithRelations | null = await client.status.findFirst({ where: { uri: uri, @@ -112,7 +111,7 @@ export const fetchFromRemote = async (uri: string): Promise => { } : undefined, quote: quotingStatus || undefined, - }); + }); */ }; /** @@ -192,12 +191,127 @@ export const getDescendants = async ( return viewableDescendants; }; +/** + * Get people mentioned in the content (match @username or @username@domain.com mentions) + * @param text The text to parse mentions from. + * @returns An array of users mentioned in the text. + */ +export const parseTextMentions = async (text: string) => { + const mentionedPeople = + text.match(/@[a-zA-Z0-9_]+(@[a-zA-Z0-9_]+)?/g) ?? []; + + return await client.user.findMany({ + where: { + OR: mentionedPeople.map((person) => ({ + username: person.split("@")[1], + instance: { + base_url: person.split("@")[2], + }, + })), + }, + include: userRelations, + }); +}; + +export const createNewStatus = async ( + author: User, + content: Lysand.ContentFormat, + visibility: APIStatus["visibility"], + is_sensitive: boolean, + spoiler_text: string, + emojis: Emoji[], + uri?: string, + mentions?: UserWithRelations[], + /** List of IDs of database Attachment objects */ + media_attachments?: string[], + inReplyTo?: StatusWithRelations, + quoting?: StatusWithRelations, +) => { + let htmlContent: string; + + if (content["text/html"]) { + htmlContent = content["text/html"].content; + } else if (content["text/markdown"]) { + htmlContent = linkifyHtml( + await sanitizeHtml(await parse(content["text/markdown"].content)), + ); + } else if (content["text/plain"]) { + htmlContent = linkifyStr(content["text/plain"].content); + + // Split by newline and add

tags + htmlContent = htmlContent + .split("\n") + .map((line) => `

${line}

`) + .join("\n"); + } else { + htmlContent = ""; + } + + // Parse emojis and fuse with existing emojis + let foundEmojis = emojis; + + if (author.instanceId === null) { + const parsedEmojis = await parseEmojis(htmlContent); + // Fuse and deduplicate + foundEmojis = [...emojis, ...parsedEmojis].filter( + (emoji, index, self) => + index === self.findIndex((t) => t.id === emoji.id), + ); + } + + const status = await client.status.create({ + data: { + authorId: author.id, + content: htmlContent, + contentSource: + content["text/plain"]?.content || + content["text/markdown"]?.content || + "", + contentType: "text/html", + visibility: visibility, + sensitive: is_sensitive, + spoilerText: spoiler_text, + isReblog: false, // DEPRECATED FIELD + emojis: { + connect: foundEmojis.map((emoji) => { + return { + id: emoji.id, + }; + }), + }, + attachments: media_attachments + ? { + connect: media_attachments.map((attachment) => { + return { + id: attachment, + }; + }), + } + : undefined, + inReplyToPostId: inReplyTo?.id, + quotingPostId: quoting?.id, + instanceId: author.instanceId || undefined, + uri: uri || null, + mentions: { + connect: mentions?.map((mention) => { + return { + id: mention.id, + }; + }), + }, + }, + include: statusAndUserRelations, + }); + + return status; +}; + /** * Creates a new status and saves it to the database. * @param data The data for the new status. * @returns A promise that resolves with the new status. */ -export const createNewStatus = async (data: { +export const createNewStatus2 = async (data: { account: User; application: Application | null; content: string; @@ -539,11 +653,18 @@ export const statusToLysand = (status: StatusWithRelations): Lysand.Note => { type: "Note", created_at: new Date(status.createdAt).toISOString(), id: status.id, - author: status.authorId, - uri: new URL( - `/objects/note/${status.id}`, - config.http.base_url, - ).toString(), + author: + status.author.uri || + new URL( + `/users/${status.author.id}`, + config.http.base_url, + ).toString(), + uri: + status.uri || + new URL( + `/objects/note/${status.id}`, + config.http.base_url, + ).toString(), content: { "text/html": { content: status.content, diff --git a/database/entities/User.ts b/database/entities/User.ts index 48ab3a62..0af0cb5b 100644 --- a/database/entities/User.ts +++ b/database/entities/User.ts @@ -11,6 +11,7 @@ import { addEmojiIfNotExists, emojiToAPI, emojiToLysand } from "./Emoji"; import { addInstanceIfNotExists } from "./Instance"; import { userRelations } from "./relations"; import { createNewRelationship } from "./Relationship"; +import { urlToContentFormat } from "@content_types"; export interface AuthData { user: UserWithRelations | null; @@ -493,18 +494,9 @@ export const userToLysand = (user: UserWithRelations): Lysand.User => { ).toString(), indexable: false, username: user.username, - avatar: { - [user.avatar.split(".")[1]]: { - content: getAvatarUrl(user, config), - }, - }, - header: { - [user.header.split(".")[1]]: { - content: getHeaderUrl(user, config), - }, - }, + avatar: urlToContentFormat(getAvatarUrl(user, config)) ?? undefined, + header: urlToContentFormat(getHeaderUrl(user, config)) ?? undefined, display_name: user.displayName, - fields: (user.source as APISource).fields.map((field) => ({ key: { "text/html": { diff --git a/package.json b/package.json index 402003f6..c2f1b830 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "@types/html-to-text": "^9.0.4", "@types/ioredis": "^5.0.0", "@types/jsonld": "^1.5.13", + "@types/mime-types": "^2.1.4", "@typescript-eslint/eslint-plugin": "latest", "@unocss/cli": "latest", "@unocss/transformer-directives": "^0.59.0", @@ -119,6 +120,7 @@ "megalodon": "^10.0.0", "meilisearch": "latest", "merge-deep-ts": "^1.2.6", + "mime-types": "^2.1.35", "next-route-matcher": "^1.0.1", "oauth4webapi": "^2.4.0", "prisma": "^5.6.0", diff --git a/routes.ts b/routes.ts index e2b1fece..67d4a75c 100644 --- a/routes.ts +++ b/routes.ts @@ -83,7 +83,7 @@ export const rawRoutes = { "./server/api/api/v1/statuses/[id]/unreblog", "/media/[id]": "./server/api/media/[id]/index", "/oauth/callback/[issuer]": "./server/api/oauth/callback/[issuer]/index", - "/object/[uuid]": "./server/api/object/[uuid]/index", + "/objects/note/[uuid]": "./server/api/objects/note/[uuid]/index", "/users/[uuid]": "./server/api/users/[uuid]/index", "/users/[uuid]/inbox": "./server/api/users/[uuid]/inbox/index", "/users/[uuid]/outbox": "./server/api/users/[uuid]/outbox/index", diff --git a/server/api/api/v1/statuses/index.ts b/server/api/api/v1/statuses/index.ts index c8db40e9..de554010 100644 --- a/server/api/api/v1/statuses/index.ts +++ b/server/api/api/v1/statuses/index.ts @@ -8,6 +8,7 @@ import type { StatusWithRelations } from "~database/entities/Status"; import { createNewStatus, statusToAPI } from "~database/entities/Status"; import type { UserWithRelations } from "~database/entities/User"; import { statusAndUserRelations } from "~database/entities/relations"; +import type { APIStatus } from "~types/entities/status"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -211,30 +212,26 @@ export default apiRoute<{ return errorResponse("Invalid media IDs", 422); } - const newStatus = await createNewStatus({ - account: user, - application, - content: sanitizedStatus, - visibility: - visibility || - (config.defaults.visibility as - | "public" - | "unlisted" - | "private" - | "direct"), - sensitive: sensitive || false, - spoiler_text: spoiler_text || "", - emojis: [], - media_attachments: media_ids, - reply: - replyStatus && replyUser - ? { - user: replyUser, - status: replyStatus, - } - : undefined, - quote: quote || undefined, - }); + const newStatus = await createNewStatus( + user, + { + "text/html": { + content: sanitizedStatus, + }, + [content_type ?? "text/plain"]: { + content: status ?? "", + }, + }, + visibility as APIStatus["visibility"], + sensitive ?? false, + spoiler_text ?? "", + [], + undefined, + [], + media_ids, + replyStatus ?? undefined, + quote ?? undefined, + ); // TODO: add database jobs to deliver the post diff --git a/server/api/objects/note/[uuid]/index.ts b/server/api/objects/note/[uuid]/index.ts new file mode 100644 index 00000000..2e6742b2 --- /dev/null +++ b/server/api/objects/note/[uuid]/index.ts @@ -0,0 +1,83 @@ +import { apiRoute, applyConfig } from "@api"; +import { errorResponse, jsonResponse } from "@response"; +import { client } from "~database/datasource"; +import { userToLysand } from "~database/entities/User"; +import { statusAndUserRelations } from "~database/entities/relations"; +import type * as Lysand from "lysand-types"; +import { statusToLysand } from "~database/entities/Status"; + +export const meta = applyConfig({ + allowedMethods: ["GET"], + auth: { + required: false, + }, + ratelimits: { + duration: 60, + max: 500, + }, + route: "/objects/note/:uuid", +}); + +export default apiRoute(async (req, matchedRoute, extraData) => { + const uuid = matchedRoute.params.uuid; + + const status = await client.status.findUnique({ + where: { + id: uuid, + }, + include: statusAndUserRelations, + }); + + if (!status) { + return errorResponse("Note not found", 404); + } + + const config = await extraData.configManager.getConfig(); + + const output = statusToLysand(status); + + const privateKey = await crypto.subtle.importKey( + "pkcs8", + Uint8Array.from(atob(status.author.privateKey ?? ""), (c) => + c.charCodeAt(0), + ), + "Ed25519", + false, + ["sign"], + ); + + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(JSON.stringify(output)), + ); + + const userInbox = new URL( + "http://lysand.localhost:8080/users/018ec11c-c6cb-7a67-bd20-a4c81bf42912/inbox", + ); + + const date = new Date(); + + const signature = await crypto.subtle.sign( + "Ed25519", + privateKey, + new TextEncoder().encode( + `(request-target): post ${userInbox.pathname}\n` + + `host: ${userInbox.host}\n` + + `date: ${date.toISOString()}\n` + + `digest: SHA-256=${btoa( + String.fromCharCode(...new Uint8Array(digest)), + )}\n`, + ), + ); + + const signatureBase64 = btoa( + String.fromCharCode(...new Uint8Array(signature)), + ); + + return jsonResponse({ + Date: date.toISOString(), + Origin: "example.com", + Signature: `keyId="https://example.com/users/${status.author.id}",algorithm="ed25519",headers="(request-target) host date digest",signature="${signatureBase64}"`, + post: output, + }); +}); diff --git a/server/api/users/[uuid]/inbox/index.ts b/server/api/users/[uuid]/inbox/index.ts index a1a98e25..df5d7f3e 100644 --- a/server/api/users/[uuid]/inbox/index.ts +++ b/server/api/users/[uuid]/inbox/index.ts @@ -1,25 +1,10 @@ -// TODO: Refactor into smaller packages import { apiRoute, applyConfig } from "@api"; -import { getBestContentType } from "@content_types"; -import { errorResponse, jsonResponse } from "@response"; +import { errorResponse, response } from "@response"; import { client } from "~database/datasource"; -import { parseEmojis } from "~database/entities/Emoji"; -import { createLike, deleteLike } from "~database/entities/Like"; -import { createFromObject } from "~database/entities/Object"; -import { createNewStatus, fetchFromRemote } from "~database/entities/Status"; -import { parseMentionsUris } from "~database/entities/User"; -import { - statusAndUserRelations, - userRelations, -} from "~database/entities/relations"; -import type { - Announce, - Like, - LysandAction, - LysandPublication, - Patch, - Undo, -} from "~types/lysand/Object"; +import { userRelations } from "~database/entities/relations"; +import type * as Lysand from "lysand-types"; +import { createNewStatus } from "~database/entities/Status"; +import type { APIStatus } from "~types/entities/status"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -30,73 +15,48 @@ export const meta = applyConfig({ duration: 60, max: 500, }, - route: "/users/:username/inbox", + route: "/users/:uuid", }); -/** - * ActivityPub user inbox endpoint - */ export default apiRoute(async (req, matchedRoute, extraData) => { - const username = matchedRoute.params.username; + const uuid = matchedRoute.params.uuid; - const config = await extraData.configManager.getConfig(); - - /* try { - if ( - config.activitypub.reject_activities.includes( - new URL(req.headers.get("Origin") ?? "").hostname, - ) - ) { - // Discard request - return jsonResponse({}); - } - } catch (e) { - console.error( - `[-] Error parsing Origin header of incoming Activity from ${req.headers.get( - "Origin", - )}`, - ); - console.error(e); - } */ - - // Process request body - const body = (await req.json()) as LysandPublication | LysandAction; - - const author = await client.user.findUnique({ + const user = await client.user.findUnique({ where: { - username, + id: uuid, }, include: userRelations, }); - if (!author) { - // TODO: Add new author to database - return errorResponse("Author not found", 404); + if (!user) { + return errorResponse("User not found", 404); } - // Verify HTTP signature - /* if (config.activitypub.authorized_fetch) { - // Check if date is older than 30 seconds - const origin = req.headers.get("Origin"); + const config = await extraData.configManager.getConfig(); - if (!origin) { - return errorResponse("Origin header is required", 401); - } + // Process incoming request + const body = extraData.parsedRequest as Lysand.Entity; - const date = req.headers.get("Date"); - - if (!date) { - return errorResponse("Date header is required", 401); - } - - if (new Date(date).getTime() < Date.now() - 30000) { - return errorResponse("Date is too old (max 30 seconds)", 401); - } + // Verify request signature + // TODO: Check if instance is defederated + // biome-ignore lint/correctness/noConstantCondition: Temporary + if (true) { + // request is a Request object containing the previous request const signatureHeader = req.headers.get("Signature"); + const origin = req.headers.get("Origin"); + const date = req.headers.get("Date"); if (!signatureHeader) { - return errorResponse("Signature header is required", 401); + return errorResponse("Missing Signature header", 400); + } + + if (!origin) { + return errorResponse("Missing Origin header", 400); + } + + if (!date) { + return errorResponse("Missing Date header", 400); } const signature = signatureHeader @@ -105,297 +65,98 @@ export default apiRoute(async (req, matchedRoute, extraData) => { const digest = await crypto.subtle.digest( "SHA-256", - new TextEncoder().encode(await req.text()), + new TextEncoder().encode(JSON.stringify(body)), ); - const expectedSignedString = - `(request-target): ${req.method.toLowerCase()} ${req.url}\n` + - `host: ${req.url}\n` + - `date: ${date}\n` + - `digest: SHA-256=${Buffer.from(digest).toString("base64")}`; + const keyId = signatureHeader + .split("keyId=")[1] + .split(",")[0] + .replace(/"/g, ""); - // author.public_key is base64 encoded raw public key - const publicKey = await crypto.subtle.importKey( + // TODO: Fetch sender using WebFinger if not found + const sender = await client.user.findUnique({ + where: { + uri: keyId, + }, + }); + + if (!sender) { + return errorResponse("Invalid keyId", 400); + } + + const public_key = await crypto.subtle.importKey( "spki", - Buffer.from(author.publicKey, "base64"), + Uint8Array.from(atob(sender.publicKey), (c) => c.charCodeAt(0)), "Ed25519", false, ["verify"], ); + const expectedSignedString = + `(request-target): ${req.method.toLowerCase()} ${ + new URL(req.url).pathname + }\n` + + `host: ${new URL(req.url).host}\n` + + `date: ${date}\n` + + `digest: SHA-256=${btoa( + String.fromCharCode(...new Uint8Array(digest)), + )}\n`; + // Check if signed string is valid const isValid = await crypto.subtle.verify( "Ed25519", - publicKey, - Buffer.from(signature, "base64"), + public_key, + Uint8Array.from(atob(signature), (c) => c.charCodeAt(0)), new TextEncoder().encode(expectedSignedString), ); if (!isValid) { - return errorResponse("Invalid signature", 401); - } - } */ - - // Get the object's ActivityPub type - const type = body.type; - - switch (type) { - case "Note": { - // Store the object in the LysandObject table - await createFromObject(body); - - const content = getBestContentType(body.contents); - - const emojis = await parseEmojis(content?.content || ""); - - const newStatus = await createNewStatus({ - account: author, - content: content?.content || "", - content_type: content?.content_type, - application: null, - // TODO: Add visibility - visibility: "public", - spoiler_text: body.subject || "", - sensitive: body.is_sensitive, - uri: body.uri, - emojis: emojis, - mentions: await parseMentionsUris(body.mentions), - }); - - // If there is a reply, fetch all the reply parents and add them to the database - if (body.replies_to.length > 0) { - newStatus.inReplyToPostId = - (await fetchFromRemote(body.replies_to[0]))?.id || null; - } - - // Same for quotes - if (body.quotes.length > 0) { - newStatus.quotingPostId = - (await fetchFromRemote(body.quotes[0]))?.id || null; - } - - await client.status.update({ - where: { - id: newStatus.id, - }, - data: { - inReplyToPostId: newStatus.inReplyToPostId, - quotingPostId: newStatus.quotingPostId, - }, - }); - - break; - } - case "Patch": { - const patch = body as Patch; - // Store the object in the LysandObject table - await createFromObject(patch); - - // Edit the status - - const content = getBestContentType(patch.contents); - - const emojis = await parseEmojis(content?.content || ""); - - const status = await client.status.findUnique({ - where: { - uri: patch.patched_id, - }, - include: statusAndUserRelations, - }); - - if (!status) { - return errorResponse("Status not found", 404); - } - - status.content = content?.content || ""; - status.contentType = content?.content_type || "text/plain"; - status.spoilerText = patch.subject || ""; - status.sensitive = patch.is_sensitive; - status.emojis = emojis; - - // If there is a reply, fetch all the reply parents and add them to the database - if (body.replies_to.length > 0) { - status.inReplyToPostId = - (await fetchFromRemote(body.replies_to[0]))?.id || null; - } - - // Same for quotes - if (body.quotes.length > 0) { - status.quotingPostId = - (await fetchFromRemote(body.quotes[0]))?.id || null; - } - - await client.status.update({ - where: { - id: status.id, - }, - data: { - content: status.content, - contentType: status.contentType, - spoilerText: status.spoilerText, - sensitive: status.sensitive, - emojis: { - connect: status.emojis.map((emoji) => ({ - id: emoji.id, - })), - }, - inReplyToPostId: status.inReplyToPostId, - quotingPostId: status.quotingPostId, - }, - }); - break; - } - case "Like": { - const like = body as Like; - // Store the object in the LysandObject table - await createFromObject(body); - - const likedStatus = await client.status.findUnique({ - where: { - uri: like.object, - }, - include: statusAndUserRelations, - }); - - if (!likedStatus) { - return errorResponse("Status not found", 404); - } - - await createLike(author, likedStatus); - - break; - } - case "Dislike": { - // Store the object in the LysandObject table - await createFromObject(body); - - return jsonResponse({ - info: "Dislikes are not supported by this software", - }); - } - case "Follow": { - // Store the object in the LysandObject table - await createFromObject(body); - break; - } - case "FollowAccept": { - // Store the object in the LysandObject table - await createFromObject(body); - break; - } - case "FollowReject": { - // Store the object in the LysandObject table - await createFromObject(body); - break; - } - case "Announce": { - const announce = body as Announce; - // Store the object in the LysandObject table - await createFromObject(body); - - const rebloggedStatus = await client.status.findUnique({ - where: { - uri: announce.object, - }, - include: statusAndUserRelations, - }); - - if (!rebloggedStatus) { - return errorResponse("Status not found", 404); - } - - // Create new reblog - await client.status.create({ - data: { - authorId: author.id, - reblogId: rebloggedStatus.id, - isReblog: true, - uri: body.uri, - visibility: rebloggedStatus.visibility, - sensitive: false, - }, - include: statusAndUserRelations, - }); - - // Create notification - await client.notification.create({ - data: { - accountId: author.id, - notifiedId: rebloggedStatus.authorId, - type: "reblog", - statusId: rebloggedStatus.id, - }, - }); - break; - } - case "Undo": { - const undo = body as Undo; - // Store the object in the LysandObject table - await createFromObject(body); - - const object = await client.lysandObject.findUnique({ - where: { - uri: undo.object, - }, - }); - - if (!object) { - return errorResponse("Object not found", 404); - } - - switch (object.type) { - case "Like": { - const status = await client.status.findUnique({ - where: { - uri: undo.object, - authorId: author.id, - }, - include: statusAndUserRelations, - }); - - if (!status) { - return errorResponse("Status not found", 404); - } - - await deleteLike(author, status); - break; - } - case "Announce": { - await client.status.delete({ - where: { - uri: undo.object, - authorId: author.id, - }, - include: statusAndUserRelations, - }); - break; - } - case "Note": { - await client.status.delete({ - where: { - uri: undo.object, - authorId: author.id, - }, - include: statusAndUserRelations, - }); - break; - } - default: { - return errorResponse("Invalid object type", 400); - } - } - break; - } - case "Extension": { - // Store the object in the LysandObject table - await createFromObject(body); - break; - } - default: { - return errorResponse("Invalid type", 400); + return errorResponse("Invalid signature", 400); } } - return jsonResponse({}); + // Add sent data to database + switch (body.type) { + case "Note": { + const note = body as Lysand.Note; + + const account = await client.user.findUnique({ + where: { + uri: note.author, + }, + }); + + if (!account) { + return errorResponse("Author not found", 400); + } + + await createNewStatus( + account, + note.content ?? { + "text/plain": { + content: "", + }, + }, + note.visibility as APIStatus["visibility"], + note.is_sensitive ?? false, + note.subject ?? "", + [], + note.uri, + // TODO: Resolve mention,s + [], + // TODO: Add attachments + [], + // TODO: Resolve replies and quoting + undefined, + undefined, + ); + + return response("Note created", 201); + } + default: { + return errorResponse("Unknown object type", 400); + } + } + + //return jsonResponse(userToLysand(user)); }); diff --git a/server/api/users/[uuid]/inbox/index3.ts b/server/api/users/[uuid]/inbox/index3.ts new file mode 100644 index 00000000..90531ac9 --- /dev/null +++ b/server/api/users/[uuid]/inbox/index3.ts @@ -0,0 +1,403 @@ +// TODO: Refactor into smaller packages +import { apiRoute, applyConfig } from "@api"; +import { getBestContentType } from "@content_types"; +import { errorResponse, jsonResponse } from "@response"; +import { client } from "~database/datasource"; +import { parseEmojis } from "~database/entities/Emoji"; +import { createLike, deleteLike } from "~database/entities/Like"; +import { createFromObject } from "~database/entities/Object"; +import { createNewStatus, fetchFromRemote } from "~database/entities/Status"; +import { parseMentionsUris } from "~database/entities/User"; +import { + statusAndUserRelations, + userRelations, +} from "~database/entities/relations"; +import type { + Announce, + Like, + LysandAction, + LysandPublication, + Patch, + Undo, +} from "~types/lysand/Object"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + auth: { + required: false, + }, + ratelimits: { + duration: 60, + max: 500, + }, + route: "/users/:uuid/inbox", +}); + +/** + * ActivityPub user inbox endpoint + */ +export default apiRoute(async (req, matchedRoute, extraData) => { + const username = matchedRoute.params.username; + + const config = await extraData.configManager.getConfig(); + + /* try { + if ( + config.activitypub.reject_activities.includes( + new URL(req.headers.get("Origin") ?? "").hostname, + ) + ) { + // Discard request + return jsonResponse({}); + } + } catch (e) { + console.error( + `[-] Error parsing Origin header of incoming Activity from ${req.headers.get( + "Origin", + )}`, + ); + console.error(e); + } */ + + // Process request body + const body = (await req.json()) as LysandPublication | LysandAction; + + const author = await client.user.findUnique({ + where: { + username, + }, + include: userRelations, + }); + + if (!author) { + // TODO: Add new author to database + return errorResponse("Author not found", 404); + } + + // Verify HTTP signature + /* if (config.activitypub.authorized_fetch) { + // Check if date is older than 30 seconds + const origin = req.headers.get("Origin"); + + if (!origin) { + return errorResponse("Origin header is required", 401); + } + + const date = req.headers.get("Date"); + + if (!date) { + return errorResponse("Date header is required", 401); + } + + if (new Date(date).getTime() < Date.now() - 30000) { + return errorResponse("Date is too old (max 30 seconds)", 401); + } + + const signatureHeader = req.headers.get("Signature"); + + if (!signatureHeader) { + return errorResponse("Signature header is required", 401); + } + + const signature = signatureHeader + .split("signature=")[1] + .replace(/"/g, ""); + + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(await req.text()), + ); + + const expectedSignedString = + `(request-target): ${req.method.toLowerCase()} ${req.url}\n` + + `host: ${req.url}\n` + + `date: ${date}\n` + + `digest: SHA-256=${Buffer.from(digest).toString("base64")}`; + + // author.public_key is base64 encoded raw public key + const publicKey = await crypto.subtle.importKey( + "spki", + Buffer.from(author.publicKey, "base64"), + "Ed25519", + false, + ["verify"], + ); + + // Check if signed string is valid + const isValid = await crypto.subtle.verify( + "Ed25519", + publicKey, + Buffer.from(signature, "base64"), + new TextEncoder().encode(expectedSignedString), + ); + + if (!isValid) { + return errorResponse("Invalid signature", 401); + } + } */ + + // Get the object's ActivityPub type + const type = body.type; + + switch (type) { + case "Note": { + // Store the object in the LysandObject table + await createFromObject(body); + + const content = getBestContentType(body.contents); + + const emojis = await parseEmojis(content?.content || ""); + + const newStatus = await createNewStatus(author); + + const newStatus = await createNewStatus({ + account: author, + content: content?.content || "", + content_type: content?.content_type, + application: null, + // TODO: Add visibility + visibility: "public", + spoiler_text: body.subject || "", + sensitive: body.is_sensitive, + uri: body.uri, + emojis: emojis, + mentions: await parseMentionsUris(body.mentions), + }); + + // If there is a reply, fetch all the reply parents and add them to the database + if (body.replies_to.length > 0) { + newStatus.inReplyToPostId = + (await fetchFromRemote(body.replies_to[0]))?.id || null; + } + + // Same for quotes + if (body.quotes.length > 0) { + newStatus.quotingPostId = + (await fetchFromRemote(body.quotes[0]))?.id || null; + } + + await client.status.update({ + where: { + id: newStatus.id, + }, + data: { + inReplyToPostId: newStatus.inReplyToPostId, + quotingPostId: newStatus.quotingPostId, + }, + }); + + break; + } + case "Patch": { + const patch = body as Patch; + // Store the object in the LysandObject table + await createFromObject(patch); + + // Edit the status + + const content = getBestContentType(patch.contents); + + const emojis = await parseEmojis(content?.content || ""); + + const status = await client.status.findUnique({ + where: { + uri: patch.patched_id, + }, + include: statusAndUserRelations, + }); + + if (!status) { + return errorResponse("Status not found", 404); + } + + status.content = content?.content || ""; + status.contentType = content?.content_type || "text/plain"; + status.spoilerText = patch.subject || ""; + status.sensitive = patch.is_sensitive; + status.emojis = emojis; + + // If there is a reply, fetch all the reply parents and add them to the database + if (body.replies_to.length > 0) { + status.inReplyToPostId = + (await fetchFromRemote(body.replies_to[0]))?.id || null; + } + + // Same for quotes + if (body.quotes.length > 0) { + status.quotingPostId = + (await fetchFromRemote(body.quotes[0]))?.id || null; + } + + await client.status.update({ + where: { + id: status.id, + }, + data: { + content: status.content, + contentType: status.contentType, + spoilerText: status.spoilerText, + sensitive: status.sensitive, + emojis: { + connect: status.emojis.map((emoji) => ({ + id: emoji.id, + })), + }, + inReplyToPostId: status.inReplyToPostId, + quotingPostId: status.quotingPostId, + }, + }); + break; + } + case "Like": { + const like = body as Like; + // Store the object in the LysandObject table + await createFromObject(body); + + const likedStatus = await client.status.findUnique({ + where: { + uri: like.object, + }, + include: statusAndUserRelations, + }); + + if (!likedStatus) { + return errorResponse("Status not found", 404); + } + + await createLike(author, likedStatus); + + break; + } + case "Dislike": { + // Store the object in the LysandObject table + await createFromObject(body); + + return jsonResponse({ + info: "Dislikes are not supported by this software", + }); + } + case "Follow": { + // Store the object in the LysandObject table + await createFromObject(body); + break; + } + case "FollowAccept": { + // Store the object in the LysandObject table + await createFromObject(body); + break; + } + case "FollowReject": { + // Store the object in the LysandObject table + await createFromObject(body); + break; + } + case "Announce": { + const announce = body as Announce; + // Store the object in the LysandObject table + await createFromObject(body); + + const rebloggedStatus = await client.status.findUnique({ + where: { + uri: announce.object, + }, + include: statusAndUserRelations, + }); + + if (!rebloggedStatus) { + return errorResponse("Status not found", 404); + } + + // Create new reblog + await client.status.create({ + data: { + authorId: author.id, + reblogId: rebloggedStatus.id, + isReblog: true, + uri: body.uri, + visibility: rebloggedStatus.visibility, + sensitive: false, + }, + include: statusAndUserRelations, + }); + + // Create notification + await client.notification.create({ + data: { + accountId: author.id, + notifiedId: rebloggedStatus.authorId, + type: "reblog", + statusId: rebloggedStatus.id, + }, + }); + break; + } + case "Undo": { + const undo = body as Undo; + // Store the object in the LysandObject table + await createFromObject(body); + + const object = await client.lysandObject.findUnique({ + where: { + uri: undo.object, + }, + }); + + if (!object) { + return errorResponse("Object not found", 404); + } + + switch (object.type) { + case "Like": { + const status = await client.status.findUnique({ + where: { + uri: undo.object, + authorId: author.id, + }, + include: statusAndUserRelations, + }); + + if (!status) { + return errorResponse("Status not found", 404); + } + + await deleteLike(author, status); + break; + } + case "Announce": { + await client.status.delete({ + where: { + uri: undo.object, + authorId: author.id, + }, + include: statusAndUserRelations, + }); + break; + } + case "Note": { + await client.status.delete({ + where: { + uri: undo.object, + authorId: author.id, + }, + include: statusAndUserRelations, + }); + break; + } + default: { + return errorResponse("Invalid object type", 400); + } + } + break; + } + case "Extension": { + // Store the object in the LysandObject table + await createFromObject(body); + break; + } + default: { + return errorResponse("Invalid type", 400); + } + } + + return jsonResponse({}); +}); diff --git a/server/api/users/[uuid]/index.ts b/server/api/users/[uuid]/index.ts index 6466b00f..91898470 100644 --- a/server/api/users/[uuid]/index.ts +++ b/server/api/users/[uuid]/index.ts @@ -5,7 +5,7 @@ import { userToLysand } from "~database/entities/User"; import { userRelations } from "~database/entities/relations"; export const meta = applyConfig({ - allowedMethods: ["POST"], + allowedMethods: ["GET"], auth: { required: false, }, @@ -16,9 +16,6 @@ export const meta = applyConfig({ route: "/users/:uuid", }); -/** - * ActivityPub user inbox endpoint - */ export default apiRoute(async (req, matchedRoute) => { const uuid = matchedRoute.params.uuid; diff --git a/utils/content_types.ts b/utils/content_types.ts index 71c929d9..468e2ff3 100644 --- a/utils/content_types.ts +++ b/utils/content_types.ts @@ -1,21 +1,42 @@ -import type { ContentFormat } from "~types/lysand/Object"; +import type * as Lysand from "lysand-types"; +import { lookup } from "mime-types"; -export const getBestContentType = (contents: ContentFormat[]) => { - // Find the best content and content type - if (contents.find((c) => c.content_type === "text/x.misskeymarkdown")) { - return ( - contents.find((c) => c.content_type === "text/x.misskeymarkdown") || - null - ); +export const getBestContentType = (content?: Lysand.ContentFormat) => { + if (!content) return { content: "", format: "text/plain" }; + + const bestFormatsRanked = [ + "text/x.misskeymarkdown", + "text/html", + "text/markdown", + "text/plain", + ]; + + for (const format of bestFormatsRanked) { + if (content[format]) + return { content: content[format].content, format }; } - if (contents.find((c) => c.content_type === "text/html")) { - return contents.find((c) => c.content_type === "text/html") || null; - } - if (contents.find((c) => c.content_type === "text/markdown")) { - return contents.find((c) => c.content_type === "text/markdown") || null; - } - if (contents.find((c) => c.content_type === "text/plain")) { - return contents.find((c) => c.content_type === "text/plain") || null; - } - return contents[0] || null; + + return { content: "", format: "text/plain" }; +}; + +export const urlToContentFormat = ( + url: string, +): Lysand.ContentFormat | null => { + if (!url) return null; + if (url.startsWith("https://api.dicebear.com/")) { + return { + "image/svg+xml": { + content: url, + }, + }; + } + const mimeType = + lookup(url.replace(new URL(url).search, "")) || + "application/octet-stream"; + + return { + [mimeType]: { + content: url, + }, + }; };