Purge ActivityPub from project to start implementing Lysand

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -81,7 +81,6 @@ export default async (req: Request): Promise<Response> => {
return errorResponse("Display name contains blocked words", 422);
}
user.actor.data.name = sanitizedDisplayName;
user.display_name = sanitizedDisplayName;
}
@ -103,7 +102,6 @@ export default async (req: Request): Promise<Response> => {
return errorResponse("Bio contains blocked words", 422);
}
user.actor.data.summary = sanitizedNote;
user.note = sanitizedNote;
}
@ -196,7 +194,6 @@ export default async (req: Request): Promise<Response> => {
}
await user.save();
await user.updateActor();
return jsonResponse(await user.toAPI());
};

View file

@ -6,11 +6,8 @@ import { getConfig } from "@config";
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response";
import { sanitizeHtml } from "@sanitization";
import { APActor } from "activitypub-types";
import { sanitize } from "isomorphic-dompurify";
import { parse } from "marked";
import { Application } from "~database/entities/Application";
import { RawObject } from "~database/entities/RawObject";
import { Status, statusRelations } from "~database/entities/Status";
import { User } from "~database/entities/User";
import { APIRouteMeta } from "~types/api";

View file

@ -1,7 +1,8 @@
/* eslint-disable @typescript-eslint/require-await */
/* eslint-disable @typescript-eslint/no-unused-vars */
import { applyConfig } from "@api";
import { errorResponse, jsonLdResponse } from "@response";
import { jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { RawActivity } from "~database/entities/RawActivity";
export const meta = applyConfig({
allowedMethods: ["GET"],
@ -22,11 +23,5 @@ export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const object = await RawActivity.findOneBy({
id: matchedRoute.params.id,
});
if (!object) return errorResponse("Object not found", 404);
return jsonLdResponse(object);
return jsonResponse({});
};

View file

@ -1,97 +1 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* 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();
});
// Empty file

View file

@ -5,7 +5,6 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { AppDataSource } from "~database/datasource";
import { Application } from "~database/entities/Application";
import { Emoji } from "~database/entities/Emoji";
import { RawActivity } from "~database/entities/RawActivity";
import { Token, TokenType } from "~database/entities/Token";
import { User } from "~database/entities/User";
import { APIEmoji } from "~types/entities/emoji";
@ -63,21 +62,6 @@ describe("API Tests", () => {
});
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 user2.remove();

View file

@ -4,7 +4,6 @@ import { getConfig } from "@config";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { AppDataSource } from "~database/datasource";
import { Application } from "~database/entities/Application";
import { RawActivity } from "~database/entities/RawActivity";
import { Token, TokenType } from "~database/entities/Token";
import { User } from "~database/entities/User";
import { APIAccount } from "~types/entities/account";
@ -63,21 +62,6 @@ describe("API Tests", () => {
});
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 user2.remove();

View file

@ -4,7 +4,6 @@ import { getConfig } from "@config";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { AppDataSource } from "~database/datasource";
import { Application } from "~database/entities/Application";
import { RawActivity } from "~database/entities/RawActivity";
import { Token, TokenType } from "~database/entities/Token";
import { User } from "~database/entities/User";
import { APIContext } from "~types/entities/context";
@ -64,21 +63,6 @@ describe("API Tests", () => {
});
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 user2.remove();

View file

@ -1,320 +1 @@
import { getConfig } from "@config";
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();
});
// Empty file