Refactor configs and activitypub parts

This commit is contained in:
Jesse Wierzbinski 2023-10-15 20:04:03 -10:00
parent ca7d325cb1
commit c0ff46559b
17 changed files with 251 additions and 70 deletions

18
benchmarks/fetch.ts Normal file
View file

@ -0,0 +1,18 @@
const timeBefore = performance.now();
const requests: Promise<Response>[] = [];
// Repeat 1000 times
for (let i = 0; i < 1000; i++) {
requests.push(
fetch(`https://mastodon.social`, {
method: "GET",
})
);
}
await Promise.all(requests);
const timeAfter = performance.now();
console.log(`Time taken: ${timeAfter - timeBefore}ms`);

View file

@ -72,7 +72,7 @@ const timeAfter = performance.now();
const activities = await RawActivity.createQueryBuilder("activity")
.where("activity.data->>'actor' = :actor", {
actor: `${config.http.base_url}/@test`,
actor: `${config.http.base_url}/users/test`,
})
.leftJoinAndSelect("activity.objects", "objects")
.getMany();

View file

@ -13,6 +13,25 @@ bind_port = "8080"
# Bans IPv4 or IPv6 IPs (wildcards, networks and ranges are supported)
banned_ips = []
[smtp]
# SMTP server to use for sending emails
server = "smtp.example.com"
port = 465
username = "test@example.com"
password = "password123"
tls = true
[email]
# Sends an email to moderators when a report is received
# NOT IMPLEMENTED
send_on_report = false
# Sends an email to moderators when a user is suspended
# NOT IMPLEMENTED
send_on_suspend = false
# Sends an email to moderators when a user is unsuspended
# NOT IMPLEMENTED
send_on_unsuspend = false
[validation]
# Self explanatory
max_displayname_size = 50
@ -20,9 +39,9 @@ max_bio_size = 160
max_note_size = 5000
max_avatar_size = 5_000_000
max_header_size = 5_000_000
max_media_size = 40_000_000
max_media_attachments = 4
max_media_description_size = 1000
max_media_size = 40_000_000 # MEDIA NOT IMPLEMENTED
max_media_attachments = 4 # MEDIA NOT IMPLEMENTED
max_media_description_size = 1000 # MEDIA NOT IMPLEMENTED
max_username_size = 30
# An array of strings, defaults are from Akkoma
username_blacklist = [ ".well-known", "~", "about", "activities" , "api",
@ -36,12 +55,12 @@ email_blacklist = []
# Valid URL schemes, otherwise the URL is parsed as text
url_scheme_whitelist = [ "http", "https", "ftp", "dat", "dweb", "gopher", "hyper",
"ipfs", "ipns", "irc", "xmpp", "ircs", "magnet", "mailto", "mumble", "ssb",
"gemini" ]
"gemini" ] # NOT IMPLEMENTED
allowed_mime_types = [ "image/jpeg", "image/png", "image/gif", "image/heic", "image/heif",
"image/webp", "image/avif", "video/webm", "video/mp4", "video/quicktime", "video/ogg",
"audio/wave", "audio/wav", "audio/x-wav", "audio/x-pn-wave", "audio/vnd.wave",
"audio/ogg", "audio/vorbis", "audio/mpeg", "audio/mp3", "audio/webm", "audio/flac",
"audio/aac", "audio/m4a", "audio/x-m4a", "audio/mp4", "audio/3gpp", "video/x-ms-asf" ]
"audio/aac", "audio/m4a", "audio/x-m4a", "audio/mp4", "audio/3gpp", "video/x-ms-asf" ] # MEDIA NOT IMPLEMENTED
[defaults]
# Default visibility for new notes
@ -58,25 +77,29 @@ header = ""
use_tombstones = true
# Fetch all members of collections (followers, following, etc) when receiving them
# WARNING: This can be a lot of data, and is not recommended
fetch_all_collection_members = false
fetch_all_collection_members = false # NOT IMPLEMENTED
# The following values must be instance domain names without "https" or glob patterns
# Rejects all activities from these instances, simply doesnt save them at all
# Rejects all activities from these instances (fediblocking)
reject_activities = []
# Force posts from this instance to be followers only
force_followers_only = []
force_followers_only = [] # NOT IMPLEMENTED
# Discard all reports from these instances
discard_reports = []
discard_reports = [] # NOT IMPLEMENTED
# Discard all deletes from these instances
discard_deletes = []
# Discard all updates (edits) from these instances
discard_updates = []
# Discard all banners from these instances
discard_banners = []
discard_banners = [] # NOT IMPLEMENTED
# Discard all avatars from these instances
discard_avatars = []
discard_avatars = [] # NOT IMPLEMENTED
# Discard all follow requests from these instances
discard_follows = []
# Force set these instances' media as sensitive
force_sensitive = []
force_sensitive = [] # NOT IMPLEMENTED
# Remove theses instances' media
remove_media = []
remove_media = [] # NOT IMPLEMENTED
[filters]
@ -91,7 +114,7 @@ username_filters = []
displayname_filters = []
# Drop users with these regex filters (only applies to new activities)
bio_filters = []
emoji_filters = []
emoji_filters = [] # NOT IMPLEMENTED
[logging]
# Log all requests (warning: this is a lot of data)

