Implement federation of statuses

This commit is contained in:
Jesse Wierzbinski 2024-04-09 16:05:02 -10:00
parent 8563c97403
commit a58c81c8e9
No known key found for this signature in database
11 changed files with 788 additions and 411 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -59,8 +59,7 @@ export const isViewableByUser = (status: Status, user: User | null) => {
export const fetchFromRemote = async (uri: string): Promise<Status | null> => {
// 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<Status | null> => {
}
: 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 <p> tags
htmlContent = htmlContent
.split("\n")
.map((line) => `<p>${line}</p>`)
.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,8 +653,15 @@ 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(
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(),

View file

@ -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": {

View file

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

View file

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

View file

@ -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,
const newStatus = await createNewStatus(
user,
{
"text/html": {
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,
});
},
[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

View file

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

View file

@ -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);
return errorResponse("Invalid signature", 400);
}
}
} */
// Get the object's ActivityPub type
const type = body.type;
switch (type) {
// Add sent data to database
switch (body.type) {
case "Note": {
// Store the object in the LysandObject table
await createFromObject(body);
const note = body as Lysand.Note;
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({
const account = await client.user.findUnique({
where: {
id: newStatus.id,
},
data: {
inReplyToPostId: newStatus.inReplyToPostId,
quotingPostId: newStatus.quotingPostId,
uri: note.author,
},
});
break;
if (!account) {
return errorResponse("Author not found", 400);
}
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,
await createNewStatus(
account,
note.content ?? {
"text/plain": {
content: "",
},
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);
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,
);
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;
return response("Note created", 201);
}
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("Unknown object type", 400);
}
}
return jsonResponse({});
//return jsonResponse(userToLysand(user));
});

View file

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

View file

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

View file

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