mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 16:38:19 +01:00
Implement federation of statuses
This commit is contained in:
parent
8563c97403
commit
a58c81c8e9
|
|
@ -59,8 +59,7 @@ export const isViewableByUser = (status: Status, user: User | null) => {
|
||||||
|
|
||||||
export const fetchFromRemote = async (uri: string): Promise<Status | null> => {
|
export const fetchFromRemote = async (uri: string): Promise<Status | null> => {
|
||||||
// Check if already in database
|
// Check if already in database
|
||||||
|
/* const existingStatus: StatusWithRelations | null =
|
||||||
const existingStatus: StatusWithRelations | null =
|
|
||||||
await client.status.findFirst({
|
await client.status.findFirst({
|
||||||
where: {
|
where: {
|
||||||
uri: uri,
|
uri: uri,
|
||||||
|
|
@ -112,7 +111,7 @@ export const fetchFromRemote = async (uri: string): Promise<Status | null> => {
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
quote: quotingStatus || undefined,
|
quote: quotingStatus || undefined,
|
||||||
});
|
}); */
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -192,12 +191,127 @@ export const getDescendants = async (
|
||||||
return viewableDescendants;
|
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.
|
* Creates a new status and saves it to the database.
|
||||||
* @param data The data for the new status.
|
* @param data The data for the new status.
|
||||||
* @returns A promise that resolves with the new status.
|
* @returns A promise that resolves with the new status.
|
||||||
*/
|
*/
|
||||||
export const createNewStatus = async (data: {
|
export const createNewStatus2 = async (data: {
|
||||||
account: User;
|
account: User;
|
||||||
application: Application | null;
|
application: Application | null;
|
||||||
content: string;
|
content: string;
|
||||||
|
|
@ -539,11 +653,18 @@ export const statusToLysand = (status: StatusWithRelations): Lysand.Note => {
|
||||||
type: "Note",
|
type: "Note",
|
||||||
created_at: new Date(status.createdAt).toISOString(),
|
created_at: new Date(status.createdAt).toISOString(),
|
||||||
id: status.id,
|
id: status.id,
|
||||||
author: status.authorId,
|
author:
|
||||||
uri: new URL(
|
status.author.uri ||
|
||||||
`/objects/note/${status.id}`,
|
new URL(
|
||||||
config.http.base_url,
|
`/users/${status.author.id}`,
|
||||||
).toString(),
|
config.http.base_url,
|
||||||
|
).toString(),
|
||||||
|
uri:
|
||||||
|
status.uri ||
|
||||||
|
new URL(
|
||||||
|
`/objects/note/${status.id}`,
|
||||||
|
config.http.base_url,
|
||||||
|
).toString(),
|
||||||
content: {
|
content: {
|
||||||
"text/html": {
|
"text/html": {
|
||||||
content: status.content,
|
content: status.content,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { addEmojiIfNotExists, emojiToAPI, emojiToLysand } from "./Emoji";
|
||||||
import { addInstanceIfNotExists } from "./Instance";
|
import { addInstanceIfNotExists } from "./Instance";
|
||||||
import { userRelations } from "./relations";
|
import { userRelations } from "./relations";
|
||||||
import { createNewRelationship } from "./Relationship";
|
import { createNewRelationship } from "./Relationship";
|
||||||
|
import { urlToContentFormat } from "@content_types";
|
||||||
|
|
||||||
export interface AuthData {
|
export interface AuthData {
|
||||||
user: UserWithRelations | null;
|
user: UserWithRelations | null;
|
||||||
|
|
@ -493,18 +494,9 @@ export const userToLysand = (user: UserWithRelations): Lysand.User => {
|
||||||
).toString(),
|
).toString(),
|
||||||
indexable: false,
|
indexable: false,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
avatar: {
|
avatar: urlToContentFormat(getAvatarUrl(user, config)) ?? undefined,
|
||||||
[user.avatar.split(".")[1]]: {
|
header: urlToContentFormat(getHeaderUrl(user, config)) ?? undefined,
|
||||||
content: getAvatarUrl(user, config),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
[user.header.split(".")[1]]: {
|
|
||||||
content: getHeaderUrl(user, config),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
display_name: user.displayName,
|
display_name: user.displayName,
|
||||||
|
|
||||||
fields: (user.source as APISource).fields.map((field) => ({
|
fields: (user.source as APISource).fields.map((field) => ({
|
||||||
key: {
|
key: {
|
||||||
"text/html": {
|
"text/html": {
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@
|
||||||
"@types/html-to-text": "^9.0.4",
|
"@types/html-to-text": "^9.0.4",
|
||||||
"@types/ioredis": "^5.0.0",
|
"@types/ioredis": "^5.0.0",
|
||||||
"@types/jsonld": "^1.5.13",
|
"@types/jsonld": "^1.5.13",
|
||||||
|
"@types/mime-types": "^2.1.4",
|
||||||
"@typescript-eslint/eslint-plugin": "latest",
|
"@typescript-eslint/eslint-plugin": "latest",
|
||||||
"@unocss/cli": "latest",
|
"@unocss/cli": "latest",
|
||||||
"@unocss/transformer-directives": "^0.59.0",
|
"@unocss/transformer-directives": "^0.59.0",
|
||||||
|
|
@ -119,6 +120,7 @@
|
||||||
"megalodon": "^10.0.0",
|
"megalodon": "^10.0.0",
|
||||||
"meilisearch": "latest",
|
"meilisearch": "latest",
|
||||||
"merge-deep-ts": "^1.2.6",
|
"merge-deep-ts": "^1.2.6",
|
||||||
|
"mime-types": "^2.1.35",
|
||||||
"next-route-matcher": "^1.0.1",
|
"next-route-matcher": "^1.0.1",
|
||||||
"oauth4webapi": "^2.4.0",
|
"oauth4webapi": "^2.4.0",
|
||||||
"prisma": "^5.6.0",
|
"prisma": "^5.6.0",
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ export const rawRoutes = {
|
||||||
"./server/api/api/v1/statuses/[id]/unreblog",
|
"./server/api/api/v1/statuses/[id]/unreblog",
|
||||||
"/media/[id]": "./server/api/media/[id]/index",
|
"/media/[id]": "./server/api/media/[id]/index",
|
||||||
"/oauth/callback/[issuer]": "./server/api/oauth/callback/[issuer]/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]": "./server/api/users/[uuid]/index",
|
||||||
"/users/[uuid]/inbox": "./server/api/users/[uuid]/inbox/index",
|
"/users/[uuid]/inbox": "./server/api/users/[uuid]/inbox/index",
|
||||||
"/users/[uuid]/outbox": "./server/api/users/[uuid]/outbox/index",
|
"/users/[uuid]/outbox": "./server/api/users/[uuid]/outbox/index",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import type { StatusWithRelations } from "~database/entities/Status";
|
||||||
import { createNewStatus, statusToAPI } from "~database/entities/Status";
|
import { createNewStatus, statusToAPI } from "~database/entities/Status";
|
||||||
import type { UserWithRelations } from "~database/entities/User";
|
import type { UserWithRelations } from "~database/entities/User";
|
||||||
import { statusAndUserRelations } from "~database/entities/relations";
|
import { statusAndUserRelations } from "~database/entities/relations";
|
||||||
|
import type { APIStatus } from "~types/entities/status";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["POST"],
|
allowedMethods: ["POST"],
|
||||||
|
|
@ -211,30 +212,26 @@ export default apiRoute<{
|
||||||
return errorResponse("Invalid media IDs", 422);
|
return errorResponse("Invalid media IDs", 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newStatus = await createNewStatus({
|
const newStatus = await createNewStatus(
|
||||||
account: user,
|
user,
|
||||||
application,
|
{
|
||||||
content: sanitizedStatus,
|
"text/html": {
|
||||||
visibility:
|
content: sanitizedStatus,
|
||||||
visibility ||
|
},
|
||||||
(config.defaults.visibility as
|
[content_type ?? "text/plain"]: {
|
||||||
| "public"
|
content: status ?? "",
|
||||||
| "unlisted"
|
},
|
||||||
| "private"
|
},
|
||||||
| "direct"),
|
visibility as APIStatus["visibility"],
|
||||||
sensitive: sensitive || false,
|
sensitive ?? false,
|
||||||
spoiler_text: spoiler_text || "",
|
spoiler_text ?? "",
|
||||||
emojis: [],
|
[],
|
||||||
media_attachments: media_ids,
|
undefined,
|
||||||
reply:
|
[],
|
||||||
replyStatus && replyUser
|
media_ids,
|
||||||
? {
|
replyStatus ?? undefined,
|
||||||
user: replyUser,
|
quote ?? undefined,
|
||||||
status: replyStatus,
|
);
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
quote: quote || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: add database jobs to deliver the post
|
// TODO: add database jobs to deliver the post
|
||||||
|
|
||||||
|
|
|
||||||
83
server/api/objects/note/[uuid]/index.ts
Normal file
83
server/api/objects/note/[uuid]/index.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,25 +1,10 @@
|
||||||
// TODO: Refactor into smaller packages
|
|
||||||
import { apiRoute, applyConfig } from "@api";
|
import { apiRoute, applyConfig } from "@api";
|
||||||
import { getBestContentType } from "@content_types";
|
import { errorResponse, response } from "@response";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
|
||||||
import { client } from "~database/datasource";
|
import { client } from "~database/datasource";
|
||||||
import { parseEmojis } from "~database/entities/Emoji";
|
import { userRelations } from "~database/entities/relations";
|
||||||
import { createLike, deleteLike } from "~database/entities/Like";
|
import type * as Lysand from "lysand-types";
|
||||||
import { createFromObject } from "~database/entities/Object";
|
import { createNewStatus } from "~database/entities/Status";
|
||||||
import { createNewStatus, fetchFromRemote } from "~database/entities/Status";
|
import type { APIStatus } from "~types/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({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["POST"],
|
allowedMethods: ["POST"],
|
||||||
|
|
@ -30,73 +15,48 @@ export const meta = applyConfig({
|
||||||
duration: 60,
|
duration: 60,
|
||||||
max: 500,
|
max: 500,
|
||||||
},
|
},
|
||||||
route: "/users/:username/inbox",
|
route: "/users/:uuid",
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* ActivityPub user inbox endpoint
|
|
||||||
*/
|
|
||||||
export default apiRoute(async (req, matchedRoute, extraData) => {
|
export default apiRoute(async (req, matchedRoute, extraData) => {
|
||||||
const username = matchedRoute.params.username;
|
const uuid = matchedRoute.params.uuid;
|
||||||
|
|
||||||
const config = await extraData.configManager.getConfig();
|
const user = await client.user.findUnique({
|
||||||
|
|
||||||
/* 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: {
|
where: {
|
||||||
username,
|
id: uuid,
|
||||||
},
|
},
|
||||||
include: userRelations,
|
include: userRelations,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!author) {
|
if (!user) {
|
||||||
// TODO: Add new author to database
|
return errorResponse("User not found", 404);
|
||||||
return errorResponse("Author not found", 404);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify HTTP signature
|
const config = await extraData.configManager.getConfig();
|
||||||
/* if (config.activitypub.authorized_fetch) {
|
|
||||||
// Check if date is older than 30 seconds
|
|
||||||
const origin = req.headers.get("Origin");
|
|
||||||
|
|
||||||
if (!origin) {
|
// Process incoming request
|
||||||
return errorResponse("Origin header is required", 401);
|
const body = extraData.parsedRequest as Lysand.Entity;
|
||||||
}
|
|
||||||
|
|
||||||
const date = req.headers.get("Date");
|
// Verify request signature
|
||||||
|
// TODO: Check if instance is defederated
|
||||||
if (!date) {
|
// biome-ignore lint/correctness/noConstantCondition: Temporary
|
||||||
return errorResponse("Date header is required", 401);
|
if (true) {
|
||||||
}
|
// request is a Request object containing the previous request
|
||||||
|
|
||||||
if (new Date(date).getTime() < Date.now() - 30000) {
|
|
||||||
return errorResponse("Date is too old (max 30 seconds)", 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const signatureHeader = req.headers.get("Signature");
|
const signatureHeader = req.headers.get("Signature");
|
||||||
|
const origin = req.headers.get("Origin");
|
||||||
|
const date = req.headers.get("Date");
|
||||||
|
|
||||||
if (!signatureHeader) {
|
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
|
const signature = signatureHeader
|
||||||
|
|
@ -105,297 +65,98 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
|
||||||
|
|
||||||
const digest = await crypto.subtle.digest(
|
const digest = await crypto.subtle.digest(
|
||||||
"SHA-256",
|
"SHA-256",
|
||||||
new TextEncoder().encode(await req.text()),
|
new TextEncoder().encode(JSON.stringify(body)),
|
||||||
);
|
);
|
||||||
|
|
||||||
const expectedSignedString =
|
const keyId = signatureHeader
|
||||||
`(request-target): ${req.method.toLowerCase()} ${req.url}\n` +
|
.split("keyId=")[1]
|
||||||
`host: ${req.url}\n` +
|
.split(",")[0]
|
||||||
`date: ${date}\n` +
|
.replace(/"/g, "");
|
||||||
`digest: SHA-256=${Buffer.from(digest).toString("base64")}`;
|
|
||||||
|
|
||||||
// author.public_key is base64 encoded raw public key
|
// TODO: Fetch sender using WebFinger if not found
|
||||||
const publicKey = await crypto.subtle.importKey(
|
const sender = await client.user.findUnique({
|
||||||
|
where: {
|
||||||
|
uri: keyId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!sender) {
|
||||||
|
return errorResponse("Invalid keyId", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const public_key = await crypto.subtle.importKey(
|
||||||
"spki",
|
"spki",
|
||||||
Buffer.from(author.publicKey, "base64"),
|
Uint8Array.from(atob(sender.publicKey), (c) => c.charCodeAt(0)),
|
||||||
"Ed25519",
|
"Ed25519",
|
||||||
false,
|
false,
|
||||||
["verify"],
|
["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
|
// Check if signed string is valid
|
||||||
const isValid = await crypto.subtle.verify(
|
const isValid = await crypto.subtle.verify(
|
||||||
"Ed25519",
|
"Ed25519",
|
||||||
publicKey,
|
public_key,
|
||||||
Buffer.from(signature, "base64"),
|
Uint8Array.from(atob(signature), (c) => c.charCodeAt(0)),
|
||||||
new TextEncoder().encode(expectedSignedString),
|
new TextEncoder().encode(expectedSignedString),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
return errorResponse("Invalid signature", 401);
|
return errorResponse("Invalid signature", 400);
|
||||||
}
|
|
||||||
} */
|
|
||||||
|
|
||||||
// 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 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));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
403
server/api/users/[uuid]/inbox/index3.ts
Normal file
403
server/api/users/[uuid]/inbox/index3.ts
Normal 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({});
|
||||||
|
});
|
||||||
|
|
@ -5,7 +5,7 @@ import { userToLysand } from "~database/entities/User";
|
||||||
import { userRelations } from "~database/entities/relations";
|
import { userRelations } from "~database/entities/relations";
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["POST"],
|
allowedMethods: ["GET"],
|
||||||
auth: {
|
auth: {
|
||||||
required: false,
|
required: false,
|
||||||
},
|
},
|
||||||
|
|
@ -16,9 +16,6 @@ export const meta = applyConfig({
|
||||||
route: "/users/:uuid",
|
route: "/users/:uuid",
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* ActivityPub user inbox endpoint
|
|
||||||
*/
|
|
||||||
export default apiRoute(async (req, matchedRoute) => {
|
export default apiRoute(async (req, matchedRoute) => {
|
||||||
const uuid = matchedRoute.params.uuid;
|
const uuid = matchedRoute.params.uuid;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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[]) => {
|
export const getBestContentType = (content?: Lysand.ContentFormat) => {
|
||||||
// Find the best content and content type
|
if (!content) return { content: "", format: "text/plain" };
|
||||||
if (contents.find((c) => c.content_type === "text/x.misskeymarkdown")) {
|
|
||||||
return (
|
const bestFormatsRanked = [
|
||||||
contents.find((c) => c.content_type === "text/x.misskeymarkdown") ||
|
"text/x.misskeymarkdown",
|
||||||
null
|
"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;
|
return { content: "", format: "text/plain" };
|
||||||
}
|
};
|
||||||
if (contents.find((c) => c.content_type === "text/markdown")) {
|
|
||||||
return contents.find((c) => c.content_type === "text/markdown") || null;
|
export const urlToContentFormat = (
|
||||||
}
|
url: string,
|
||||||
if (contents.find((c) => c.content_type === "text/plain")) {
|
): Lysand.ContentFormat | null => {
|
||||||
return contents.find((c) => c.content_type === "text/plain") || null;
|
if (!url) return null;
|
||||||
}
|
if (url.startsWith("https://api.dicebear.com/")) {
|
||||||
return contents[0] || null;
|
return {
|
||||||
|
"image/svg+xml": {
|
||||||
|
content: url,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const mimeType =
|
||||||
|
lookup(url.replace(new URL(url).search, "")) ||
|
||||||
|
"application/octet-stream";
|
||||||
|
|
||||||
|
return {
|
||||||
|
[mimeType]: {
|
||||||
|
content: url,
|
||||||
|
},
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue