Refactor code, add more filtering

This commit is contained in:
Jesse Wierzbinski 2023-09-17 19:38:08 -10:00
parent 768d1858dc
commit 4d0283caf0
No known key found for this signature in database
GPG key ID: F9A1E418934E40B0
8 changed files with 397 additions and 151 deletions

View file

@ -6,8 +6,10 @@ import {
ManyToMany,
PrimaryGeneratedColumn,
} from "typeorm";
import { APActivity } from "activitypub-types";
import { APActivity, APActor, APObject, APTombstone } from "activitypub-types";
import { RawObject } from "./RawObject";
import { RawActor } from "./RawActor";
import { getConfig } from "@config";
/**
* Stores an ActivityPub activity as raw JSON-LD data
@ -26,4 +28,189 @@ export class RawActivity extends BaseEntity {
@ManyToMany(() => RawObject, object => object.id)
@JoinTable()
objects!: RawObject[];
@ManyToMany(() => RawActor, actor => actor.id)
@JoinTable()
actors!: RawActor[];
static async getByObjectId(id: string) {
return await RawActivity.createQueryBuilder("activity")
// Objects is a many-to-many relationship
.leftJoinAndSelect("activity.objects", "objects")
.leftJoinAndSelect("activity.actors", "actors")
.where("objects.data @> :data", {
data: JSON.stringify({
id,
}),
})
.getMany();
}
static async getById(id: string) {
return await RawActivity.createQueryBuilder("activity")
.leftJoinAndSelect("activity.objects", "objects")
.leftJoinAndSelect("activity.actors", "actors")
.where("activity.data->>'id' = :id", {
id,
})
.getOne();
}
static async getLatestById(id: string) {
return await RawActivity.createQueryBuilder("activity")
// Where id is part of the jsonb column 'data'
.where("activity.data->>'id' = :id", {
id,
})
.leftJoinAndSelect("activity.objects", "objects")
.leftJoinAndSelect("activity.actors", "actors")
// Sort by most recent
.orderBy("activity.data->>'published'", "DESC")
.getOne();
}
static async exists(id: string) {
return !!(await RawActivity.getById(id));
}
static async updateObjectIfExists(object: APObject) {
const rawObject = await RawObject.getById(object.id ?? "");
if (rawObject) {
rawObject.data = object;
// Check if object body contains any filtered terms
if (await rawObject.isObjectFiltered())
return new Error("Object filtered");
await rawObject.save();
return rawObject;
} else {
return new Error("Object does not exist");
}
}
static async deleteObjectIfExists(object: APObject) {
const dbObject = await RawObject.getById(object.id ?? "");
if (!dbObject) return new Error("Object not found");
const config = getConfig();
if (config.activitypub.use_tombstones) {
dbObject.data = {
...dbObject.data,
type: "Tombstone",
deleted: new Date(),
formerType: dbObject.data.type,
} as APTombstone;
await dbObject.save();
} else {
const activities = await RawActivity.getByObjectId(object.id ?? "");
activities.forEach(
activity =>
(activity.objects = activity.objects.filter(
o => o.id !== object.id
))
);
await Promise.all(
activities.map(async activity => await activity.save())
);
await dbObject.remove();
}
return dbObject;
}
static async addIfNotExists(activity: APActivity) {
if (!(await RawActivity.exists(activity.id ?? ""))) {
const rawActivity = new RawActivity();
rawActivity.data = {
...activity,
object: undefined,
actor: undefined,
};
const actor = await rawActivity.addActorIfNotExists(
activity.actor as APActor
);
if (actor instanceof Error) {
return actor;
}
const object = await rawActivity.addObjectIfNotExists(
activity.object as APObject
);
if (object instanceof Error) {
return object;
}
await rawActivity.save();
return rawActivity;
} else {
return new Error("Activity already exists");
}
}
async addObjectIfNotExists(object: APObject) {
if (!this.objects.some(o => o.data.id === object.id)) {
const rawObject = new RawObject();
rawObject.data = object;
// Check if object body contains any filtered terms
if (await rawObject.isObjectFiltered())
return new Error("Object filtered");
await rawObject.save();
this.objects.push(rawObject);
return rawObject;
} else {
return new Error("Object already exists");
}
}
async addActorIfNotExists(actor: APActor) {
if (!this.actors.some(a => a.data.id === actor.id)) {
const rawActor = new RawActor();
rawActor.data = actor;
const config = getConfig();
if (
config.activitypub.discard_avatars.find(
instance => actor.id?.includes(instance)
)
) {
rawActor.data.icon = undefined;
}
if (
config.activitypub.discard_banners.find(
instance => actor.id?.includes(instance)
)
) {
rawActor.data.image = undefined;
}
if (await rawActor.isObjectFiltered()) {
return new Error("Actor filtered");
}
await rawActor.save();
this.actors.push(rawActor);
return rawActor;
} else {
return new Error("Actor already exists");
}
}
}

View file

@ -0,0 +1,79 @@
import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm";
import { APActor } from "activitypub-types";
import { getConfig } from "@config";
import { appendFile } from "fs/promises";
/**
* Stores an ActivityPub actor as raw JSON-LD data
*/
@Entity({
name: "actors",
})
export class RawActor extends BaseEntity {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column("jsonb")
data!: APActor;
static async getById(id: string) {
return await RawActor.createQueryBuilder("actor")
.where("actor.data->>'id' = :id", {
id,
})
.getOne();
}
async isObjectFiltered() {
const config = getConfig();
const usernameFilterResult = await Promise.all(
config.filters.username_filters.map(async filter => {
if (
this.data.type === "Person" &&
this.data.preferredUsername?.match(filter)
) {
// Log filter
if (config.logging.log_filters)
await appendFile(
process.cwd() + "/logs/filters.log",
`${new Date().toISOString()} Filtered actor username: "${
this.data.preferredUsername
}" (ID: ${this.data.id}) based on rule: ${filter}\n`
);
return true;
}
})
);
const displayNameFilterResult = await Promise.all(
config.filters.displayname_filters.map(async filter => {
if (
this.data.type === "Person" &&
this.data.name?.match(filter)
) {
// Log filter
if (config.logging.log_filters)
await appendFile(
process.cwd() + "/logs/filters.log",
`${new Date().toISOString()} Filtered actor username: "${
this.data.preferredUsername
}" (ID: ${this.data.id}) based on rule: ${filter}\n`
);
return true;
}
})
);
return (
usernameFilterResult.includes(true) ||
displayNameFilterResult.includes(true)
);
}
static async exists(id: string) {
return !!(await RawActor.getById(id));
}
}

