mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
guh
This commit is contained in:
parent
d0c07d804b
commit
2cadb68a56
|
|
@ -12,4 +12,7 @@ module.exports = {
|
|||
ignorePatterns: ["node_modules/", "dist/", ".eslintrc.cjs"],
|
||||
plugins: ["@typescript-eslint"],
|
||||
root: true,
|
||||
rules: {
|
||||
"@typescript-eslint/no-unsafe-assignment": "off",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
57
server/api/api/v1/statuses/[id]/index.ts
Normal file
57
server/api/api/v1/statuses/[id]/index.ts
Normal 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({});
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue