Purge ActivityPub from project to start implementing Lysand

This commit is contained in:
Jesse Wierzbinski 2023-10-30 10:23:29 -10:00
parent 3e86ffbf2d
commit 02b56f8fde
No known key found for this signature in database
GPG key ID: F9A1E418934E40B0
13 changed files with 9 additions and 1357 deletions

View file

@ -1,272 +0,0 @@
import {
BaseEntity,
Column,
Entity,
Index,
JoinTable,
ManyToMany,
PrimaryGeneratedColumn,
} from "typeorm";
import { APActivity, APActor, APObject, APTombstone } from "activitypub-types";
import { RawObject } from "./RawObject";
import { RawActor } from "./RawActor";
import { getConfig } from "@config";
import { errorResponse } from "@response";
/**
* Represents a raw activity entity in the database.
*/
@Entity({
name: "activities",
})
export class RawActivity extends BaseEntity {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column("jsonb")
// Index ID for faster lookups
@Index({ unique: true, where: "(data->>'id') IS NOT NULL" })
data!: APActivity;
@ManyToMany(() => RawObject)
@JoinTable()
objects!: RawObject[];
@ManyToMany(() => RawActor)
@JoinTable()
actors!: RawActor[];
/**
* Retrieves all activities that contain an object with the given ID.
* @param id The ID of the object to search for.
* @returns A promise that resolves to an array of matching activities.
*/
static async getByObjectId(id: string) {
return await RawActivity.createQueryBuilder("activity")
.leftJoinAndSelect("activity.objects", "objects")
.leftJoinAndSelect("activity.actors", "actors")
.where("objects.data @> :data", { data: JSON.stringify({ id }) })
.getMany();
}
/**
* Retrieves the activity with the given ID.
* @param id The ID of the activity to retrieve.
* @returns A promise that resolves to the matching activity, or undefined if not found.
*/
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();
}
/**
* Retrieves the latest activity with the given ID.
* @param id The ID of the activity to retrieve.
* @returns A promise that resolves to the latest matching activity, or undefined if not found.
*/
static async getLatestById(id: string) {
return await RawActivity.createQueryBuilder("activity")
.where("activity.data->>'id' = :id", { id })
.leftJoinAndSelect("activity.objects", "objects")
.leftJoinAndSelect("activity.actors", "actors")
.orderBy("activity.data->>'published'", "DESC")
.getOne();
}
/**
* Checks if an activity with the given ID exists.
* @param id The ID of the activity to check for.
* @returns A promise that resolves to true if the activity exists, false otherwise.
*/
static async exists(id: string) {
return !!(await RawActivity.getById(id));
}
/**
* Updates an object in the database if it exists.
* @param object The object to update.
* @returns A promise that resolves to the updated object, or an error response if the object does not exist or is filtered.
*/
static async updateObjectIfExists(object: APObject) {
const rawObject = await RawObject.getById(object.id ?? "");
if (!rawObject) {
return errorResponse("Object does not exist", 404);
}
rawObject.data = object;
if (await rawObject.isObjectFiltered()) {
return errorResponse("Object filtered", 409);
}
await rawObject.save();
return rawObject;
}
/**
* Deletes an object from the database if it exists.
* @param object The object to delete.
* @returns A promise that resolves to the deleted object, or an error response if the object does not exist.
*/
static async deleteObjectIfExists(object: APObject) {
const dbObject = await RawObject.getById(object.id ?? "");
if (!dbObject) {
return errorResponse("Object does not exist", 404);
}
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 ?? "");
for (const activity of activities) {
activity.objects = activity.objects.filter(
o => o.id !== object.id
);
await activity.save();
}
await dbObject.remove();
}
return dbObject;
}
/**
* Adds an activity to the database if it does not already exist.
* @param activity The activity to add.
* @param addObject An optional object to add to the activity.
* @returns A promise that resolves to the added activity, or an error response if the activity already exists or is filtered.
*/
static async createIfNotExists(
activity: APActivity,
addObject?: RawObject
) {
if (await RawActivity.exists(activity.id ?? "")) {
return errorResponse("Activity already exists", 409);
}
const rawActivity = new RawActivity();
rawActivity.data = { ...activity, object: undefined, actor: undefined };
rawActivity.actors = [];
rawActivity.objects = [];
const actor = await rawActivity.addActorIfNotExists(
activity.actor as APActor
);
if (actor instanceof Response) {
return actor;
}
if (addObject) {
rawActivity.objects.push(addObject);
} else {
const object = await rawActivity.addObjectIfNotExists(
activity.object as APObject
);
if (object instanceof Response) {
return object;
}
}
await rawActivity.save();
return rawActivity;
}
/**
* Returns the ActivityPub representation of the activity.
* @returns The ActivityPub representation of the activity.
*/
getActivityPubRepresentation() {
return {
...this.data,
object: this.objects[0].data,
actor: this.actors[0].data,
};
}
/**
* Adds an object to the activity if it does not already exist.
* @param object The object to add.
* @returns A promise that resolves to the added object, or an error response if the object already exists or is filtered.
*/
async addObjectIfNotExists(object: APObject) {
if (this.objects.some(o => o.data.id === object.id)) {
return errorResponse("Object already exists", 409);
}
const rawObject = new RawObject();
rawObject.data = object;
if (await rawObject.isObjectFiltered()) {
return errorResponse("Object filtered", 409);
}
await rawObject.save();
this.objects.push(rawObject);
return rawObject;
}
/**
* Adds an actor to the activity if it does not already exist.
* @param actor The actor to add.
* @returns A promise that resolves to the added actor, or an error response if the actor already exists or is filtered.
*/
async addActorIfNotExists(actor: APActor) {
const dbActor = await RawActor.getByActorId(actor.id ?? "");
if (dbActor) {
this.actors.push(dbActor);
return dbActor;
}
if (this.actors.some(a => a.data.id === actor.id)) {
return errorResponse("Actor already exists", 409);
}
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 errorResponse("Actor filtered", 409);
}
await rawActor.save();
this.actors.push(rawActor);
return rawActor;
}
}