View file

@ -116,7 +116,7 @@ export class RawActor extends BaseEntity {
username: preferredUsername ?? "",
display_name: name ?? preferredUsername ?? "",
note: summary ?? "",
url: `${config.http.base_url}/@${preferredUsername}${
url: `${config.http.base_url}/users/${preferredUsername}${
isLocalUser ? "" : `@${this.getInstanceDomain()}`
}`,
avatar:

View file

@ -220,7 +220,7 @@ export class Status extends BaseEntity {
}
newStatus.object.data = {
id: `${config.http.base_url}/@${data.account.username}/statuses/${newStatus.id}`,
id: `${config.http.base_url}/users/${data.account.username}/statuses/${newStatus.id}`,
type: "Note",
summary: data.spoiler_text,
content: data.content, // TODO: Format as HTML
@ -229,14 +229,14 @@ export class Status extends BaseEntity {
: undefined,
published: new Date().toISOString(),
tag: [],
attributedTo: `${config.http.base_url}/@${data.account.username}`,
attributedTo: `${config.http.base_url}/users/${data.account.username}`,
};
// Get people mentioned in the content
const mentionedPeople = [
...data.content.matchAll(/@([a-zA-Z0-9_]+)/g),
].map(match => {
return `${config.http.base_url}/@${match[1]}`;
return `${config.http.base_url}/users/${match[1]}`;
});
// Map this to Users
@ -286,7 +286,7 @@ export class Status extends BaseEntity {
if (data.visibility === "private") {
newStatus.object.data.cc = [
`${config.http.base_url}/@${data.account.username}/followers`,
`${config.http.base_url}/users/${data.account.username}/followers`,
];
} else if (data.visibility === "direct") {
// Add nothing else
@ -296,7 +296,7 @@ export class Status extends BaseEntity {
"https://www.w3.org/ns/activitystreams#Public",
];
newStatus.object.data.cc = [
`${config.http.base_url}/@${data.account.username}/followers`,
`${config.http.base_url}/users/${data.account.username}/followers`,
];
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition

View file

@ -379,7 +379,7 @@ export class User extends BaseEntity {
// Check if actor exists
const actorExists = await RawActor.getByActorId(
`${config.http.base_url}/@${this.username}`
`${config.http.base_url}/users/${this.username}`
);
let actor: RawActor;
@ -395,14 +395,14 @@ export class User extends BaseEntity {
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
],
id: `${config.http.base_url}/@${this.username}`,
id: `${config.http.base_url}/users/${this.username}`,
type: "Person",
preferredUsername: this.username,
name: this.display_name,
inbox: `${config.http.base_url}/@${this.username}/inbox`,
outbox: `${config.http.base_url}/@${this.username}/outbox`,
followers: `${config.http.base_url}/@${this.username}/followers`,
following: `${config.http.base_url}/@${this.username}/following`,
inbox: `${config.http.base_url}/users/${this.username}/inbox`,
outbox: `${config.http.base_url}/users/${this.username}/outbox`,
followers: `${config.http.base_url}/users/${this.username}/followers`,
following: `${config.http.base_url}/users/${this.username}/following`,
manuallyApprovesFollowers: false,
summary: this.note,
icon: {
@ -414,8 +414,8 @@ export class User extends BaseEntity {
url: this.header,
},
publicKey: {
id: `${config.http.base_url}/@${this.username}/actor#main-key`,
owner: `${config.http.base_url}/@${this.username}/actor`,
id: `${config.http.base_url}/users/${this.username}/actor#main-key`,
owner: `${config.http.base_url}/users/${this.username}/actor`,
publicKeyPem: this.public_key,
},
} as APActor;

