This commit is contained in:
Jesse Wierzbinski 2023-09-26 12:19:10 -10:00
parent d0c07d804b
commit 2cadb68a56
6 changed files with 332 additions and 97 deletions

View file

@ -12,4 +12,7 @@ module.exports = {
ignorePatterns: ["node_modules/", "dist/", ".eslintrc.cjs"], ignorePatterns: ["node_modules/", "dist/", ".eslintrc.cjs"],
plugins: ["@typescript-eslint"], plugins: ["@typescript-eslint"],
root: true, root: true,
rules: {
"@typescript-eslint/no-unsafe-assignment": "off",
},
}; };

View file

@ -1,38 +1,55 @@
/* eslint-disable @typescript-eslint/require-await */
import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm"; import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm";
import { APActor, APOrderedCollectionPage, IconField } from "activitypub-types"; import { APActor, APImage, APOrderedCollectionPage } from "activitypub-types";
import { getConfig, getHost } from "@config"; import { getConfig, getHost } from "@config";
import { appendFile } from "fs/promises"; import { appendFile } from "fs/promises";
import { errorResponse } from "@response"; import { errorResponse } from "@response";
import { APIAccount } from "~types/entities/account"; import { APIAccount } from "~types/entities/account";
/** /**
* Stores an ActivityPub actor as raw JSON-LD data * Represents a raw actor entity in the database.
*/ */
@Entity({ @Entity({ name: "actors" })
name: "actors",
})
export class RawActor extends BaseEntity { export class RawActor extends BaseEntity {
/**
* The unique identifier of the actor.
*/
@PrimaryGeneratedColumn("uuid") @PrimaryGeneratedColumn("uuid")
id!: string; id!: string;
/**
* The ActivityPub actor data associated with the actor.
*/
@Column("jsonb") @Column("jsonb")
data!: APActor; data!: APActor;
@Column("jsonb", { /**
default: [], * The list of follower IDs associated with the actor.
}) */
@Column("jsonb", { default: [] })
followers!: string[]; followers!: string[];
/**
* 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) { static async getByActorId(id: string) {
return await RawActor.createQueryBuilder("actor") return await RawActor.createQueryBuilder("actor")
.where("actor.data->>'id' = :id", { .where("actor.data->>'id' = :id", { id })
id,
})
.getOne(); .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) { static async addIfNotExists(data: APActor) {
if (!(await RawActor.exists(data.id ?? ""))) { if (await RawActor.exists(data.id ?? "")) {
return errorResponse("Actor already exists", 409);
}
const actor = new RawActor(); const actor = new RawActor();
actor.data = data; actor.data = data;
actor.followers = []; actor.followers = [];
@ -40,7 +57,7 @@ export class RawActor extends BaseEntity {
const config = getConfig(); const config = getConfig();
if ( if (
config.activitypub.discard_avatars.find(instance => config.activitypub.discard_avatars.some(instance =>
actor.id.includes(instance) actor.id.includes(instance)
) )
) { ) {
@ -48,7 +65,7 @@ export class RawActor extends BaseEntity {
} }
if ( if (
config.activitypub.discard_banners.find(instance => config.activitypub.discard_banners.some(instance =>
actor.id.includes(instance) actor.id.includes(instance)
) )
) { ) {
@ -63,38 +80,33 @@ export class RawActor extends BaseEntity {
return actor; return actor;
} }
return errorResponse("Actor already exists", 409);
}
/**
* Retrieves the domain of the instance associated with the actor.
* @returns The domain of the instance associated with the actor.
*/
getInstanceDomain() { getInstanceDomain() {
return new URL(this.data.id ?? "").host; return new URL(this.data.id ?? "").host;
} }
/**
* Fetches the list of followers associated with the actor and updates the `followers` property.
*/
async fetchFollowers() { async fetchFollowers() {
// Fetch follower list using ActivityPub
// Loop to fetch all followers until there are no more pages
let followers: APOrderedCollectionPage = await fetch( let followers: APOrderedCollectionPage = await fetch(
`${this.data.followers?.toString() ?? ""}?page=1`, `${this.data.followers?.toString() ?? ""}?page=1`,
{ {
headers: { headers: { Accept: "application/activity+json" },
Accept: "application/activity+json",
},
} }
); );
let followersList = followers.orderedItems ?? []; let followersList = followers.orderedItems ?? [];
while (followers.type === "OrderedCollectionPage" && followers.next) { while (followers.type === "OrderedCollectionPage" && followers.next) {
// Fetch next page followers = await fetch((followers.next as string).toString(), {
// eslint-disable-next-line @typescript-eslint/no-base-to-string headers: { Accept: "application/activity+json" },
followers = await fetch(followers.next.toString(), {
headers: {
Accept: "application/activity+json",
},
}).then(res => res.json()); }).then(res => res.json());
// Add new followers to list
followersList = { followersList = {
...followersList, ...followersList,
...(followers.orderedItems ?? []), ...(followers.orderedItems ?? []),
@ -104,26 +116,32 @@ export class RawActor extends BaseEntity {
this.followers = followersList as string[]; this.followers = followersList as string[];
} }
// eslint-disable-next-line @typescript-eslint/require-await /**
* 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> { async toAPIAccount(isOwnAccount = false): Promise<APIAccount> {
const config = getConfig(); const config = getConfig();
const { preferredUsername, name, summary, published, icon, image } =
this.data;
return { return {
id: this.id, id: this.id,
username: this.data.preferredUsername ?? "", username: preferredUsername ?? "",
display_name: this.data.name ?? this.data.preferredUsername ?? "", display_name: name ?? preferredUsername ?? "",
note: this.data.summary ?? "", note: summary ?? "",
url: `${config.http.base_url}/@${ url: `${
this.data.preferredUsername config.http.base_url
}@${this.getInstanceDomain()}`, }/@${preferredUsername}@${this.getInstanceDomain()}`,
// @ts-expect-error It actually works avatar:
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment ((icon as APImage).url as string | undefined) ??
avatar: (this.data.icon as IconField).url ?? config.defaults.avatar, config.defaults.avatar,
// @ts-expect-error It actually works header:
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment ((image as APImage).url as string | undefined) ??
header: this.data.image?.url ?? config.defaults.header, config.defaults.header,
locked: false, locked: false,
created_at: new Date(this.data.published ?? 0).toISOString(), created_at: new Date(published ?? 0).toISOString(),
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
followers_count: 0, followers_count: 0,
following_count: 0, following_count: 0,
statuses_count: 0, statuses_count: 0,
@ -143,10 +161,8 @@ export class RawActor extends BaseEntity {
header_static: "", header_static: "",
acct: acct:
this.getInstanceDomain() == getHost() this.getInstanceDomain() == getHost()
? `${this.data.preferredUsername}` ? `${preferredUsername}`
: `${ : `${preferredUsername}@${this.getInstanceDomain()}`,
this.data.preferredUsername
}@${this.getInstanceDomain()}`,
limited: false, limited: false,
moved: null, moved: null,
noindex: false, noindex: false,
@ -158,24 +174,23 @@ export class RawActor extends BaseEntity {
}; };
} }
/**
* Determines whether the actor is filtered based on the instance's filter rules.
* @returns Whether the actor is filtered.
*/
async isObjectFiltered() { async isObjectFiltered() {
const config = getConfig(); const config = getConfig();
const { type, preferredUsername, name, id } = this.data;
const usernameFilterResult = await Promise.all( const usernameFilterResult = await Promise.all(
config.filters.username_filters.map(async filter => { config.filters.username_filters.map(async filter => {
if ( if (type === "Person" && preferredUsername?.match(filter)) {
this.data.type === "Person" && if (config.logging.log_filters) {
this.data.preferredUsername?.match(filter)
) {
// Log filter
if (config.logging.log_filters)
await appendFile( await appendFile(
process.cwd() + "/logs/filters.log", process.cwd() + "/logs/filters.log",
`${new Date().toISOString()} Filtered actor username: "${ `${new Date().toISOString()} Filtered actor username: "${preferredUsername}" (ID: ${id}) based on rule: ${filter}\n`
this.data.preferredUsername
}" (ID: ${this.data.id}) based on rule: ${filter}\n`
); );
}
return true; return true;
} }
}) })
@ -183,19 +198,13 @@ export class RawActor extends BaseEntity {
const displayNameFilterResult = await Promise.all( const displayNameFilterResult = await Promise.all(
config.filters.displayname_filters.map(async filter => { config.filters.displayname_filters.map(async filter => {
if ( if (type === "Person" && name?.match(filter)) {
this.data.type === "Person" && if (config.logging.log_filters) {
this.data.name?.match(filter)
) {
// Log filter
if (config.logging.log_filters)
await appendFile( await appendFile(
process.cwd() + "/logs/filters.log", process.cwd() + "/logs/filters.log",
`${new Date().toISOString()} Filtered actor username: "${ `${new Date().toISOString()} Filtered actor username: "${preferredUsername}" (ID: ${id}) based on rule: ${filter}\n`
this.data.preferredUsername
}" (ID: ${this.data.id}) based on rule: ${filter}\n`
); );
}
return true; return true;
} }
}) })
@ -207,6 +216,11 @@ export class RawActor extends BaseEntity {
); );
} }
/**
* 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) { static async exists(id: string) {
return !!(await RawActor.getByActorId(id)); return !!(await RawActor.getByActorId(id));
} }

View file

@ -1,10 +1,11 @@
import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm"; import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm";
import { APActor, APObject, DateTime } from "activitypub-types"; import { APActor, APImage, APObject, DateTime } from "activitypub-types";
import { getConfig } from "@config"; import { getConfig } from "@config";
import { appendFile } from "fs/promises"; import { appendFile } from "fs/promises";
import { APIStatus } from "~types/entities/status"; import { APIStatus } from "~types/entities/status";
import { RawActor } from "./RawActor"; import { RawActor } from "./RawActor";
import { APIAccount } from "~types/entities/account"; import { APIAccount } from "~types/entities/account";
import { APIEmoji } from "~types/entities/emoji";
/** /**
* Stores an ActivityPub object as raw JSON-LD data * Stores an ActivityPub object as raw JSON-LD data
@ -27,7 +28,38 @@ export class RawObject extends BaseEntity {
.getOne(); .getOne();
} }
// 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[];
}
async toAPI(): Promise<APIStatus> { 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 { return {
account: account:
(await ( (await (
@ -41,11 +73,13 @@ export class RawObject extends BaseEntity {
application: null, application: null,
card: null, card: null,
content: this.data.content as string, content: this.data.content as string,
emojis: [], emojis: await this.parseEmojis(),
favourited: false, favourited: false,
favourites_count: 0, favourites_count: 0,
media_attachments: [], media_attachments: [],
mentions: [], mentions: await Promise.all(
mentions.map(async m => await m.toAPIAccount())
),
in_reply_to_account_id: null, in_reply_to_account_id: null,
language: null, language: null,
muted: false, muted: false,

View file

@ -7,7 +7,9 @@ import {
JoinTable, JoinTable,
ManyToMany, ManyToMany,
ManyToOne, ManyToOne,
OneToOne,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
RemoveOptions,
UpdateDateColumn, UpdateDateColumn,
} from "typeorm"; } from "typeorm";
import { APIStatus } from "~types/entities/status"; import { APIStatus } from "~types/entities/status";
@ -15,6 +17,8 @@ import { User } from "./User";
import { Application } from "./Application"; import { Application } from "./Application";
import { Emoji } from "./Emoji"; import { Emoji } from "./Emoji";
import { RawActivity } from "./RawActivity"; import { RawActivity } from "./RawActivity";
import { RawObject } from "./RawObject";
import { RawActor } from "./RawActor";
const config = getConfig(); const config = getConfig();
@ -42,6 +46,9 @@ export class Status extends BaseEntity {
}) })
reblog?: Status; reblog?: Status;
@OneToOne(() => Status)
object!: RawObject;
@Column("boolean") @Column("boolean")
isReblog!: boolean; isReblog!: boolean;
@ -53,6 +60,16 @@ export class Status extends BaseEntity {
@Column("varchar") @Column("varchar")
visibility!: APIStatus["visibility"]; visibility!: APIStatus["visibility"];
@ManyToOne(() => RawObject, {
nullable: true,
})
in_reply_to_post!: RawObject;
@ManyToOne(() => RawActor, {
nullable: true,
})
in_reply_to_account!: RawActor;
@Column("boolean") @Column("boolean")
sensitive!: boolean; sensitive!: boolean;
@ -78,6 +95,15 @@ export class Status extends BaseEntity {
@JoinTable() @JoinTable()
announces!: RawActivity[]; announces!: RawActivity[];
async remove(options?: RemoveOptions | undefined) {
// Delete object
await this.object.remove(options);
await super.remove(options);
return this;
}
static async createNew(data: { static async createNew(data: {
account: User; account: User;
application: Application | null; application: Application | null;
@ -86,6 +112,10 @@ export class Status extends BaseEntity {
sensitive: boolean; sensitive: boolean;
spoiler_text: string; spoiler_text: string;
emojis: Emoji[]; emojis: Emoji[];
reply?: {
object: RawObject;
actor: RawActor;
};
}) { }) {
const newStatus = new Status(); const newStatus = new Status();
@ -101,6 +131,99 @@ export class Status extends BaseEntity {
newStatus.isReblog = false; newStatus.isReblog = false;
newStatus.announces = []; newStatus.announces = [];
newStatus.object = new RawObject();
if (data.reply) {
newStatus.in_reply_to_post = data.reply.object;
newStatus.in_reply_to_account = data.reply.actor;
}
newStatus.object.data = {
id: `${config.http.base_url}/@${data.account.username}/statuses/${newStatus.id}`,
type: "Note",
summary: data.spoiler_text,
content: data.content, // TODO: Format as HTML
inReplyTo: data.reply?.object
? data.reply.object.data.id
: undefined,
attributedTo: `${config.http.base_url}/@${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]}`;
});
// Map this to Actors
const mentionedActors = (
await Promise.all(
mentionedPeople.map(async person => {
// Check if post is in format @username or @username@instance.com
// If is @username, the user is a local user
const instanceUrl =
person.split("@").length === 3
? person.split("@")[2]
: null;
if (instanceUrl) {
const actor = await RawActor.createQueryBuilder("actor")
.where("actor.data->>'id' = :id", {
// Where ID contains the instance URL
id: `%${instanceUrl}%`,
})
// Where actor preferredUsername is the username
.andWhere(
"actor.data->>'preferredUsername' = :username",
{
username: person.split("@")[1],
}
)
.getOne();
return actor?.data.id;
} else {
const actor = await User.findOne({
where: {
username: person.split("@")[1],
},
relations: {
actor: true,
},
});
return actor?.actor.data.id;
}
})
)
).map(actor => actor as string);
newStatus.object.data.to = mentionedActors;
if (data.visibility === "private") {
newStatus.object.data.cc = [
`${config.http.base_url}/@${data.account.username}/followers`,
];
} else if (data.visibility === "direct") {
// Add nothing else
} else if (data.visibility === "public") {
newStatus.object.data.to = [
...newStatus.object.data.to,
"https://www.w3.org/ns/activitystreams#Public",
];
newStatus.object.data.cc = [
`${config.http.base_url}/@${data.account.username}/followers`,
];
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
else if (data.visibility === "unlisted") {
newStatus.object.data.to = [
...newStatus.object.data.to,
"https://www.w3.org/ns/activitystreams#Public",
];
}
// TODO: Add default language // TODO: Add default language
await newStatus.save(); await newStatus.save();

View file

@ -0,0 +1,57 @@
import { getUserByToken } from "@auth";
import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { RawObject } from "~database/entities/RawObject";
import { Status } from "~database/entities/Status";
/**
* Fetch a user
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const id = matchedRoute.params.id;
// Check auth token
const token = req.headers.get("Authorization")?.split(" ")[1] || null;
const user = await getUserByToken(token);
// TODO: Add checks for user's permissions to view this status
let foundStatus: RawObject | null;
try {
foundStatus = await RawObject.findOneBy({
id,
});
} catch (e) {
return errorResponse("Invalid ID", 404);
}
if (!foundStatus) return errorResponse("Record not found", 404);
if (req.method === "GET") {
return jsonResponse(await foundStatus.toAPI());
} else if (req.method === "DELETE") {
if ((await foundStatus.toAPI()).account.id !== user?.id) {
return errorResponse("Unauthorized", 401);
}
// Get associated Status object
const status = await Status.createQueryBuilder("status")
.leftJoinAndSelect("status.object", "object")
.where("object.id = :id", { id: foundStatus.id })
.getOne();
if (!status) {
return errorResponse("Status not found", 404);
}
// Delete status and all associated objects
await status.object.remove();
return jsonResponse({});
}
return jsonResponse({});
};

View file

@ -53,8 +53,12 @@ export default async (req: Request): Promise<Response> => {
visibility?: "public" | "unlisted" | "private" | "direct"; visibility?: "public" | "unlisted" | "private" | "direct";
language?: string; language?: string;
scheduled_at?: string; scheduled_at?: string;
local_only?: boolean;
content_type?: string;
}>(req); }>(req);
// TODO: Parse Markdown statuses
// Validate status // Validate status
if (!status) { if (!status) {
return errorResponse("Status is required", 422); return errorResponse("Status is required", 422);