View file

@ -1,212 +0,0 @@
/* eslint-disable @typescript-eslint/require-await */
import {
BaseEntity,
Column,
Entity,
Index,
PrimaryGeneratedColumn,
} from "typeorm";
import { APActor, APImage } from "activitypub-types";
import { getConfig, getHost } from "@config";
import { appendFile } from "fs/promises";
import { errorResponse } from "@response";
import { APIAccount } from "~types/entities/account";
import { RawActivity } from "./RawActivity";
/**
* Represents a raw actor entity in the database.
*/
@Entity({ name: "actors" })
export class RawActor extends BaseEntity {
/**
* The unique identifier of the actor.
*/
@PrimaryGeneratedColumn("uuid")
id!: string;
/**
* The ActivityPub actor data associated with the actor.
*/
@Column("jsonb")
@Index({ unique: true, where: "(data->>'id') IS NOT NULL" })
data!: APActor;
/**
* Retrieves a RawActor entity by actor ID.
* @param id The ID of the actor to retrieve.
* @returns The RawActor entity with the specified ID, or undefined if not found.
*/
static async getByActorId(id: string) {
return await RawActor.createQueryBuilder("actor")
.where("actor.data->>'id' = :id", { id })
.getOne();
}
/**
* Adds a new RawActor entity to the database if an actor with the same ID does not already exist.
* @param data The ActivityPub actor data to add.
* @returns The newly created RawActor entity, or an error response if the actor already exists or is filtered.
*/
static async addIfNotExists(data: APActor) {
// TODO: Also add corresponding user
if (await RawActor.exists(data.id ?? "")) {
return errorResponse("Actor already exists", 409);
}
const actor = new RawActor();
actor.data = data;
const config = getConfig();
if (
config.activitypub.discard_avatars.some(instance =>
actor.id.includes(instance)
)
) {
actor.data.icon = undefined;
}
if (
config.activitypub.discard_banners.some(instance =>
actor.id.includes(instance)
)
) {
actor.data.image = undefined;
}
if (await actor.isObjectFiltered()) {
return errorResponse("Actor filtered", 409);
}
await actor.save();
return actor;
}
/**
* Retrieves the domain of the instance associated with the actor.
* @returns The domain of the instance associated with the actor.
*/
getInstanceDomain() {
return new URL(this.data.id ?? "").host;
}
/**
* Converts the RawActor entity to an API account object.
* @param isOwnAccount Whether the account is the user's own account.
* @returns The API account object representing the RawActor entity.
*/
async toAPIAccount(isOwnAccount = false): Promise<APIAccount> {
const config = getConfig();
const { preferredUsername, name, summary, published, icon, image } =
this.data;
const statusCount = await RawActivity.createQueryBuilder("activity")
.leftJoinAndSelect("activity.actors", "actors")
.where("actors.data @> :data", {
data: JSON.stringify({
id: this.data.id,
}),
})
.getCount();
const isLocalUser = this.getInstanceDomain() == getHost();
return {
id: this.id,
username: preferredUsername ?? "",
display_name: name ?? preferredUsername ?? "",
note: summary ?? "",
url: `${config.http.base_url}/users/${preferredUsername}${
isLocalUser ? "" : `@${this.getInstanceDomain()}`
}`,
avatar:
((icon as APImage).url as string | undefined) ||
config.defaults.avatar,
header:
((image as APImage).url as string | undefined) ||
config.defaults.header,
locked: false,
created_at: new Date(published ?? 0).toISOString(),
followers_count: 0,
following_count: 0,
statuses_count: statusCount,
emojis: [],
fields: [],
bot: false,
source: isOwnAccount
? {
privacy: "public",
sensitive: false,
language: "en",
note: "",
fields: [],
}
: undefined,
avatar_static: "",
header_static: "",
acct:
this.getInstanceDomain() == getHost()
? `${preferredUsername}`
: `${preferredUsername}@${this.getInstanceDomain()}`,
limited: false,
moved: null,
noindex: false,
suspended: false,
discoverable: undefined,
mute_expires_at: undefined,
group: false,
role: undefined,
};
}
/**
* Determines whether the actor is filtered based on the instance's filter rules.
* @returns Whether the actor is filtered.
*/
async isObjectFiltered() {
const config = getConfig();
const { type, preferredUsername, name, id } = this.data;
const usernameFilterResult = await Promise.all(
config.filters.username_filters.map(async filter => {
if (type === "Person" && preferredUsername?.match(filter)) {
if (config.logging.log_filters) {
await appendFile(
process.cwd() + "/logs/filters.log",
`${new Date().toISOString()} Filtered actor username: "${preferredUsername}" (ID: ${id}) based on rule: ${filter}\n`
);
}
return true;
}
})
);
const displayNameFilterResult = await Promise.all(
config.filters.displayname_filters.map(async filter => {
if (type === "Person" && name?.match(filter)) {
if (config.logging.log_filters) {
await appendFile(
process.cwd() + "/logs/filters.log",
`${new Date().toISOString()} Filtered actor username: "${preferredUsername}" (ID: ${id}) based on rule: ${filter}\n`
);
}
return true;
}
})
);
return (
usernameFilterResult.includes(true) ||
displayNameFilterResult.includes(true)
);
}
/**
* Determines whether an actor with the specified ID exists in the database.
* @param id The ID of the actor to check for.
* @returns Whether an actor with the specified ID exists in the database.
*/
static async exists(id: string) {
return !!(await RawActor.getByActorId(id));
}
}