View file

@ -1,7 +1,7 @@
import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { User } from "~database/entities/User";
import { getHost } from "@config";
import { getConfig, getHost } from "@config";
/**
* ActivityPub WebFinger endpoint
@ -14,6 +14,8 @@ export default async (
const resource = matchedRoute.query.resource;
const requestedUser = resource.split("acct:")[1];
const config = getConfig();
// Check if user is a local user
if (requestedUser.split("@")[1] !== getHost()) {
return errorResponse("User is a remote user", 404);
@ -32,17 +34,17 @@ export default async (
{
rel: "self",
type: "application/activity+json",
href: `https://${getHost()}/@${user.username}/actor`
href: `${config.http.base_url}/users/${user.username}/actor`
},
{
rel: "https://webfinger.net/rel/profile-page",
type: "text/html",
href: `https://${getHost()}/@${user.username}`
href: `${config.http.base_url}/users/${user.username}`
},
{
rel: "self",
type: "application/activity+json; profile=\"https://www.w3.org/ns/activitystreams\"",
href: `https://${getHost()}/@${user.username}/actor`
href: `${config.http.base_url}/users/${user.username}/actor`
}
]
})

View file

@ -74,8 +74,6 @@ export default async (req: Request): Promise<Response> => {
}
);
config.validation.max_username_size;
// Check if username is valid
if (!body.username?.match(/^[a-zA-Z0-9_]+$/))
errors.details.username.push({
@ -83,6 +81,18 @@ export default async (req: Request): Promise<Response> => {
description: `must only contain letters, numbers, and underscores`,
});
// Check if username doesnt match filters
if (
config.filters.username_filters.some(
filter => body.username?.match(filter)
)
) {
errors.details.username.push({
error: "ERR_INVALID",
description: `contains blocked words`,
});
}
// Check if username is too long
if ((body.username?.length ?? 0) > config.validation.max_username_size)
errors.details.username.push({

View file

@ -62,6 +62,15 @@ export default async (req: Request): Promise<Response> => {
);
}
// Check if display name doesnt match filters
if (
config.filters.displayname_filters.some(filter =>
display_name.match(filter)
)
) {
return errorResponse("Display name contains blocked words", 422);
}
user.actor.data.name = display_name;
user.display_name = display_name;
}
@ -75,6 +84,11 @@ export default async (req: Request): Promise<Response> => {
);
}
// Check if bio doesnt match filters
if (config.filters.bio_filters.some(filter => note.match(filter))) {
return errorResponse("Bio contains blocked words", 422);
}
user.actor.data.summary = note;
user.note = note;
}

View file

@ -125,6 +125,11 @@ export default async (req: Request): Promise<Response> => {
);
}
// Check if status body doesnt match filters
if (config.filters.note_filters.some(filter => status.match(filter))) {
return errorResponse("Status contains blocked words", 422);
}
// Create status
const newStatus = await Status.createNew({
account: user,

View file

@ -89,7 +89,7 @@ export default async (
Hashtag: "as:Hashtag",
},
],
id: `${config.http.base_url}/@${user.username}`,
id: `${config.http.base_url}/users/${user.username}`,
type: "Person",
preferredUsername: user.username, // TODO: Add user display name
name: user.username,
@ -104,21 +104,21 @@ export default async (
url: user.header,
mediaType: "image/png", // TODO: Set user header mimetype
},
inbox: `${config.http.base_url}/@${user.username}/inbox`,
outbox: `${config.http.base_url}/@${user.username}/outbox`,
followers: `${config.http.base_url}/@${user.username}/followers`,
following: `${config.http.base_url}/@${user.username}/following`,
liked: `${config.http.base_url}/@${user.username}/liked`,
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}/@${
id: `${getHost()}${config.http.base_url}/users/${
user.username
}/actor#main-key`,
owner: `${config.http.base_url}/@${user.username}`,
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)

View file

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unused-vars */
import { getConfig } from "@config";
import { errorResponse, jsonResponse } from "@response";
@ -30,11 +31,61 @@ export default async (
return errorResponse("Method not allowed", 405);
}
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
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)
);
// Get the object's ActivityPub type
const type = body.type;
@ -57,6 +108,24 @@ export default async (
// 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
);
@ -78,6 +147,24 @@ export default async (
// 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
);
@ -152,6 +239,24 @@ export default async (
// 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 ?? ""
);

