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

View file

@ -1,10 +1,11 @@
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 { 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";
/**
* Stores an ActivityPub object as raw JSON-LD data
@ -27,7 +28,38 @@ export class RawObject extends BaseEntity {
.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> {
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 (
@ -41,11 +73,13 @@ export class RawObject extends BaseEntity {
application: null,
card: null,
content: this.data.content as string,
emojis: [],
emojis: await this.parseEmojis(),
favourited: false,
favourites_count: 0,
media_attachments: [],
mentions: [],
mentions: await Promise.all(
mentions.map(async m => await m.toAPIAccount())
),
in_reply_to_account_id: null,
language: null,
muted: false,

View file

@ -7,7 +7,9 @@ import {
JoinTable,
ManyToMany,
ManyToOne,
OneToOne,
PrimaryGeneratedColumn,
RemoveOptions,
UpdateDateColumn,
} from "typeorm";
import { APIStatus } from "~types/entities/status";
@ -15,6 +17,8 @@ import { User } from "./User";
import { Application } from "./Application";
import { Emoji } from "./Emoji";
import { RawActivity } from "./RawActivity";
import { RawObject } from "./RawObject";
import { RawActor } from "./RawActor";
const config = getConfig();
@ -42,6 +46,9 @@ export class Status extends BaseEntity {
})
reblog?: Status;
@OneToOne(() => Status)
object!: RawObject;
@Column("boolean")
isReblog!: boolean;
@ -53,6 +60,16 @@ export class Status extends BaseEntity {
@Column("varchar")
visibility!: APIStatus["visibility"];
@ManyToOne(() => RawObject, {
nullable: true,
})
in_reply_to_post!: RawObject;
@ManyToOne(() => RawActor, {
nullable: true,
})
in_reply_to_account!: RawActor;
@Column("boolean")
sensitive!: boolean;
@ -78,6 +95,15 @@ export class Status extends BaseEntity {
@JoinTable()
announces!: RawActivity[];
async remove(options?: RemoveOptions | undefined) {
// Delete object
await this.object.remove(options);
await super.remove(options);
return this;
}
static async createNew(data: {
account: User;
application: Application | null;
@ -86,6 +112,10 @@ export class Status extends BaseEntity {
sensitive: boolean;
spoiler_text: string;
emojis: Emoji[];
reply?: {
object: RawObject;
actor: RawActor;
};
}) {
const newStatus = new Status();
@ -101,6 +131,99 @@ export class Status extends BaseEntity {
newStatus.isReblog = false;
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
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";
language?: string;
scheduled_at?: string;
local_only?: boolean;
content_type?: string;
}>(req);
// TODO: Parse Markdown statuses
// Validate status
if (!status) {
return errorResponse("Status is required", 422);