View file

@ -1,5 +1,7 @@
import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm";
import { APObject } from "activitypub-types";
import { getConfig } from "@config";
import { appendFile } from "fs/promises";
/**
* Stores an ActivityPub object as raw JSON-LD data
@ -13,4 +15,45 @@ export class RawObject extends BaseEntity {
@Column("jsonb")
data!: APObject;
static async getById(id: string) {
return await RawObject.createQueryBuilder("object")
.where("object.data->>'id' = :id", {
id,
})
.getOne();
}
async isObjectFiltered() {
const config = getConfig();
const filter_result = await Promise.all(
config.filters.note_filters.map(async filter => {
if (
this.data.type === "Note" &&
this.data.content?.match(filter)
) {
// Log filter
if (config.logging.log_filters)
await appendFile(
process.cwd() + "/logs/filters.log",
`${new Date().toISOString()} Filtered note content: "${this.data.content.replaceAll(
"\n",
" "
)}" (ID: ${
this.data.id
}) based on rule: ${filter}\n`
);
return true;
}
})
);
return filter_result.includes(true);
}
static async exists(id: string) {
return !!(await RawObject.getById(id));
}
}

View file

@ -50,6 +50,48 @@ export class User extends BaseEntity {
@UpdateDateColumn()
updated_at!: Date;
@Column("varchar")
public_key!: string;
@Column("varchar")
private_key!: string;
async generateKeys(): Promise<void> {
// openssl genrsa -out private.pem 2048
// openssl rsa -in private.pem -outform PEM -pubout -out public.pem
const keys = await crypto.subtle.generateKey(
{
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
modulusLength: 4096,
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
},
true,
["sign", "verify"]
);
const privateKey = btoa(
String.fromCharCode.apply(null, [
...new Uint8Array(
await crypto.subtle.exportKey("pkcs8", keys.privateKey)
),
])
);
const publicKey = btoa(
String.fromCharCode(
...new Uint8Array(
await crypto.subtle.exportKey("spki", keys.publicKey)
)
)
);
// Add header, footer and newlines later on
// These keys are PEM encrypted
this.private_key = privateKey;
this.public_key = publicKey;
}
// eslint-disable-next-line @typescript-eslint/require-await
async toAPI(): Promise<APIAccount> {
return {

View file

@ -13,9 +13,7 @@ import {
APUpdate,
} from "activitypub-types";
import { MatchedRoute } from "bun";
import { appendFile } from "fs/promises";
import { RawActivity } from "~database/entities/RawActivity";
import { RawObject } from "~database/entities/RawObject";
/**
* ActivityPub user inbox endpoint
@ -44,63 +42,11 @@ export default async (
// TODO: Add authentication
// Check is Activity already exists
const exists = await RawActivity.createQueryBuilder("activity")
.where("activity.data->>'id' = :id", {
id: body.id,
})
.getOne();
const activity = await RawActivity.addIfNotExists(body);
if (exists) return errorResponse("Activity already exists", 409);
// Check if object already exists
const objectExists = await RawObject.createQueryBuilder("object")
.where("object.data->>'id' = :id", {
id: (body.object as APObject).id,
})
.getOne();
if (objectExists)
return errorResponse("Object already exists", 409);
// Check if object body contains any filtered terms
const filter_result = await Promise.all(
config.filters.note_filters.map(async filter => {
if (
(body.object as APObject).type === "Note" &&
(body.object as APObject).content?.match(filter)
) {
// Log filter
if (config.logging.log_filters)
await appendFile(
process.cwd() + "/logs/filters.log",
`${new Date().toISOString()} Filtered note content: "${(
body.object as APObject
).content?.replaceAll("\n", " ")}" (ID: ${
(body.object as APObject).id
}) based on rule: ${filter}\n`
);
return true;
}
})
);
if (filter_result.includes(true)) return jsonResponse({});
const activity = new RawActivity();
const object = new RawObject();
activity.data = {
...body,
object: undefined,
};
object.data = body.object as APObject;
activity.objects = [object];
// Save the new object and activity
await object.save();
await activity.save();
if (activity instanceof Error) {
return errorResponse(activity.message, 409);
}
break;
}
case "Update" as APUpdate: {
@ -108,26 +54,20 @@ export default async (
// Replace the object in database with the new provided object
// TODO: Add authentication
const object = await RawObject.createQueryBuilder("object")
.where("object.data->>'id' = :id", {
id: (body.object as APObject).id,
})
.getOne();
const object = await RawActivity.updateObjectIfExists(
body.object as APObject
);
if (!object) return errorResponse("Object not found", 404);
if (object instanceof Error) {
return errorResponse(object.message, 409);
}
object.data = body.object as APObject;
const activity = await RawActivity.addIfNotExists(body);
// Store the Update event in database
const activity = new RawActivity();
activity.data = {
...body,
object: undefined,
};
activity.objects = [object];
if (activity instanceof Error) {
return errorResponse(activity.message, 409);
}
await object.save();
await activity.save();
break;
}
case "Delete" as APDelete: {
@ -135,59 +75,10 @@ export default async (
// Delete the object from database
// TODO: Add authentication
const object = await RawObject.createQueryBuilder("object")
.where("object.data->>'id' = :id", {
id: (body.object as APObject).id,
})
.getOne();
if (!object) return errorResponse("Object not found", 404);
const activities = await RawActivity.createQueryBuilder("activity")
// Objects is a many-to-many relationship
.leftJoinAndSelect("activity.objects", "objects")
.where("objects.data @> :data", {
data: JSON.stringify({
id: object.id,
}),
})
.getMany();
if (config.activitypub.use_tombstones) {
object.data = {
...object.data,
type: "Tombstone",
deleted: new Date(),
formerType: object.data.type,
} as APTombstone;
await object.save();
} else {
activities.forEach(
activity =>
(activity.objects = activity.objects.filter(
o => o.id !== object.id
))
);
await Promise.all(
activities.map(async activity => await activity.save())
);
await object.remove();
}
await RawActivity.deleteObjectIfExists(body.object as APObject);
// Store the Delete event in the database
const activity = new RawActivity();
activity.data = {
...body,
object: undefined,
};
activity.objects = config.activitypub.use_tombstones
? [object]
: [];
await activity.save();
const activity = RawActivity.addIfNotExists(body);
break;
}
case "Accept" as APAccept: {

View file

@ -19,6 +19,8 @@ beforeAll(async () => {
user.display_name = "";
user.bio = "";
await user.generateKeys();
await user.save();
});
@ -55,13 +57,9 @@ describe("POST /@test/inbox", () => {
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("application/json");
const activity = await RawActivity.createQueryBuilder("activity")
// id is part of the jsonb column 'data'
.where("activity.data->>'id' = :id", {
id: "https://example.com/notes/1/activity",
})
.leftJoinAndSelect("activity.objects", "objects")
.getOne();
const activity = await RawActivity.getLatestById(
"https://example.com/notes/1/activity"
);
expect(activity).not.toBeUndefined();
expect(activity?.data).toEqual({
@ -118,15 +116,9 @@ describe("POST /@test/inbox", () => {
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("application/json");
const activity = await RawActivity.createQueryBuilder("activity")
// Where id is part of the jsonb column 'data'
.where("activity.data->>'id' = :id", {
id: "https://example.com/notes/1/activity",
})
.leftJoinAndSelect("activity.objects", "objects")
// Sort by most recent
.orderBy("activity.data->>'published'", "DESC")
.getOne();
const activity = await RawActivity.getLatestById(
"https://example.com/notes/1/activity"
);
expect(activity).not.toBeUndefined();
expect(activity?.data).toEqual({
@ -183,15 +175,9 @@ describe("POST /@test/inbox", () => {
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("application/json");
const activity = await RawActivity.createQueryBuilder("activity")
// Where id is part of the jsonb column 'data'
.where("activity.data->>'id' = :id", {
id: "https://example.com/notes/1/activity",
})
.leftJoinAndSelect("activity.objects", "objects")
// Sort by most recent
.orderBy("activity.data->>'published'", "DESC")
.getOne();
const activity = await RawActivity.getLatestById(
"https://example.com/notes/1/activity"
);
expect(activity).not.toBeUndefined();
expect(activity?.data).toEqual({

View file

@ -23,6 +23,8 @@ beforeAll(async () => {
user.display_name = "";
user.bio = "";
await user.generateKeys();
await user.save();
});

View file

@ -33,6 +33,14 @@ export interface ConfigType {
activitypub: {
use_tombstones: boolean;
reject_activities: string[];
force_followers_only: string[];
discard_reports: string[];
discard_deletes: string[];
discard_banners: string[];
discard_avatars: string[];
force_sensitive: string[];
remove_media: string[];
};
filters: {
@ -128,6 +136,14 @@ export const configDefaults: ConfigType = {
},
activitypub: {
use_tombstones: true,
reject_activities: [],
force_followers_only: [],
discard_reports: [],
discard_deletes: [],
discard_banners: [],
discard_avatars: [],
force_sensitive: [],
remove_media: [],
},
filters: {
note_filters: [],