View file

@ -1,228 +0,0 @@
import {
BaseEntity,
Column,
Entity,
Index,
PrimaryGeneratedColumn,
} from "typeorm";
import { APImage, APObject, DateTime } from "activitypub-types";
import { ConfigType, getConfig } from "@config";
import { appendFile } from "fs/promises";
import { APIStatus } from "~types/entities/status";
import { RawActor } from "./RawActor";
import { APIAccount } from "~types/entities/account";
import { APIEmoji } from "~types/entities/emoji";
import { User } from "./User";
import { Status } from "./Status";
/**
* Represents a raw ActivityPub object in the database.
*/
@Entity({
name: "objects",
})
export class RawObject extends BaseEntity {
/**
* The unique identifier of the object.
*/
@PrimaryGeneratedColumn("uuid")
id!: string;
/**
* The data associated with the object.
*/
@Column("jsonb")
// Index ID, attributedTo, to and published for faster lookups
@Index({ unique: true, where: "(data->>'id') IS NOT NULL" })
@Index({ where: "(data->>'attributedTo') IS NOT NULL" })
@Index({ where: "(data->>'to') IS NOT NULL" })
@Index({ where: "(data->>'published') IS NOT NULL" })
data!: APObject;
/**
* Retrieves a RawObject instance by its ID.
* @param id The ID of the RawObject to retrieve.
* @returns A Promise that resolves to the RawObject instance, or undefined if not found.
*/
static async getById(id: string) {
return await RawObject.createQueryBuilder("object")
.where("object.data->>'id' = :id", {
id,
})
.getOne();
}
/**
* Parses the emojis associated with the object.
* @returns A Promise that resolves to an array of APIEmoji objects.
*/
// eslint-disable-next-line @typescript-eslint/require-await
async parseEmojis() {
const emojis = this.data.tag as {
id: string;
type: string;
name: string;
updated: string;
icon: {
type: "Image";
mediaType: string;
url: string;
};
}[];
return emojis.map(emoji => ({
shortcode: emoji.name,
static_url: (emoji.icon as APImage).url,
url: (emoji.icon as APImage).url,
visible_in_picker: true,
category: "custom",
})) as APIEmoji[];
}
/**
* Converts the RawObject instance to an APIStatus object.
* @returns A Promise that resolves to the APIStatus object.
*/
async toAPI(): Promise<APIStatus> {
const mentions = (
await Promise.all(
(this.data.to as string[]).map(
async person => await RawActor.getByActorId(person)
)
)
).filter(m => m) as RawActor[];
return {
account:
(await (
await User.getByActorId(this.data.attributedTo as string)
)?.toAPI()) ?? (null as unknown as APIAccount),
created_at: new Date(this.data.published as DateTime).toISOString(),
id: this.id,
in_reply_to_id: null,
application: null,
card: null,
content: this.data.content as string,
emojis: await this.parseEmojis(),
favourited: false,
favourites_count: 0,
media_attachments: [],
mentions: await Promise.all(
mentions.map(async m => await m.toAPIAccount())
),
in_reply_to_account_id: null,
language: null,
muted: false,
pinned: false,
poll: null,
reblog: null,
reblogged: false,
reblogs_count: 0,
replies_count: 0,
sensitive: false,
spoiler_text: "",
tags: [],
uri: this.data.id as string,
visibility: "public",
url: this.data.id as string,
bookmarked: false,
quote: null,
quote_id: undefined,
};
}
/**
* Determines whether the object is filtered based on the note filters in the configuration.
* @returns A Promise that resolves to a boolean indicating whether the object is filtered.
*/
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);
}
/**
* Determines whether a RawObject instance with the given ID exists in the database.
* @param id The ID of the RawObject to check for existence.
* @returns A Promise that resolves to a boolean indicating whether the RawObject exists.
*/
static async exists(id: string) {
return !!(await RawObject.getById(id));
}
/**
* Creates a RawObject instance from a Status object.
* DOES NOT SAVE THE OBJECT TO THE DATABASE.
* @param status The Status object to create the RawObject from.
* @returns A Promise that resolves to the RawObject instance.
*/
static createFromStatus(status: Status, config: ConfigType) {
const object = new RawObject();
object.data = {
id: `${config.http.base_url}/users/${status.account.username}/statuses/${status.id}`,
type: "Note",
summary: status.spoiler_text,
content: status.content,
inReplyTo: status.in_reply_to_post?.object.data.id,
published: new Date().toISOString(),
tag: [],
attributedTo: `${config.http.base_url}/users/${status.account.username}`,
};
// Map status mentions to ActivityPub Actor IDs
const mentionedUsers = status.mentions.map(
user => user.actor.data.id as string
);
object.data.to = mentionedUsers;
if (status.visibility === "private") {
object.data.cc = [
`${config.http.base_url}/users/${status.account.username}/followers`,
];
} else if (status.visibility === "direct") {
// Add nothing else
} else if (status.visibility === "public") {
object.data.to = [
...object.data.to,
"https://www.w3.org/ns/activitystreams#Public",
];
object.data.cc = [
`${config.http.base_url}/users/${status.account.username}/followers`,
];
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
else if (status.visibility === "unlisted") {
object.data.to = [
...object.data.to,
"https://www.w3.org/ns/activitystreams#Public",
];
}
return object;
}
}