View file

@ -23,7 +23,7 @@ beforeAll(async () => {
describe("POST /@test/actor", () => {
test("should return a valid ActivityPub Actor when querying an existing user", async () => {
const response = await fetch(`${config.http.base_url}/@test/actor`, {
const response = await fetch(`${config.http.base_url}/users/test/actor`, {
method: "GET",
headers: {
Accept: "application/activity+json",
@ -38,16 +38,16 @@ describe("POST /@test/actor", () => {
const actor: APActor = await response.json();
expect(actor.type).toBe("Person");
expect(actor.id).toBe(`${config.http.base_url}/@test`);
expect(actor.id).toBe(`${config.http.base_url}/users/test`);
expect(actor.preferredUsername).toBe("test");
expect(actor.inbox).toBe(`${config.http.base_url}/@test/inbox`);
expect(actor.outbox).toBe(`${config.http.base_url}/@test/outbox`);
expect(actor.followers).toBe(`${config.http.base_url}/@test/followers`);
expect(actor.following).toBe(`${config.http.base_url}/@test/following`);
expect(actor.inbox).toBe(`${config.http.base_url}/users/test/inbox`);
expect(actor.outbox).toBe(`${config.http.base_url}/users/test/outbox`);
expect(actor.followers).toBe(`${config.http.base_url}/users/test/followers`);
expect(actor.following).toBe(`${config.http.base_url}/users/test/following`);
expect((actor as any).publicKey).toBeDefined();
expect((actor as any).publicKey.id).toBeDefined();
expect((actor as any).publicKey.owner).toBe(
`${config.http.base_url}/@test`
`${config.http.base_url}/users/test`
);
expect((actor as any).publicKey.publicKeyPem).toBeDefined();
expect((actor as any).publicKey.publicKeyPem).toMatch(
@ -64,7 +64,7 @@ afterAll(async () => {
const activities = await RawActivity.createQueryBuilder("activity")
.where("activity.data->>'actor' = :actor", {
actor: `${config.http.base_url}/@test`,
actor: `${config.http.base_url}/users/test`,
})
.leftJoinAndSelect("activity.objects", "objects")
.getMany();

View file

@ -200,7 +200,7 @@ describe("GET /api/v1/accounts/verify_credentials", () => {
expect(account.following_count).toBe(0);
expect(account.statuses_count).toBe(0);
expect(account.note).toBe("");
expect(account.url).toBe(`${config.http.base_url}/@${user.username}`);
expect(account.url).toBe(`${config.http.base_url}/users/${user.username}`);
expect(account.avatar).toBeDefined();
expect(account.avatar_static).toBeDefined();
expect(account.header).toBeDefined();
@ -719,7 +719,7 @@ describe("GET /api/v1/custom_emojis", () => {
afterAll(async () => {
const activities = await RawActivity.createQueryBuilder("activity")
.where("activity.data->>'actor' = :actor", {
actor: `${config.http.base_url}/@test`,
actor: `${config.http.base_url}/users/test`,
})
.leftJoinAndSelect("activity.objects", "objects")
.getMany();

View file

@ -23,7 +23,7 @@ describe("POST /@test/inbox", () => {
test("should store a new Note object", async () => {
const activityId = `https://example.com/objects/${crypto.randomUUID()}`;
const response = await fetch(`${config.http.base_url}/@test/inbox/`, {
const response = await fetch(`${config.http.base_url}/users/test/inbox/`, {
method: "POST",
headers: {
"Content-Type": "application/activity+json",
@ -33,7 +33,7 @@ describe("POST /@test/inbox", () => {
type: "Create",
id: activityId,
actor: {
id: `${config.http.base_url}/@test`,
id: `${config.http.base_url}/users/test`,
type: "Person",
preferredUsername: "test",
},
@ -82,7 +82,7 @@ describe("POST /@test/inbox", () => {
test("should try to update that Note object", async () => {
const activityId = `https://example.com/objects/${crypto.randomUUID()}`;
const response = await fetch(`${config.http.base_url}/@test/inbox/`, {
const response = await fetch(`${config.http.base_url}/users/test/inbox/`, {
method: "POST",
headers: {
"Content-Type": "application/activity+json",
@ -92,7 +92,7 @@ describe("POST /@test/inbox", () => {
type: "Update",
id: activityId,
actor: {
id: `${config.http.base_url}/@test`,
id: `${config.http.base_url}/users/test`,
type: "Person",
preferredUsername: "test",
},
@ -140,7 +140,7 @@ describe("POST /@test/inbox", () => {
test("should delete the Note object", async () => {
const activityId = `https://example.com/objects/${crypto.randomUUID()}`;
const response = await fetch(`${config.http.base_url}/@test/inbox/`, {
const response = await fetch(`${config.http.base_url}/users/test/inbox/`, {
method: "POST",
headers: {
"Content-Type": "application/activity+json",
@ -150,7 +150,7 @@ describe("POST /@test/inbox", () => {
type: "Delete",
id: activityId,
actor: {
id: `${config.http.base_url}/@test`,
id: `${config.http.base_url}/users/test`,
type: "Person",
preferredUsername: "test",
},
@ -187,17 +187,17 @@ describe("POST /@test/inbox", () => {
expect(activity?.actors).toHaveLength(1);
expect(activity?.actors[0].data).toEqual({
preferredUsername: "test",
id: `${config.http.base_url}/@test`,
id: `${config.http.base_url}/users/test`,
summary: "",
publicKey: {
id: `${config.http.base_url}/@test/actor#main-key`,
owner: `${config.http.base_url}/@test/actor`,
id: `${config.http.base_url}/users/test/actor#main-key`,
owner: `${config.http.base_url}/users/test/actor`,
publicKeyPem: expect.any(String),
},
outbox: `${config.http.base_url}/@test/outbox`,
outbox: `${config.http.base_url}/users/test/outbox`,
manuallyApprovesFollowers: false,
followers: `${config.http.base_url}/@test/followers`,
following: `${config.http.base_url}/@test/following`,
followers: `${config.http.base_url}/users/test/followers`,
following: `${config.http.base_url}/users/test/following`,
name: "",
"@context": [
"https://www.w3.org/ns/activitystreams",
@ -211,7 +211,7 @@ describe("POST /@test/inbox", () => {
type: "Image",
url: "",
},
inbox: `${config.http.base_url}/@test/inbox`,
inbox: `${config.http.base_url}/users/test/inbox`,
type: "Person",
});
@ -226,7 +226,7 @@ describe("POST /@test/inbox", () => {
test("should return a 404 error when trying to delete a non-existent Note object", async () => {
const activityId = `https://example.com/objects/${crypto.randomUUID()}`;
const response = await fetch(`${config.http.base_url}/@test/inbox/`, {
const response = await fetch(`${config.http.base_url}/users/test/inbox/`, {
method: "POST",
headers: {
"Content-Type": "application/activity+json",
@ -236,7 +236,7 @@ describe("POST /@test/inbox", () => {
type: "Delete",
id: activityId,
actor: {
id: `${config.http.base_url}/@test`,
id: `${config.http.base_url}/users/test`,
type: "Person",
preferredUsername: "test",
},
@ -274,10 +274,10 @@ afterAll(async () => {
.leftJoinAndSelect("activity.objects", "objects")
.leftJoinAndSelect("activity.actors", "actors")
// activity.actors is a many-to-many relationship with Actor objects (it is an array of Actor objects)
// Get the actors of the activity that have data.id as `${config.http.base_url}/@test`
// Get the actors of the activity that have data.id as `${config.http.base_url}/users/test`
.where("actors.data @> :data", {
data: JSON.stringify({
id: `${config.http.base_url}/@test`,
id: `${config.http.base_url}/users/test`,
}),
})
.getMany();

View file

@ -50,6 +50,8 @@ export interface ConfigType {
discard_deletes: string[];
discard_banners: string[];
discard_avatars: string[];
discard_updates: string[];
discard_follows: string[];
force_sensitive: string[];
remove_media: string[];
fetch_all_colletion_members: boolean;
@ -178,6 +180,8 @@ export const configDefaults: ConfigType = {
discard_banners: [],
discard_avatars: [],
force_sensitive: [],
discard_updates: [],
discard_follows: [],
remove_media: [],
fetch_all_colletion_members: false,
},