mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
Purge ActivityPub from project to start implementing Lysand
This commit is contained in:
parent
3e86ffbf2d
commit
02b56f8fde
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -18,8 +18,6 @@ import { APIStatus } from "~types/entities/status";
|
||||||
import { User, userRelations } from "./User";
|
import { User, userRelations } from "./User";
|
||||||
import { Application } from "./Application";
|
import { Application } from "./Application";
|
||||||
import { Emoji } from "./Emoji";
|
import { Emoji } from "./Emoji";
|
||||||
import { RawActivity } from "./RawActivity";
|
|
||||||
import { RawObject } from "./RawObject";
|
|
||||||
import { Instance } from "./Instance";
|
import { Instance } from "./Instance";
|
||||||
import { Like } from "./Like";
|
import { Like } from "./Like";
|
||||||
import { AppDataSource } from "~database/datasource";
|
import { AppDataSource } from "~database/datasource";
|
||||||
|
|
@ -29,25 +27,17 @@ const config = getConfig();
|
||||||
export const statusRelations = [
|
export const statusRelations = [
|
||||||
"account",
|
"account",
|
||||||
"reblog",
|
"reblog",
|
||||||
"object",
|
|
||||||
"in_reply_to_post",
|
"in_reply_to_post",
|
||||||
"instance",
|
"instance",
|
||||||
"in_reply_to_post.account",
|
"in_reply_to_post.account",
|
||||||
"application",
|
"application",
|
||||||
"emojis",
|
"emojis",
|
||||||
"mentions",
|
"mentions",
|
||||||
"likes",
|
|
||||||
"announces",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const statusAndUserRelations = [
|
export const statusAndUserRelations = [
|
||||||
...statusRelations,
|
...statusRelations,
|
||||||
...[
|
...["account.relationships", "account.pinned_notes", "account.instance"],
|
||||||
"account.actor",
|
|
||||||
"account.relationships",
|
|
||||||
"account.pinned_notes",
|
|
||||||
"account.instance",
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -91,15 +81,6 @@ export class Status extends BaseEntity {
|
||||||
})
|
})
|
||||||
reblog?: Status | null;
|
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.
|
* Whether this status is a reblog.
|
||||||
*/
|
*/
|
||||||
|
|
@ -175,20 +156,6 @@ export class Status extends BaseEntity {
|
||||||
@JoinTable()
|
@JoinTable()
|
||||||
mentions!: User[];
|
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.
|
* Removes this status from the database.
|
||||||
* @param options The options for removing this status.
|
* @param options The options for removing this status.
|
||||||
|
|
@ -196,8 +163,6 @@ export class Status extends BaseEntity {
|
||||||
*/
|
*/
|
||||||
async remove(options?: RemoveOptions | undefined) {
|
async remove(options?: RemoveOptions | undefined) {
|
||||||
// Delete object
|
// 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
|
// Get all associated Likes and remove them as well
|
||||||
await Like.delete({
|
await Like.delete({
|
||||||
|
|
@ -338,10 +303,7 @@ export class Status extends BaseEntity {
|
||||||
newStatus.sensitive = data.sensitive;
|
newStatus.sensitive = data.sensitive;
|
||||||
newStatus.spoiler_text = data.spoiler_text;
|
newStatus.spoiler_text = data.spoiler_text;
|
||||||
newStatus.emojis = data.emojis;
|
newStatus.emojis = data.emojis;
|
||||||
newStatus.likes = [];
|
|
||||||
newStatus.announces = [];
|
|
||||||
newStatus.isReblog = false;
|
newStatus.isReblog = false;
|
||||||
newStatus.announces = [];
|
|
||||||
newStatus.mentions = [];
|
newStatus.mentions = [];
|
||||||
newStatus.instance = data.account.instance;
|
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();
|
await newStatus.save();
|
||||||
return newStatus;
|
return newStatus;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,24 +13,13 @@ import {
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from "typeorm";
|
} from "typeorm";
|
||||||
import { APIAccount } from "~types/entities/account";
|
import { APIAccount } from "~types/entities/account";
|
||||||
import { RawActor } from "./RawActor";
|
|
||||||
import {
|
|
||||||
APActor,
|
|
||||||
APCollectionPage,
|
|
||||||
APOrderedCollectionPage,
|
|
||||||
} from "activitypub-types";
|
|
||||||
import { Token } from "./Token";
|
import { Token } from "./Token";
|
||||||
import { Status, statusRelations } from "./Status";
|
import { Status, statusRelations } from "./Status";
|
||||||
import { APISource } from "~types/entities/source";
|
import { APISource } from "~types/entities/source";
|
||||||
import { Relationship } from "./Relationship";
|
import { Relationship } from "./Relationship";
|
||||||
import { Instance } from "./Instance";
|
import { Instance } from "./Instance";
|
||||||
|
|
||||||
export const userRelations = [
|
export const userRelations = ["relationships", "pinned_notes", "instance"];
|
||||||
"actor",
|
|
||||||
"relationships",
|
|
||||||
"pinned_notes",
|
|
||||||
"instance",
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a user in the database.
|
* Represents a user in the database.
|
||||||
|
|
@ -151,14 +140,6 @@ export class User extends BaseEntity {
|
||||||
})
|
})
|
||||||
instance!: Instance | null;
|
instance!: Instance | null;
|
||||||
|
|
||||||
/** */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The actor for the user.
|
|
||||||
*/
|
|
||||||
@ManyToOne(() => RawActor, actor => actor.id)
|
|
||||||
actor!: RawActor;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The pinned notes for the user.
|
* The pinned notes for the user.
|
||||||
*/
|
*/
|
||||||
|
|
@ -199,53 +180,11 @@ export class User extends BaseEntity {
|
||||||
return { user: await User.retrieveFromToken(token), token };
|
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
|
* Fetches the list of followers associated with the actor and updates the user's followers
|
||||||
*/
|
*/
|
||||||
async fetchFollowers() {
|
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.generateKeys();
|
||||||
await user.save();
|
await user.save();
|
||||||
await user.updateActor();
|
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
@ -406,64 +344,6 @@ export class User extends BaseEntity {
|
||||||
return relationships;
|
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.
|
* Generates keys for the user.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,6 @@ export default async (req: Request): Promise<Response> => {
|
||||||
return errorResponse("Display name contains blocked words", 422);
|
return errorResponse("Display name contains blocked words", 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
user.actor.data.name = sanitizedDisplayName;
|
|
||||||
user.display_name = sanitizedDisplayName;
|
user.display_name = sanitizedDisplayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,7 +102,6 @@ export default async (req: Request): Promise<Response> => {
|
||||||
return errorResponse("Bio contains blocked words", 422);
|
return errorResponse("Bio contains blocked words", 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
user.actor.data.summary = sanitizedNote;
|
|
||||||
user.note = sanitizedNote;
|
user.note = sanitizedNote;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -196,7 +194,6 @@ export default async (req: Request): Promise<Response> => {
|
||||||
}
|
}
|
||||||
|
|
||||||
await user.save();
|
await user.save();
|
||||||
await user.updateActor();
|
|
||||||
|
|
||||||
return jsonResponse(await user.toAPI());
|
return jsonResponse(await user.toAPI());
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,8 @@ import { getConfig } from "@config";
|
||||||
import { parseRequest } from "@request";
|
import { parseRequest } from "@request";
|
||||||
import { errorResponse, jsonResponse } from "@response";
|
import { errorResponse, jsonResponse } from "@response";
|
||||||
import { sanitizeHtml } from "@sanitization";
|
import { sanitizeHtml } from "@sanitization";
|
||||||
import { APActor } from "activitypub-types";
|
|
||||||
import { sanitize } from "isomorphic-dompurify";
|
|
||||||
import { parse } from "marked";
|
import { parse } from "marked";
|
||||||
import { Application } from "~database/entities/Application";
|
import { Application } from "~database/entities/Application";
|
||||||
import { RawObject } from "~database/entities/RawObject";
|
|
||||||
import { Status, statusRelations } from "~database/entities/Status";
|
import { Status, statusRelations } from "~database/entities/Status";
|
||||||
import { User } from "~database/entities/User";
|
import { User } from "~database/entities/User";
|
||||||
import { APIRouteMeta } from "~types/api";
|
import { APIRouteMeta } from "~types/api";
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
|
/* eslint-disable @typescript-eslint/require-await */
|
||||||
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
import { applyConfig } from "@api";
|
import { applyConfig } from "@api";
|
||||||
import { errorResponse, jsonLdResponse } from "@response";
|
import { jsonResponse } from "@response";
|
||||||
import { MatchedRoute } from "bun";
|
import { MatchedRoute } from "bun";
|
||||||
import { RawActivity } from "~database/entities/RawActivity";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
export const meta = applyConfig({
|
||||||
allowedMethods: ["GET"],
|
allowedMethods: ["GET"],
|
||||||
|
|
@ -22,11 +23,5 @@ export default async (
|
||||||
req: Request,
|
req: Request,
|
||||||
matchedRoute: MatchedRoute
|
matchedRoute: MatchedRoute
|
||||||
): Promise<Response> => {
|
): Promise<Response> => {
|
||||||
const object = await RawActivity.findOneBy({
|
return jsonResponse({});
|
||||||
id: matchedRoute.params.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!object) return errorResponse("Object not found", 404);
|
|
||||||
|
|
||||||
return jsonLdResponse(object);
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,97 +1 @@
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
// Empty file
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
import { getConfig } from "@config";
|
|
||||||
import { APActor } from "activitypub-types";
|
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
||||||
import { AppDataSource } from "~database/datasource";
|
|
||||||
import { RawActivity } from "~database/entities/RawActivity";
|
|
||||||
import { User, userRelations } from "~database/entities/User";
|
|
||||||
|
|
||||||
const config = getConfig();
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
if (!AppDataSource.isInitialized) await AppDataSource.initialize();
|
|
||||||
|
|
||||||
// Initialize test user
|
|
||||||
await User.createNewLocal({
|
|
||||||
email: "test@test.com",
|
|
||||||
username: "test",
|
|
||||||
password: "test",
|
|
||||||
display_name: "",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("POST /@test/actor", () => {
|
|
||||||
test("should return a valid ActivityPub Actor when querying an existing user", async () => {
|
|
||||||
const response = await fetch(
|
|
||||||
`${config.http.base_url}/users/test/actor`,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
Accept: "application/activity+json",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.headers.get("content-type")).toBe(
|
|
||||||
"application/activity+json"
|
|
||||||
);
|
|
||||||
|
|
||||||
const actor = (await response.json()) as APActor;
|
|
||||||
|
|
||||||
expect(actor.type).toBe("Person");
|
|
||||||
expect(actor.id).toBe(`${config.http.base_url}/users/test`);
|
|
||||||
expect(actor.preferredUsername).toBe("test");
|
|
||||||
expect(actor.inbox).toBe(`${config.http.base_url}/users/test/inbox`);
|
|
||||||
expect(actor.outbox).toBe(`${config.http.base_url}/users/test/outbox`);
|
|
||||||
expect(actor.followers).toBe(
|
|
||||||
`${config.http.base_url}/users/test/followers`
|
|
||||||
);
|
|
||||||
expect(actor.following).toBe(
|
|
||||||
`${config.http.base_url}/users/test/following`
|
|
||||||
);
|
|
||||||
expect((actor as any).publicKey).toBeDefined();
|
|
||||||
expect((actor as any).publicKey.id).toBeDefined();
|
|
||||||
expect((actor as any).publicKey.owner).toBe(
|
|
||||||
`${config.http.base_url}/users/test`
|
|
||||||
);
|
|
||||||
expect((actor as any).publicKey.publicKeyPem).toBeDefined();
|
|
||||||
expect((actor as any).publicKey.publicKeyPem).toMatch(
|
|
||||||
/(-----BEGIN PUBLIC KEY-----(\n|\r|\r\n)([0-9a-zA-Z+/=]{64}(\n|\r|\r\n))*([0-9a-zA-Z+/=]{1,63}(\n|\r|\r\n))?-----END PUBLIC KEY-----)|(-----BEGIN PRIVATE KEY-----(\n|\r|\r\n)([0-9a-zA-Z+/=]{64}(\n|\r|\r\n))*([0-9a-zA-Z+/=]{1,63}(\n|\r|\r\n))?-----END PRIVATE KEY-----)/
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
// Clean up user
|
|
||||||
const user = await User.findOne({
|
|
||||||
where: {
|
|
||||||
username: "test",
|
|
||||||
},
|
|
||||||
relations: userRelations,
|
|
||||||
});
|
|
||||||
|
|
||||||
const activities = await RawActivity.createQueryBuilder("activity")
|
|
||||||
.where("activity.data->>'actor' = :actor", {
|
|
||||||
actor: `${config.http.base_url}/users/test`,
|
|
||||||
})
|
|
||||||
.leftJoinAndSelect("activity.objects", "objects")
|
|
||||||
.getMany();
|
|
||||||
|
|
||||||
// Delete all created objects and activities as part of testing
|
|
||||||
await Promise.all(
|
|
||||||
activities.map(async activity => {
|
|
||||||
await Promise.all(
|
|
||||||
activity.objects.map(async object => await object.remove())
|
|
||||||
);
|
|
||||||
await activity.remove();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
if (user) {
|
|
||||||
await user.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
await AppDataSource.destroy();
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { AppDataSource } from "~database/datasource";
|
import { AppDataSource } from "~database/datasource";
|
||||||
import { Application } from "~database/entities/Application";
|
import { Application } from "~database/entities/Application";
|
||||||
import { Emoji } from "~database/entities/Emoji";
|
import { Emoji } from "~database/entities/Emoji";
|
||||||
import { RawActivity } from "~database/entities/RawActivity";
|
|
||||||
import { Token, TokenType } from "~database/entities/Token";
|
import { Token, TokenType } from "~database/entities/Token";
|
||||||
import { User } from "~database/entities/User";
|
import { User } from "~database/entities/User";
|
||||||
import { APIEmoji } from "~types/entities/emoji";
|
import { APIEmoji } from "~types/entities/emoji";
|
||||||
|
|
@ -63,21 +62,6 @@ describe("API Tests", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
const activities = await RawActivity.createQueryBuilder("activity")
|
|
||||||
.where("activity.data->>'actor' = :actor", {
|
|
||||||
actor: `${config.http.base_url}/users/test`,
|
|
||||||
})
|
|
||||||
.leftJoinAndSelect("activity.objects", "objects")
|
|
||||||
.getMany();
|
|
||||||
|
|
||||||
// Delete all created objects and activities as part of testing
|
|
||||||
for (const activity of activities) {
|
|
||||||
for (const object of activity.objects) {
|
|
||||||
await object.remove();
|
|
||||||
}
|
|
||||||
await activity.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
await user.remove();
|
await user.remove();
|
||||||
await user2.remove();
|
await user2.remove();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { getConfig } from "@config";
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { AppDataSource } from "~database/datasource";
|
import { AppDataSource } from "~database/datasource";
|
||||||
import { Application } from "~database/entities/Application";
|
import { Application } from "~database/entities/Application";
|
||||||
import { RawActivity } from "~database/entities/RawActivity";
|
|
||||||
import { Token, TokenType } from "~database/entities/Token";
|
import { Token, TokenType } from "~database/entities/Token";
|
||||||
import { User } from "~database/entities/User";
|
import { User } from "~database/entities/User";
|
||||||
import { APIAccount } from "~types/entities/account";
|
import { APIAccount } from "~types/entities/account";
|
||||||
|
|
@ -63,21 +62,6 @@ describe("API Tests", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
const activities = await RawActivity.createQueryBuilder("activity")
|
|
||||||
.where("activity.data->>'actor' = :actor", {
|
|
||||||
actor: `${config.http.base_url}/users/test`,
|
|
||||||
})
|
|
||||||
.leftJoinAndSelect("activity.objects", "objects")
|
|
||||||
.getMany();
|
|
||||||
|
|
||||||
// Delete all created objects and activities as part of testing
|
|
||||||
for (const activity of activities) {
|
|
||||||
for (const object of activity.objects) {
|
|
||||||
await object.remove();
|
|
||||||
}
|
|
||||||
await activity.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
await user.remove();
|
await user.remove();
|
||||||
await user2.remove();
|
await user2.remove();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { getConfig } from "@config";
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { AppDataSource } from "~database/datasource";
|
import { AppDataSource } from "~database/datasource";
|
||||||
import { Application } from "~database/entities/Application";
|
import { Application } from "~database/entities/Application";
|
||||||
import { RawActivity } from "~database/entities/RawActivity";
|
|
||||||
import { Token, TokenType } from "~database/entities/Token";
|
import { Token, TokenType } from "~database/entities/Token";
|
||||||
import { User } from "~database/entities/User";
|
import { User } from "~database/entities/User";
|
||||||
import { APIContext } from "~types/entities/context";
|
import { APIContext } from "~types/entities/context";
|
||||||
|
|
@ -64,21 +63,6 @@ describe("API Tests", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
const activities = await RawActivity.createQueryBuilder("activity")
|
|
||||||
.where("activity.data->>'actor' = :actor", {
|
|
||||||
actor: `${config.http.base_url}/users/test`,
|
|
||||||
})
|
|
||||||
.leftJoinAndSelect("activity.objects", "objects")
|
|
||||||
.getMany();
|
|
||||||
|
|
||||||
// Delete all created objects and activities as part of testing
|
|
||||||
for (const activity of activities) {
|
|
||||||
for (const object of activity.objects) {
|
|
||||||
await object.remove();
|
|
||||||
}
|
|
||||||
await activity.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
await user.remove();
|
await user.remove();
|
||||||
await user2.remove();
|
await user2.remove();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,320 +1 @@
|
||||||
import { getConfig } from "@config";
|
// Empty file
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
||||||
import { AppDataSource } from "~database/datasource";
|
|
||||||
import { RawActivity } from "~database/entities/RawActivity";
|
|
||||||
import { Token } from "~database/entities/Token";
|
|
||||||
import { User, userRelations } from "~database/entities/User";
|
|
||||||
|
|
||||||
const config = getConfig();
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
if (!AppDataSource.isInitialized) await AppDataSource.initialize();
|
|
||||||
|
|
||||||
// Initialize test user
|
|
||||||
await User.createNewLocal({
|
|
||||||
email: "test@test.com",
|
|
||||||
username: "test",
|
|
||||||
password: "test",
|
|
||||||
display_name: "",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("POST /@test/inbox", () => {
|
|
||||||
test("should store a new Note object", async () => {
|
|
||||||
const activityId = `https://example.com/objects/${crypto.randomUUID()}`;
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`${config.http.base_url}/users/test/inbox/`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/activity+json",
|
|
||||||
Origin: "http://lysand-test.localhost",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
type: "Create",
|
|
||||||
id: activityId,
|
|
||||||
actor: {
|
|
||||||
id: `${config.http.base_url}/users/test`,
|
|
||||||
type: "Person",
|
|
||||||
preferredUsername: "test",
|
|
||||||
},
|
|
||||||
to: ["https://www.w3.org/ns/activitystreams#Public"],
|
|
||||||
cc: [],
|
|
||||||
published: "2021-01-01T00:00:00.000Z",
|
|
||||||
object: {
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
id: "https://example.com/notes/1",
|
|
||||||
type: "Note",
|
|
||||||
content: "Hello, world!",
|
|
||||||
summary: null,
|
|
||||||
inReplyTo: null,
|
|
||||||
published: "2021-01-01T00:00:00.000Z",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.headers.get("content-type")).toBe("application/json");
|
|
||||||
|
|
||||||
const activity = await RawActivity.getLatestById(activityId);
|
|
||||||
|
|
||||||
expect(activity).not.toBeUndefined();
|
|
||||||
expect(activity?.data).toEqual({
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
type: "Create",
|
|
||||||
id: activityId,
|
|
||||||
to: ["https://www.w3.org/ns/activitystreams#Public"],
|
|
||||||
cc: [],
|
|
||||||
published: "2021-01-01T00:00:00.000Z",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(activity?.objects).toHaveLength(1);
|
|
||||||
expect(activity?.objects[0].data).toEqual({
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
id: "https://example.com/notes/1",
|
|
||||||
type: "Note",
|
|
||||||
content: "Hello, world!",
|
|
||||||
summary: null,
|
|
||||||
inReplyTo: null,
|
|
||||||
published: "2021-01-01T00:00:00.000Z",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should try to update that Note object", async () => {
|
|
||||||
const activityId = `https://example.com/objects/${crypto.randomUUID()}`;
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`${config.http.base_url}/users/test/inbox/`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/activity+json",
|
|
||||||
Origin: "http://lysand-test.localhost",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
type: "Update",
|
|
||||||
id: activityId,
|
|
||||||
actor: {
|
|
||||||
id: `${config.http.base_url}/users/test`,
|
|
||||||
type: "Person",
|
|
||||||
preferredUsername: "test",
|
|
||||||
},
|
|
||||||
to: ["https://www.w3.org/ns/activitystreams#Public"],
|
|
||||||
cc: [],
|
|
||||||
published: "2021-01-02T00:00:00.000Z",
|
|
||||||
object: {
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
id: "https://example.com/notes/1",
|
|
||||||
type: "Note",
|
|
||||||
content: "This note has been edited!",
|
|
||||||
summary: null,
|
|
||||||
inReplyTo: null,
|
|
||||||
published: "2021-01-01T00:00:00.000Z",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.headers.get("content-type")).toBe("application/json");
|
|
||||||
|
|
||||||
const activity = await RawActivity.getLatestById(activityId);
|
|
||||||
|
|
||||||
expect(activity).not.toBeUndefined();
|
|
||||||
expect(activity?.data).toEqual({
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
type: "Update",
|
|
||||||
id: activityId,
|
|
||||||
to: ["https://www.w3.org/ns/activitystreams#Public"],
|
|
||||||
cc: [],
|
|
||||||
published: "2021-01-02T00:00:00.000Z",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(activity?.objects).toHaveLength(1);
|
|
||||||
expect(activity?.objects[0].data).toEqual({
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
id: "https://example.com/notes/1",
|
|
||||||
type: "Note",
|
|
||||||
content: "This note has been edited!",
|
|
||||||
summary: null,
|
|
||||||
inReplyTo: null,
|
|
||||||
published: "2021-01-01T00:00:00.000Z",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should delete the Note object", async () => {
|
|
||||||
const activityId = `https://example.com/objects/${crypto.randomUUID()}`;
|
|
||||||
const response = await fetch(
|
|
||||||
`${config.http.base_url}/users/test/inbox/`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/activity+json",
|
|
||||||
Origin: "http://lysand-test.localhost",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
type: "Delete",
|
|
||||||
id: activityId,
|
|
||||||
actor: {
|
|
||||||
id: `${config.http.base_url}/users/test`,
|
|
||||||
type: "Person",
|
|
||||||
preferredUsername: "test",
|
|
||||||
},
|
|
||||||
to: ["https://www.w3.org/ns/activitystreams#Public"],
|
|
||||||
cc: [],
|
|
||||||
published: "2021-01-03T00:00:00.000Z",
|
|
||||||
object: {
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
id: "https://example.com/notes/1",
|
|
||||||
type: "Note",
|
|
||||||
content: "This note has been edited!",
|
|
||||||
summary: null,
|
|
||||||
inReplyTo: null,
|
|
||||||
published: "2021-01-01T00:00:00.000Z",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.headers.get("content-type")).toBe("application/json");
|
|
||||||
|
|
||||||
const activity = await RawActivity.getLatestById(activityId);
|
|
||||||
|
|
||||||
expect(activity).not.toBeUndefined();
|
|
||||||
expect(activity?.data).toEqual({
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
type: "Delete",
|
|
||||||
id: activityId,
|
|
||||||
to: ["https://www.w3.org/ns/activitystreams#Public"],
|
|
||||||
cc: [],
|
|
||||||
published: "2021-01-03T00:00:00.000Z",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(activity?.actors).toHaveLength(1);
|
|
||||||
expect(activity?.actors[0].data).toEqual({
|
|
||||||
preferredUsername: "test",
|
|
||||||
id: `${config.http.base_url}/users/test`,
|
|
||||||
summary: "",
|
|
||||||
publicKey: {
|
|
||||||
id: `${config.http.base_url}/users/test/actor#main-key`,
|
|
||||||
owner: `${config.http.base_url}/users/test/actor`,
|
|
||||||
publicKeyPem: expect.any(String),
|
|
||||||
},
|
|
||||||
outbox: `${config.http.base_url}/users/test/outbox`,
|
|
||||||
manuallyApprovesFollowers: false,
|
|
||||||
followers: `${config.http.base_url}/users/test/followers`,
|
|
||||||
following: `${config.http.base_url}/users/test/following`,
|
|
||||||
published: expect.any(String),
|
|
||||||
name: "",
|
|
||||||
"@context": [
|
|
||||||
"https://www.w3.org/ns/activitystreams",
|
|
||||||
"https://w3id.org/security/v1",
|
|
||||||
],
|
|
||||||
icon: {
|
|
||||||
type: "Image",
|
|
||||||
url: expect.any(String),
|
|
||||||
},
|
|
||||||
image: {
|
|
||||||
type: "Image",
|
|
||||||
url: expect.any(String),
|
|
||||||
},
|
|
||||||
inbox: `${config.http.base_url}/users/test/inbox`,
|
|
||||||
type: "Person",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Can be 0 or 1 length depending on whether config.activitypub.use_tombstone is true or false
|
|
||||||
if (config.activitypub.use_tombstones) {
|
|
||||||
expect(activity?.objects).toHaveLength(1);
|
|
||||||
} else {
|
|
||||||
expect(activity?.objects).toHaveLength(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return a 404 error when trying to delete a non-existent Note object", async () => {
|
|
||||||
const activityId = `https://example.com/objects/${crypto.randomUUID()}`;
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`${config.http.base_url}/users/test/inbox/`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/activity+json",
|
|
||||||
Origin: "http://lysand-test.localhost",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
type: "Delete",
|
|
||||||
id: activityId,
|
|
||||||
actor: {
|
|
||||||
id: `${config.http.base_url}/users/test`,
|
|
||||||
type: "Person",
|
|
||||||
preferredUsername: "test",
|
|
||||||
},
|
|
||||||
to: ["https://www.w3.org/ns/activitystreams#Public"],
|
|
||||||
cc: [],
|
|
||||||
published: "2021-01-03T00:00:00.000Z",
|
|
||||||
object: {
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
id: "https://example.com/notes/2345678909876543",
|
|
||||||
type: "Note",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
|
||||||
expect(response.headers.get("content-type")).toBe("application/json");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
// Clean up user
|
|
||||||
const user = await User.findOne({
|
|
||||||
where: {
|
|
||||||
username: "test",
|
|
||||||
},
|
|
||||||
relations: userRelations,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clean up tokens
|
|
||||||
const tokens = await Token.findBy({
|
|
||||||
user: {
|
|
||||||
username: "test",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const activities = await RawActivity.createQueryBuilder("activity")
|
|
||||||
// Join objects
|
|
||||||
.leftJoinAndSelect("activity.objects", "objects")
|
|
||||||
.leftJoinAndSelect("activity.actors", "actors")
|
|
||||||
// activity.actors is a many-to-many relationship with Actor objects (it is an array of Actor objects)
|
|
||||||
// Get the actors of the activity that have data.id as `${config.http.base_url}/users/test`
|
|
||||||
.where("actors.data @> :data", {
|
|
||||||
data: JSON.stringify({
|
|
||||||
id: `${config.http.base_url}/users/test`,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.getMany();
|
|
||||||
|
|
||||||
// Delete all created objects and activities as part of testing
|
|
||||||
await Promise.all(
|
|
||||||
activities.map(async activity => {
|
|
||||||
await Promise.all(
|
|
||||||
activity.objects.map(async object => await object.remove())
|
|
||||||
);
|
|
||||||
await activity.remove();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
await Promise.all(tokens.map(async token => await token.remove()));
|
|
||||||
|
|
||||||
if (user) await user.remove();
|
|
||||||
|
|
||||||
await AppDataSource.destroy();
|
|
||||||
});
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue