mirror of
https://github.com/versia-pub/server.git
synced 2026-03-13 13:59:16 +01:00
More work on converting to the Lysand protocol
This commit is contained in:
parent
02b56f8fde
commit
77a675afe6
25 changed files with 1181 additions and 807 deletions
50
server/api/.well-known/lysand.ts
Normal file
50
server/api/.well-known/lysand.ts
Normal 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
|
||||
})
|
||||
};
|
||||
|
|
@ -59,8 +59,6 @@ export default async (
|
|||
|
||||
relationship.note = comment ?? "";
|
||||
|
||||
// TODO: Implement duration
|
||||
|
||||
await relationship.save();
|
||||
return jsonResponse(await relationship.toAPI());
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(", "),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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({});
|
||||
};
|
||||
|
|
@ -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[],
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
224
server/api/users/uuid/inbox/index.ts
Normal file
224
server/api/users/uuid/inbox/index.ts
Normal 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({});
|
||||
};
|
||||
44
server/api/users/uuid/index.ts
Normal file
44
server/api/users/uuid/index.ts
Normal 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());
|
||||
};
|
||||
67
server/api/users/uuid/outbox/index.ts
Normal file
67
server/api/users/uuid/outbox/index.ts
Normal 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()),
|
||||
});
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue