More work on converting to the Lysand protocol

This commit is contained in:
Jesse Wierzbinski 2023-11-03 17:34:31 -10:00
parent 02b56f8fde
commit 77a675afe6
No known key found for this signature in database
GPG key ID: F9A1E418934E40B0
25 changed files with 1181 additions and 807 deletions

View file

@ -0,0 +1,50 @@
import { jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { getConfig } from "@config";
import { applyConfig } from "@api";
export const meta = applyConfig({
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 60,
},
route: "/.well-known/lysand",
});
/**
* Lysand instance metadata endpoint
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const config = getConfig();
// In the format acct:name@example.com
return jsonResponse({
type: "ServerMetadata",
name: config.instance.name,
version: "0.0.1",
description: config.instance.description,
logo: config.instance.logo ? [
{
content: config.instance.logo,
content_type: `image/${config.instance.logo.split(".")[1]}`,
}
] : undefined,
banner: config.instance.banner ? [
{
content: config.instance.banner,
content_type: `image/${config.instance.banner.split(".")[1]}`,
}
] : undefined,
supported_extensions: [
"org.lysand:custom_emojis"
],
website: "https://lysand.org",
// TODO: Add admins, moderators field
})
};

View file

@ -59,8 +59,6 @@ export default async (
relationship.note = comment ?? "";
// TODO: Implement duration
await relationship.save();
return jsonResponse(await relationship.toAPI());
};

View file

@ -1,8 +1,10 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { Status, statusAndUserRelations } from "~database/entities/Status";
import { User, userRelations } from "~database/entities/User";
import { applyConfig } from "@api";
import { FindManyOptions } from "typeorm";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -25,10 +27,13 @@ export default async (
): Promise<Response> => {
const id = matchedRoute.params.id;
// TODO: Add pinned
const {
max_id,
min_id,
since_id,
limit,
exclude_reblogs,
pinned,
}: {
max_id?: string;
since_id?: string;
@ -51,12 +56,8 @@ export default async (
if (!user) return errorResponse("User not found", 404);
if (pinned) {
// TODO: Add pinned statuses
}
// TODO: Check if status can be seen by this user
const statuses = await Status.find({
// Get list of boosts for this status
let query: FindManyOptions<Status> = {
where: {
account: {
id: user.id,
@ -64,13 +65,81 @@ export default async (
isReblog: exclude_reblogs ? true : undefined,
},
relations: statusAndUserRelations,
order: {
created_at: "DESC",
},
take: limit ?? 20,
});
order: {
id: "DESC",
},
};
if (max_id) {
const maxStatus = await Status.findOneBy({ id: max_id });
if (maxStatus) {
query = {
...query,
where: {
...query.where,
created_at: {
...(query.where as any)?.created_at,
$lt: maxStatus.created_at,
},
},
};
}
}
if (since_id) {
const sinceStatus = await Status.findOneBy({ id: since_id });
if (sinceStatus) {
query = {
...query,
where: {
...query.where,
created_at: {
...(query.where as any)?.created_at,
$gt: sinceStatus.created_at,
},
},
};
}
}
if (min_id) {
const minStatus = await Status.findOneBy({ id: min_id });
if (minStatus) {
query = {
...query,
where: {
...query.where,
created_at: {
...(query.where as any)?.created_at,
$gte: minStatus.created_at,
},
},
};
}
}
const objects = await Status.find(query);
// Constuct HTTP Link header (next and prev)
const linkHeader = [];
if (objects.length > 0) {
const urlWithoutQuery = req.url.split("?")[0];
linkHeader.push(
`<${urlWithoutQuery}?max_id=${objects[0].id}&limit=${limit}>; rel="next"`
);
linkHeader.push(
`<${urlWithoutQuery}?since_id=${
objects[objects.length - 1].id
}&limit=${limit}>; rel="prev"`
);
}
return jsonResponse(
await Promise.all(statuses.map(async status => await status.toAPI()))
await Promise.all(objects.map(async status => await status.toAPI())),
200,
{
Link: linkHeader.join(", "),
}
);
};

View file

@ -6,6 +6,7 @@ import { applyConfig } from "@api";
import { sanitize } from "isomorphic-dompurify";
import { sanitizeHtml } from "@sanitization";
import { uploadFile } from "~classes/media";
import { Emoji } from "~database/entities/Emoji";
export const meta = applyConfig({
allowedMethods: ["PATCH"],
@ -81,6 +82,9 @@ export default async (req: Request): Promise<Response> => {
return errorResponse("Display name contains blocked words", 422);
}
// Remove emojis
user.emojis = [];
user.display_name = sanitizedDisplayName;
}
@ -102,6 +106,9 @@ export default async (req: Request): Promise<Response> => {
return errorResponse("Bio contains blocked words", 422);
}
// Remove emojis
user.emojis = [];
user.note = sanitizedNote;
}
@ -193,6 +200,18 @@ export default async (req: Request): Promise<Response> => {
// user.discoverable = discoverable === "true";
}
// Parse emojis
const displaynameEmojis = await Emoji.parseEmojis(sanitizedDisplayName);
const noteEmojis = await Emoji.parseEmojis(sanitizedNote);
user.emojis = [...displaynameEmojis, ...noteEmojis];
// Deduplicate emojis
user.emojis = user.emojis.filter(
(emoji, index, self) => self.findIndex(e => e.id === emoji.id) === index
);
await user.save();
return jsonResponse(await user.toAPI());

View file

@ -1,153 +0,0 @@
import { errorResponse, jsonLdResponse } from "@response";
import { MatchedRoute } from "bun";
import { User, userRelations } from "~database/entities/User";
import { getConfig, getHost } from "@config";
import { applyConfig } from "@api";
export const meta = applyConfig({
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 500,
},
route: "/users/:username/actor",
});
/**
* ActivityPub user actor endpoinmt
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
// Check for Accept header
const accept = req.headers.get("Accept");
if (!accept || !accept.includes("application/activity+json")) {
return errorResponse("This endpoint requires an Accept header", 406);
}
const config = getConfig();
const username = matchedRoute.params.username;
const user = await User.findOne({
where: { username },
relations: userRelations,
});
if (!user) {
return errorResponse("User not found", 404);
}
return jsonLdResponse({
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
manuallyApprovesFollowers: "as:manuallyApprovesFollowers",
toot: "http://joinmastodon.org/ns#",
featured: {
"@id": "toot:featured",
"@type": "@id",
},
featuredTags: {
"@id": "toot:featuredTags",
"@type": "@id",
},
alsoKnownAs: {
"@id": "as:alsoKnownAs",
"@type": "@id",
},
movedTo: {
"@id": "as:movedTo",
"@type": "@id",
},
schema: "http://schema.org#",
PropertyValue: "schema:PropertyValue",
value: "schema:value",
discoverable: "toot:discoverable",
Device: "toot:Device",
Ed25519Signature: "toot:Ed25519Signature",
Ed25519Key: "toot:Ed25519Key",
Curve25519Key: "toot:Curve25519Key",
EncryptedMessage: "toot:EncryptedMessage",
publicKeyBase64: "toot:publicKeyBase64",
deviceId: "toot:deviceId",
claim: {
"@type": "@id",
"@id": "toot:claim",
},
fingerprintKey: {
"@type": "@id",
"@id": "toot:fingerprintKey",
},
identityKey: {
"@type": "@id",
"@id": "toot:identityKey",
},
devices: {
"@type": "@id",
"@id": "toot:devices",
},
messageFranking: "toot:messageFranking",
messageType: "toot:messageType",
cipherText: "toot:cipherText",
suspended: "toot:suspended",
Emoji: "toot:Emoji",
focalPoint: {
"@container": "@list",
"@id": "toot:focalPoint",
},
Hashtag: "as:Hashtag",
},
],
id: `${config.http.base_url}/users/${user.username}`,
type: "Person",
preferredUsername: user.username, // TODO: Add user display name
name: user.username,
summary: user.note,
icon: {
type: "Image",
url: user.avatar,
mediaType: "image/png", // TODO: Set user avatar mimetype
},
image: {
type: "Image",
url: user.header,
mediaType: "image/png", // TODO: Set user header mimetype
},
inbox: `${config.http.base_url}/users/${user.username}/inbox`,
outbox: `${config.http.base_url}/users/${user.username}/outbox`,
followers: `${config.http.base_url}/users/${user.username}/followers`,
following: `${config.http.base_url}/users/${user.username}/following`,
liked: `${config.http.base_url}/users/${user.username}/liked`,
discoverable: true,
alsoKnownAs: [
// TODO: Add accounts from which the user migrated
],
manuallyApprovesFollowers: false, // TODO: Change
publicKey: {
id: `${getHost()}${config.http.base_url}/users/${
user.username
}/actor#main-key`,
owner: `${config.http.base_url}/users/${user.username}`,
// Split the public key into PEM format
publicKeyPem: `-----BEGIN PUBLIC KEY-----\n${user.public_key
.match(/.{1,64}/g)
?.join("\n")}\n-----END PUBLIC KEY-----`,
},
tag: [
// TODO: Add emojis here, and hashtags
],
attachment: [
// TODO: Add user attachments (I.E. profile metadata)
],
endpoints: {
sharedInbox: `${config.http.base_url}/inbox`,
},
});
};

View file

@ -1,305 +0,0 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unused-vars */
import { applyConfig } from "@api";
import { getConfig } from "@config";
import { errorResponse, jsonResponse } from "@response";
import {
APAccept,
APActivity,
APActor,
APCreate,
APDelete,
APFollow,
APObject,
APReject,
APTombstone,
APUpdate,
} from "activitypub-types";
import { MatchedRoute } from "bun";
import { RawActivity } from "~database/entities/RawActivity";
import { RawActor } from "~database/entities/RawActor";
import { User } from "~database/entities/User";
export const meta = applyConfig({
allowedMethods: ["POST"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 500,
},
route: "/users/:username/inbox",
});
/**
* ActivityPub user inbox endpoint
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const username = matchedRoute.params.username;
const config = 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: APActivity = await req.json();
// Verify HTTP signature
if (config.activitypub.authorized_fetch) {
// Check if date is older than 30 seconds
const date = new Date(req.headers.get("Date") ?? "");
if (date.getTime() < Date.now() - 30000) {
return errorResponse("Date is too old (max 30 seconds)", 401);
}
const signature = req.headers.get("Signature") ?? "";
const signatureParams = signature
.split(",")
.reduce<Record<string, string>>((params, param) => {
const [key, value] = param.split("=");
params[key] = value.replace(/"/g, "");
return params;
}, {});
const signedString = `(request-target): post /users/${username}/inbox\nhost: ${
config.http.base_url
}\ndate: ${req.headers.get("Date")}`;
const signatureBuffer = new TextEncoder().encode(
signatureParams.signature
);
const signatureBytes = new Uint8Array(signatureBuffer).buffer;
const publicKeyBuffer = (body.actor as any).publicKey.publicKeyPem;
const publicKey = await crypto.subtle.importKey(
"spki",
publicKeyBuffer,
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false,
["verify"]
);
const verified = await crypto.subtle.verify(
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
publicKey,
signatureBytes,
new TextEncoder().encode(signedString)
);
if (!verified) {
return errorResponse("Invalid signature", 401);
}
}
// Get the object's ActivityPub type
const type = body.type;
switch (type) {
case "Create" as APCreate: {
// Body is an APCreate object
// Store the Create object in database
// TODO: Add authentication
// Check is Activity already exists
const activity = await RawActivity.createIfNotExists(body);
if (activity instanceof Response) {
return activity;
}
break;
}
case "Update" as APUpdate: {
// Body is an APUpdate object
// Replace the object in database with the new provided object
// TODO: Add authentication
try {
if (
config.activitypub.discard_updates.includes(
new URL(req.headers.get("Origin") ?? "").hostname
)
) {
// Discard request
return jsonResponse({});
}
} catch (e) {
console.error(
`[-] Error parsing Origin header of incoming Update Activity from ${req.headers.get(
"Origin"
)}`
);
console.error(e);
}
const object = await RawActivity.updateObjectIfExists(
body.object as APObject
);
if (object instanceof Response) {
return object;
}
const activity = await RawActivity.createIfNotExists(body, object);
if (activity instanceof Response) {
return activity;
}
break;
}
case "Delete" as APDelete: {
// Body is an APDelete object
// Delete the object from database
// TODO: Add authentication
try {
if (
config.activitypub.discard_deletes.includes(
new URL(req.headers.get("Origin") ?? "").hostname
)
) {
// Discard request
return jsonResponse({});
}
} catch (e) {
console.error(
`[-] Error parsing Origin header of incoming Delete Activity from ${req.headers.get(
"Origin"
)}`
);
console.error(e);
}
const response = await RawActivity.deleteObjectIfExists(
body.object as APObject
);
if (response instanceof Response) {
return response;
}
// Store the Delete event in the database
const activity = await RawActivity.createIfNotExists(body);
if (activity instanceof Response) {
return activity;
}
break;
}
case "Accept" as APAccept: {
// Body is an APAccept object
// Add the actor to the object actor's followers list
if ((body.object as APFollow).type === "Follow") {
const user = await User.getByActorId(
((body.object as APFollow).actor as APActor).id ?? ""
);
if (!user) {
return errorResponse("User not found", 404);
}
const actor = await RawActor.addIfNotExists(
body.actor as APActor
);
if (actor instanceof Response) {
return actor;
}
// TODO: Add follower
await user.save();
}
break;
}
case "Reject" as APReject: {
// Body is an APReject object
// Mark the follow request as not pending
if ((body.object as APFollow).type === "Follow") {
const user = await User.getByActorId(
((body.object as APFollow).actor as APActor).id ?? ""
);
if (!user) {
return errorResponse("User not found", 404);
}
const actor = await RawActor.addIfNotExists(
body.actor as APActor
);
if (actor instanceof Response) {
return actor;
}
// TODO: Remove follower
await user.save();
}
break;
}
case "Follow" as APFollow: {
// Body is an APFollow object
// Add the actor to the object actor's followers list
try {
if (
config.activitypub.discard_follows.includes(
new URL(req.headers.get("Origin") ?? "").hostname
)
) {
// Reject request
return jsonResponse({});
}
} catch (e) {
console.error(
`[-] Error parsing Origin header of incoming Delete Activity from ${req.headers.get(
"Origin"
)}`
);
console.error(e);
}
const user = await User.getByActorId(
(body.actor as APActor).id ?? ""
);
if (!user) {
return errorResponse("User not found", 404);
}
const actor = await RawActor.addIfNotExists(body.actor as APActor);
if (actor instanceof Response) {
return actor;
}
// TODO: Add follower
await user.save();
break;
}
}
return jsonResponse({});
};

View file

@ -1,163 +0,0 @@
import { errorResponse, jsonLdResponse } from "@response";
import { MatchedRoute } from "bun";
import { User, userRelations } from "~database/entities/User";
import { getHost } from "@config";
import { NodeObject, compact } from "jsonld";
import { RawActivity } from "~database/entities/RawActivity";
import { applyConfig } from "@api";
export const meta = applyConfig({
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 500,
},
route: "/users/:username/outbox",
});
/**
* ActivityPub user outbox endpoint
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const username = matchedRoute.params.username.split("@")[0];
const page = Boolean(matchedRoute.query.page || "false");
const min_id = matchedRoute.query.min_id || false;
const max_id = matchedRoute.query.max_id || false;
const user = await User.findOne({
where: { username },
relations: userRelations,
});
if (!user) {
return errorResponse("User not found", 404);
}
// Get the user's corresponding ActivityPub notes
const count = await RawActivity.count({
where: {
data: {
attributedTo: `${getHost()}/@${user.username}`,
},
},
order: {
data: {
published: "DESC",
},
},
});
const lastPost = (
await RawActivity.find({
where: {
data: {
attributedTo: `${getHost()}/@${user.username}`,
},
},
order: {
data: {
published: "ASC",
},
},
take: 1,
})
)[0];
if (!page)
return jsonLdResponse(
await compact({
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
],
id: `${getHost()}/@${user.username}/inbox`,
type: "OrderedCollection",
totalItems: count,
first: `${getHost()}/@${user.username}/outbox?page=true`,
last: `${getHost()}/@${user.username}/outbox?min_id=${
lastPost.id
}&page=true`,
})
);
else {
let posts: RawActivity[] = [];
if (min_id) {
posts = await RawActivity.find({
where: {
data: {
attributedTo: `${getHost()}/@${user.username}`,
id: min_id,
},
},
order: {
data: {
published: "DESC",
},
},
take: 11, // Take one extra to have the ID of the next post
});
} else if (max_id) {
posts = await RawActivity.find({
where: {
data: {
attributedTo: `${getHost()}/@${user.username}`,
id: max_id,
},
},
order: {
data: {
published: "ASC",
},
},
take: 10,
});
}
return jsonLdResponse(
await compact({
"@context": [
"https://www.w3.org/ns/activitystreams",
{
ostatus: "http://ostatus.org#",
atomUri: "ostatus:atomUri",
inReplyToAtomUri: "ostatus:inReplyToAtomUri",
conversation: "ostatus:conversation",
sensitive: "as:sensitive",
toot: "http://joinmastodon.org/ns#",
votersCount: "toot:votersCount",
litepub: "http://litepub.social/ns#",
directMessage: "litepub:directMessage",
Emoji: "toot:Emoji",
focalPoint: {
"@container": "@list",
"@id": "toot:focalPoint",
},
blurhash: "toot:blurhash",
},
],
id: `${getHost()}/@${user.username}/inbox`,
type: "OrderedCollectionPage",
totalItems: count,
partOf: `${getHost()}/@${user.username}/inbox`,
// Next is less recent posts chronologically, uses min_id
next: `${getHost()}/@${user.username}/outbox?min_id=${
posts[posts.length - 1].id
}&page=true`,
// Prev is more recent posts chronologically, uses max_id
prev: `${getHost()}/@${user.username}/outbox?max_id=${
posts[0].id
}&page=true`,
orderedItems: posts
.slice(0, 10)
.map(post => post.data) as NodeObject[],
})
);
}
};

View file

@ -0,0 +1,224 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unused-vars */
import { applyConfig } from "@api";
import { getConfig } from "@config";
import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { Status } from "~database/entities/Status";
import { User, userRelations } from "~database/entities/User";
import {
ContentFormat,
LysandAction,
LysandObjectType,
LysandPublication,
} from "~types/lysand/Object";
export const meta = applyConfig({
allowedMethods: ["POST"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 500,
},
route: "/users/:username/inbox",
});
/**
* ActivityPub user inbox endpoint
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const username = matchedRoute.params.username;
const config = 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 User.findOne({
where: {
uri: body.author,
},
relations: 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(
"raw",
Buffer.from(author.public_key, "base64"),
{
name: "ed25519",
},
false,
["verify"]
);
// Check if signed string is valid
const isValid = await crypto.subtle.verify(
{
name: "ed25519",
saltLength: 0,
},
publicKey,
new TextEncoder().encode(signature),
new TextEncoder().encode(expectedSignedString)
);
if (!isValid) {
throw new Error("Invalid signature");
}
}
// Get the object's ActivityPub type
const type = body.type;
switch (type) {
case "Note": {
let content: ContentFormat | null;
// Find the best content and content type
if (
body.contents.find(
c => c.content_type === "text/x.misskeymarkdown"
)
) {
content =
body.contents.find(
c => c.content_type === "text/x.misskeymarkdown"
) || null;
} else if (
body.contents.find(c => c.content_type === "text/html")
) {
content =
body.contents.find(c => c.content_type === "text/html") ||
null;
} else if (
body.contents.find(c => c.content_type === "text/markdown")
) {
content =
body.contents.find(
c => c.content_type === "text/markdown"
) || null;
} else if (
body.contents.find(c => c.content_type === "text/plain")
) {
content =
body.contents.find(c => c.content_type === "text/plain") ||
null;
} else {
content = body.contents[0] || null;
}
const status = await Status.createNew({
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,
// TODO: Add emojis
emojis: [],
});
break;
}
case "Patch": {
break;
}
case "Like": {
break;
}
case "Dislike": {
break;
}
case "Follow": {
break;
}
case "FollowAccept": {
break;
}
case "FollowReject": {
break;
}
case "Announce": {
break;
}
case "Undo": {
break;
}
default: {
return errorResponse("Invalid type", 400);
}
}
return jsonResponse({});
};

View file

@ -0,0 +1,44 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unused-vars */
import { applyConfig } from "@api";
import { getConfig } from "@config";
import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { User, userRelations } from "~database/entities/User";
export const meta = applyConfig({
allowedMethods: ["POST"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 500,
},
route: "/users/:uuid",
});
/**
* ActivityPub user inbox endpoint
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const uuid = matchedRoute.params.uuid;
const config = getConfig();
const user = await User.findOne({
where: {
id: uuid,
},
relations: userRelations,
});
if (!user) {
return errorResponse("User not found", 404);
}
return jsonResponse(user.toLysand());
};

View file

@ -0,0 +1,67 @@
import { jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { userRelations } from "~database/entities/User";
import { getHost } from "@config";
import { applyConfig } from "@api";
import { Status } from "~database/entities/Status";
import { In } from "typeorm";
export const meta = applyConfig({
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 500,
},
route: "/users/:uuid/outbox",
});
/**
* ActivityPub user outbox endpoint
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const uuid = matchedRoute.params.uuid;
const pageNumber = Number(matchedRoute.query.page) || 1;
const statuses = await Status.find({
where: {
account: {
id: uuid,
},
visibility: In(["public", "unlisted"]),
},
relations: userRelations,
take: 20,
skip: 20 * (pageNumber - 1),
});
const totalStatuses = await Status.count({
where: {
account: {
id: uuid,
},
visibility: In(["public", "unlisted"]),
},
relations: userRelations,
});
return jsonResponse({
first: `${getHost()}/users/${uuid}/outbox?page=1`,
last: `${getHost()}/users/${uuid}/outbox?page=1`,
total_items: totalStatuses,
next:
statuses.length === 20
? `${getHost()}/users/${uuid}/outbox?page=${pageNumber + 1}`
: undefined,
prev:
pageNumber > 1
? `${getHost()}/users/${uuid}/outbox?page=${pageNumber - 1}`
: undefined,
items: statuses.map(s => s.toLysand()),
});
};