View file

@ -18,8 +18,6 @@ import { APIStatus } from "~types/entities/status";
import { User, userRelations } from "./User";
import { Application } from "./Application";
import { Emoji } from "./Emoji";
import { RawActivity } from "./RawActivity";
import { RawObject } from "./RawObject";
import { Instance } from "./Instance";
import { Like } from "./Like";
import { AppDataSource } from "~database/datasource";
@ -29,25 +27,17 @@ const config = getConfig();
export const statusRelations = [
"account",
"reblog",
"object",
"in_reply_to_post",
"instance",
"in_reply_to_post.account",
"application",
"emojis",
"mentions",
"likes",
"announces",
];
export const statusAndUserRelations = [
...statusRelations,
...[
"account.actor",
"account.relationships",
"account.pinned_notes",
"account.instance",
],
...["account.relationships", "account.pinned_notes", "account.instance"],
];
/**
@ -91,15 +81,6 @@ export class Status extends BaseEntity {
})
reblog?: Status | null;
/**
* The raw object associated with this status.
*/
@ManyToOne(() => RawObject, {
nullable: true,
onDelete: "SET NULL",
})
object!: RawObject;
/**
* Whether this status is a reblog.
*/
@ -175,20 +156,6 @@ export class Status extends BaseEntity {
@JoinTable()
mentions!: User[];
/**
* The activities that have liked this status.
*/
@ManyToMany(() => RawActivity, activity => activity.id)
@JoinTable()
likes!: RawActivity[];
/**
* The activities that have announced this status.
*/
@ManyToMany(() => RawActivity, activity => activity.id)
@JoinTable()
announces!: RawActivity[];
/**
* Removes this status from the database.
* @param options The options for removing this status.
@ -196,8 +163,6 @@ export class Status extends BaseEntity {
*/
async remove(options?: RemoveOptions | undefined) {
// Delete object
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (this.object) await this.object.remove(options);
// Get all associated Likes and remove them as well
await Like.delete({
@ -338,10 +303,7 @@ export class Status extends BaseEntity {
newStatus.sensitive = data.sensitive;
newStatus.spoiler_text = data.spoiler_text;
newStatus.emojis = data.emojis;
newStatus.likes = [];
newStatus.announces = [];
newStatus.isReblog = false;
newStatus.announces = [];
newStatus.mentions = [];
newStatus.instance = data.account.instance;
@ -391,10 +353,6 @@ export class Status extends BaseEntity {
})
);
const object = RawObject.createFromStatus(newStatus, config);
newStatus.object = object;
await newStatus.object.save();
await newStatus.save();
return newStatus;
}

View file

@ -13,24 +13,13 @@ import {
UpdateDateColumn,
} from "typeorm";
import { APIAccount } from "~types/entities/account";
import { RawActor } from "./RawActor";
import {
APActor,
APCollectionPage,
APOrderedCollectionPage,
} from "activitypub-types";
import { Token } from "./Token";
import { Status, statusRelations } from "./Status";
import { APISource } from "~types/entities/source";
import { Relationship } from "./Relationship";
import { Instance } from "./Instance";
export const userRelations = [
"actor",
"relationships",
"pinned_notes",
"instance",
];
export const userRelations = ["relationships", "pinned_notes", "instance"];
/**
* Represents a user in the database.
@ -151,14 +140,6 @@ export class User extends BaseEntity {
})
instance!: Instance | null;
/** */
/**
* The actor for the user.
*/
@ManyToOne(() => RawActor, actor => actor.id)
actor!: RawActor;
/**
* The pinned notes for the user.
*/
@ -199,53 +180,11 @@ export class User extends BaseEntity {
return { user: await User.retrieveFromToken(token), token };
}
/**
* Update this user data from its actor
* @returns The updated user.
*/
async updateFromActor() {
const actor = await this.actor.toAPIAccount();
this.username = actor.username;
this.display_name = actor.display_name;
this.note = actor.note;
this.avatar = actor.avatar;
this.header = actor.header;
this.avatar = actor.avatar;
return await this.save();
}
/**
* Fetches the list of followers associated with the actor and updates the user's followers
*/
async fetchFollowers() {
const config = getConfig();
let followers: APOrderedCollectionPage = await fetch(
`${this.actor.data.followers?.toString() ?? ""}?page=1`,
{
headers: { Accept: "application/activity+json" },
}
);
let followersList = followers.orderedItems ?? [];
while (followers.type === "OrderedCollectionPage" && followers.next) {
followers = await fetch((followers.next as string).toString(), {
headers: { Accept: "application/activity+json" },
}).then(res => res.json() as APCollectionPage);
followersList = {
...followersList,
...(followers.orderedItems ?? []),
};
}
if (config.activitypub.fetch_all_collection_members) {
// Loop through followers list and retrieve each actor individually
// TODO: Implement
}
//
}
/**
@ -306,7 +245,6 @@ export class User extends BaseEntity {
await user.generateKeys();
await user.save();
await user.updateActor();
return user;
}
@ -406,64 +344,6 @@ export class User extends BaseEntity {
return relationships;
}
/**
* Updates the actor for the user.
* @returns The updated actor.
*/
async updateActor() {
const config = getConfig();
// Check if actor exists
const actorExists = await RawActor.getByActorId(
`${config.http.base_url}/users/${this.username}`
);
let actor: RawActor;
if (actorExists) {
actor = actorExists;
} else {
actor = new RawActor();
}
actor.data = {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
],
id: `${config.http.base_url}/users/${this.username}`,
type: "Person",
preferredUsername: this.username,
name: this.display_name,
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`,
published: new Date(this.created_at).toISOString(),
manuallyApprovesFollowers: false,
summary: this.note,
icon: {
type: "Image",
url: this.getAvatarUrl(config),
},
image: {
type: "Image",
url: this.getHeaderUrl(config),
},
publicKey: {
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;
await actor.save();
this.actor = actor;
await this.save();
return actor;
}
/**
* Generates keys for the user.
*/