diff --git a/bun.lockb b/bun.lockb index 6fc6a713..9fb7e5c1 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/config/config.example.toml b/config/config.example.toml index 081ca89f..33b13faf 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -128,6 +128,14 @@ remove_media = [] # NOT IMPLEMENTED # significantly depending on processing power) authorized_fetch = false +[instance] +name = "Lysand" +description = "A test instance of Lysand" +# URL to your instance logo (jpg files should be renamed to jpeg) +logo = "" +# URL to your instance banner (jpg files should be renamed to jpeg) +banner = "" + [filters] # Drop notes with these regex filters (only applies to new activities) diff --git a/database/entities/Emoji.ts b/database/entities/Emoji.ts index af8c5a50..45d3e5ef 100644 --- a/database/entities/Emoji.ts +++ b/database/entities/Emoji.ts @@ -2,11 +2,13 @@ import { BaseEntity, Column, Entity, + IsNull, ManyToOne, PrimaryGeneratedColumn, } from "typeorm"; import { APIEmoji } from "~types/entities/emoji"; import { Instance } from "./Instance"; +import { Emoji as LysandEmoji } from "~types/lysand/extensions/org.lysand/custom_emojis"; /** * Represents an emoji entity in the database. @@ -42,12 +44,69 @@ export class Emoji extends BaseEntity { @Column("varchar") url!: string; + /** + * The alt text for the emoji. + */ + @Column("varchar", { + nullable: true, + }) + alt!: string | null; + + /** + * The content type of the emoji. + */ + @Column("varchar") + content_type!: string; + /** * Whether the emoji is visible in the picker. */ @Column("boolean") visible_in_picker!: boolean; + /** + * Used for parsing emojis from local text + * @param text The text to parse + * @returns An array of emojis + */ + static async parseEmojis(text: string): Promise { + const regex = /:[a-zA-Z0-9_]+:/g; + const matches = text.match(regex); + if (!matches) return []; + return ( + await Promise.all( + matches.map(match => + Emoji.findOne({ + where: { + shortcode: match.slice(1, -1), + instance: IsNull(), + }, + relations: ["instance"], + }) + ) + ) + ).filter(emoji => emoji !== null) as Emoji[]; + } + + static async addIfNotExists(emoji: LysandEmoji) { + const existingEmoji = await Emoji.findOne({ + where: { + shortcode: emoji.name, + instance: IsNull(), + }, + }); + if (existingEmoji) return existingEmoji; + const newEmoji = new Emoji(); + newEmoji.shortcode = emoji.name; + // TODO: Content types + newEmoji.url = emoji.url[0].content; + newEmoji.alt = emoji.alt || null; + newEmoji.content_type = emoji.url[0].content_type; + newEmoji.visible_in_picker = true; + await newEmoji.save(); + return newEmoji; + } + /** * Converts the emoji to an APIEmoji object. * @returns The APIEmoji object. @@ -62,4 +121,17 @@ export class Emoji extends BaseEntity { category: undefined, }; } + + toLysand(): LysandEmoji { + return { + name: this.shortcode, + url: [ + { + content: this.url, + content_type: this.content_type, + }, + ], + alt: this.alt || undefined, + }; + } } diff --git a/database/entities/Instance.ts b/database/entities/Instance.ts index 145eab9d..36ee42fb 100644 --- a/database/entities/Instance.ts +++ b/database/entities/Instance.ts @@ -1,62 +1,5 @@ import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm"; -import { APIInstance } from "~types/entities/instance"; -import { APIAccount } from "~types/entities/account"; - -export interface NodeInfo { - software: { - name: string; - version: string; - }; - protocols: string[]; - version: string; - services: { - inbound: string[]; - outbound: string[]; - }; - openRegistrations: boolean; - usage: { - users: { - total: number; - activeHalfyear: number; - activeMonth: number; - }; - localPosts: number; - localComments?: number; - remotePosts?: number; - remoteComments?: number; - }; - metadata: Partial<{ - nodeName: string; - nodeDescription: string; - maintainer: { - name: string; - email: string; - }; - langs: string[]; - tosUrl: string; - repositoryUrl: string; - feedbackUrl: string; - disableRegistration: boolean; - disableLocalTimeline: boolean; - disableRecommendedTimeline: boolean; - disableGlobalTimeline: boolean; - emailRequiredForSignup: boolean; - searchFilters: boolean; - postEditing: boolean; - postImports: boolean; - enableHcaptcha: boolean; - enableRecaptcha: boolean; - maxNoteTextLength: number; - maxCaptionTextLength: number; - enableTwitterIntegration: boolean; - enableGithubIntegration: boolean; - enableDiscordIntegration: boolean; - enableEmail: boolean; - enableServiceWorker: boolean; - proxyAccountName: string | null; - themeColor: string; - }>; -} +import { ContentFormat, ServerMetadata } from "~types/lysand/Object"; /** * Represents an instance in the database. @@ -79,18 +22,27 @@ export class Instance extends BaseEntity { base_url!: string; /** - * The configuration of the instance. + * The name of the instance. */ - @Column("jsonb", { - nullable: true, - }) - instance_data?: APIInstance; + @Column("varchar") + name!: string; /** - * Instance nodeinfo data + * The description of the instance. + */ + @Column("varchar") + version!: string; + + /** + * The logo of the instance. */ @Column("jsonb") - nodeinfo!: NodeInfo; + logo?: ContentFormat[]; + + /** + * The banner of the instance. + */ + banner?: ContentFormat[]; /** * Adds an instance to the database if it doesn't already exist. @@ -114,80 +66,25 @@ export class Instance extends BaseEntity { instance.base_url = hostname; // Fetch the instance configuration - const nodeinfo: NodeInfo = await fetch(`${origin}/nodeinfo/2.0`).then( + const metadata = (await fetch(`${origin}/.well-known/lysand`).then( res => res.json() - ); + )) as Partial; - // Try to fetch configuration from Mastodon-compatible instances - if ( - ["firefish", "iceshrimp", "mastodon", "akkoma", "pleroma"].includes( - nodeinfo.software.name - ) - ) { - const instanceData: APIInstance = await fetch( - `${origin}/api/v1/instance` - ).then(res => res.json()); - - instance.instance_data = instanceData; + if (metadata.type !== "ServerMetadata") { + throw new Error("Invalid instance metadata"); } - instance.nodeinfo = nodeinfo; + if (!(metadata.name && metadata.version)) { + throw new Error("Invalid instance metadata"); + } + + instance.name = metadata.name; + instance.version = metadata.version; + instance.logo = metadata.logo; + instance.banner = metadata.banner; await instance.save(); return instance; } - - /** - * Converts the instance to an API instance. - * @returns The API instance. - */ - // eslint-disable-next-line @typescript-eslint/require-await - async toAPI(): Promise { - return { - uri: this.instance_data?.uri || this.base_url, - approval_required: this.instance_data?.approval_required || false, - email: this.instance_data?.email || "", - thumbnail: this.instance_data?.thumbnail || "", - title: this.instance_data?.title || "", - version: this.instance_data?.version || "", - configuration: this.instance_data?.configuration || { - media_attachments: { - image_matrix_limit: 0, - image_size_limit: 0, - supported_mime_types: [], - video_frame_limit: 0, - video_matrix_limit: 0, - video_size_limit: 0, - }, - polls: { - max_characters_per_option: 0, - max_expiration: 0, - max_options: 0, - min_expiration: 0, - }, - statuses: { - characters_reserved_per_url: 0, - max_characters: 0, - max_media_attachments: 0, - }, - }, - contact_account: - this.instance_data?.contact_account || ({} as APIAccount), - description: this.instance_data?.description || "", - invites_enabled: this.instance_data?.invites_enabled || false, - languages: this.instance_data?.languages || [], - registrations: this.instance_data?.registrations || false, - rules: this.instance_data?.rules || [], - stats: { - domain_count: this.instance_data?.stats.domain_count || 0, - status_count: this.instance_data?.stats.status_count || 0, - user_count: this.instance_data?.stats.user_count || 0, - }, - urls: { - streaming_api: this.instance_data?.urls.streaming_api || "", - }, - max_toot_chars: this.instance_data?.max_toot_chars || 0, - }; - } } diff --git a/database/entities/Like.ts b/database/entities/Like.ts index d28531c5..a4bb72d1 100644 --- a/database/entities/Like.ts +++ b/database/entities/Like.ts @@ -7,6 +7,8 @@ import { } from "typeorm"; import { User } from "./User"; import { Status } from "./Status"; +import { Like as LysandLike } from "~types/lysand/Object"; +import { getConfig } from "@config"; /** * Represents a Like entity in the database. @@ -29,4 +31,15 @@ export class Like extends BaseEntity { @CreateDateColumn() created_at!: Date; + + toLysand(): LysandLike { + return { + id: this.id, + author: this.liker.uri, + type: "Like", + created_at: new Date(this.created_at).toISOString(), + object: this.liked.toLysand().uri, + uri: `${getConfig().http.base_url}/actions/${this.id}`, + }; + } } diff --git a/database/entities/Object.ts b/database/entities/Object.ts new file mode 100644 index 00000000..a6d3737a --- /dev/null +++ b/database/entities/Object.ts @@ -0,0 +1,92 @@ +import { + BaseEntity, + Column, + Entity, + ManyToOne, + PrimaryGeneratedColumn, +} from "typeorm"; +import { LysandObjectType } from "~types/lysand/Object"; + +/** + * Represents a Lysand object in the database. + */ +@Entity({ + name: "objects", +}) +export class LysandObject extends BaseEntity { + /** + * The unique identifier for the object. If local, same as `remote_id` + */ + @PrimaryGeneratedColumn("uuid") + id!: string; + + /** + * UUID of the object across the network. If the object is local, same as `id` + */ + remote_id!: string; + + /** + * Any valid Lysand type, such as `Note`, `Like`, `Follow`, etc. + */ + @Column("varchar") + type!: string; + + /** + * Remote URI for the object + * Example: `https://example.com/publications/ef235cc6-d68c-4756-b0df-4e6623c4d51c` + */ + @Column("varchar") + uri!: string; + + @Column("timestamp") + created_at!: string; + + /** + * References an Actor object by URI + */ + @ManyToOne(() => LysandObject, object => object.uri, { + nullable: true, + }) + author!: LysandObject; + + @Column("jsonb") + extra_data!: Omit< + Omit, "id">, "uri">, + "type" + >; + + @Column("jsonb") + extensions!: Record; + + static new(type: string, uri: string): LysandObject { + const object = new LysandObject(); + object.type = type; + object.uri = uri; + object.created_at = new Date().toISOString(); + return object; + } + + isPublication(): boolean { + return this.type === "Note" || this.type === "Patch"; + } + + isAction(): boolean { + return [ + "Like", + "Follow", + "Dislike", + "FollowAccept", + "FollowReject", + "Undo", + "Announce", + ].includes(this.type); + } + + isActor(): boolean { + return this.type === "User"; + } + + isExtension(): boolean { + return this.type === "Extension"; + } +} diff --git a/database/entities/Status.ts b/database/entities/Status.ts index ab595371..170e5ad4 100644 --- a/database/entities/Status.ts +++ b/database/entities/Status.ts @@ -21,6 +21,8 @@ import { Emoji } from "./Emoji"; import { Instance } from "./Instance"; import { Like } from "./Like"; import { AppDataSource } from "~database/datasource"; +import { Note } from "~types/lysand/Object"; +import { htmlToText } from "html-to-text"; const config = getConfig(); @@ -95,6 +97,14 @@ export class Status extends BaseEntity { }) content!: string; + /** + * The content type of this status. + */ + @Column("varchar", { + default: "text/plain", + }) + content_type!: string; + /** * The visibility of this status. */ @@ -289,6 +299,8 @@ export class Status extends BaseEntity { sensitive: boolean; spoiler_text: string; emojis: Emoji[]; + content_type?: string; + mentions?: User[]; reply?: { status: Status; user: User; @@ -299,6 +311,7 @@ export class Status extends BaseEntity { newStatus.account = data.account; newStatus.application = data.application ?? null; newStatus.content = data.content; + newStatus.content_type = data.content_type ?? "text/plain"; newStatus.visibility = data.visibility; newStatus.sensitive = data.sensitive; newStatus.spoiler_text = data.spoiler_text; @@ -318,40 +331,44 @@ export class Status extends BaseEntity { }); // Get list of mentioned users - await Promise.all( - mentionedPeople.map(async person => { - // Check if post is in format @username or @username@instance.com - // If is @username, the user is a local user - const instanceUrl = - person.split("@").length === 3 - ? person.split("@")[2] - : null; + if (!data.mentions) { + await Promise.all( + mentionedPeople.map(async person => { + // Check if post is in format @username or @username@instance.com + // If is @username, the user is a local user + const instanceUrl = + person.split("@").length === 3 + ? person.split("@")[2] + : null; - if (instanceUrl) { - const user = await User.findOne({ - where: { - username: person.split("@")[1], - // If contains instanceUrl - instance: { - base_url: instanceUrl, + if (instanceUrl) { + const user = await User.findOne({ + where: { + username: person.split("@")[1], + // If contains instanceUrl + instance: { + base_url: instanceUrl, + }, }, - }, - relations: userRelations, - }); + relations: userRelations, + }); - newStatus.mentions.push(user as User); - } else { - const user = await User.findOne({ - where: { - username: person.split("@")[1], - }, - relations: userRelations, - }); + newStatus.mentions.push(user as User); + } else { + const user = await User.findOne({ + where: { + username: person.split("@")[1], + }, + relations: userRelations, + }); - newStatus.mentions.push(user as User); - } - }) - ); + newStatus.mentions.push(user as User); + } + }) + ); + } else { + newStatus.mentions = data.mentions; + } await newStatus.save(); return newStatus; @@ -442,4 +459,41 @@ export class Status extends BaseEntity { quote_id: undefined, }; } + + toLysand(): Note { + return { + type: "Note", + created_at: new Date(this.created_at).toISOString(), + id: this.id, + author: this.account.uri, + uri: `${config.http.base_url}/users/${this.account.id}/statuses/${this.id}`, + contents: [ + { + content: this.content, + content_type: "text/html", + }, + { + // Content converted to plaintext + content: htmlToText(this.content), + content_type: "text/plain", + }, + ], + // TODO: Add attachments + attachments: [], + is_sensitive: this.sensitive, + mentions: this.mentions.map(mention => mention.id), + // TODO: Add quotes + quotes: [], + replies_to: this.in_reply_to_post?.id + ? [this.in_reply_to_post.id] + : [], + subject: this.spoiler_text, + extensions: { + "org.lysand:custom_emojis": { + emojis: this.emojis.map(emoji => emoji.toLysand()), + }, + // TODO: Add polls and reactions + }, + }; + } } diff --git a/database/entities/User.ts b/database/entities/User.ts index abf4aab2..f4566c82 100644 --- a/database/entities/User.ts +++ b/database/entities/User.ts @@ -18,6 +18,9 @@ import { Status, statusRelations } from "./Status"; import { APISource } from "~types/entities/source"; import { Relationship } from "./Relationship"; import { Instance } from "./Instance"; +import { User as LysandUser } from "~types/lysand/Object"; +import { htmlToText } from "html-to-text"; +import { Emoji } from "./Emoji"; export const userRelations = ["relationships", "pinned_notes", "instance"]; @@ -35,6 +38,12 @@ export class User extends BaseEntity { @PrimaryGeneratedColumn("uuid") id!: string; + /** + * The user URI on the global network + */ + @Column("varchar") + uri!: string; + /** * The username for the user. */ @@ -82,6 +91,19 @@ export class User extends BaseEntity { }) is_admin!: boolean; + @Column("jsonb", { + nullable: true, + }) + endpoints!: { + liked: string; + disliked: string; + featured: string; + followers: string; + following: string; + inbox: string; + outbox: string; + } | null; + /** * The source for the user. */ @@ -147,6 +169,13 @@ export class User extends BaseEntity { @JoinTable() pinned_notes!: Status[]; + /** + * The emojis for the user. + */ + @ManyToMany(() => Emoji, emoji => emoji.id) + @JoinTable() + emojis!: Emoji[]; + /** * Get the user's avatar in raw URL format * @param config The config to use @@ -180,6 +209,76 @@ export class User extends BaseEntity { return { user: await User.retrieveFromToken(token), token }; } + static async fetchRemoteUser(uri: string) { + const response = await fetch(uri, { + method: "GET", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + }); + + const data = (await response.json()) as Partial; + + const user = new User(); + + if ( + !( + data.id && + data.username && + data.uri && + data.created_at && + data.disliked && + data.featured && + data.liked && + data.followers && + data.following && + data.inbox && + data.outbox && + data.public_key + ) + ) { + throw new Error("Invalid user data"); + } + + user.id = data.id; + user.username = data.username; + user.uri = data.uri; + user.created_at = new Date(data.created_at); + user.endpoints = { + disliked: data.disliked, + featured: data.featured, + liked: data.liked, + followers: data.followers, + following: data.following, + inbox: data.inbox, + outbox: data.outbox, + }; + + user.avatar = (data.avatar && data.avatar[0].content) || ""; + user.header = (data.header && data.header[0].content) || ""; + user.display_name = data.display_name ?? ""; + // TODO: Add bio content types + user.note = data.bio?.[0].content ?? ""; + + // Parse emojis and add them to database + const emojis = + data.extensions?.["org.lysand:custom_emojis"]?.emojis ?? []; + + for (const emoji of emojis) { + user.emojis.push(await Emoji.addIfNotExists(emoji)); + } + + user.public_key = data.public_key.public_key; + + const uriData = new URL(data.uri); + + user.instance = await Instance.addIfNotExists(uriData.origin); + + await user.save(); + return user; + } + /** * Fetches the list of followers associated with the actor and updates the user's followers */ @@ -229,6 +328,7 @@ export class User extends BaseEntity { user.note = data.bio ?? ""; user.avatar = data.avatar ?? config.defaults.avatar; user.header = data.header ?? config.defaults.avatar; + user.uri = `${config.http.base_url}/users/${user.id}`; user.relationships = []; user.instance = null; @@ -348,15 +448,10 @@ export class User extends BaseEntity { * Generates keys for the user. */ async generateKeys(): Promise { - // openssl genrsa -out private.pem 2048 - // openssl rsa -in private.pem -outform PEM -pubout -out public.pem - const keys = await crypto.subtle.generateKey( { - name: "RSASSA-PKCS1-v1_5", - hash: "SHA-256", - modulusLength: 4096, - publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + name: "ed25519", + namedCurve: "ed25519", }, true, ["sign", "verify"] @@ -366,7 +461,7 @@ export class User extends BaseEntity { String.fromCharCode.apply(null, [ ...new Uint8Array( // jesus help me what do these letters mean - await crypto.subtle.exportKey("pkcs8", keys.privateKey) + await crypto.subtle.exportKey("raw", keys.privateKey) ), ]) ); @@ -374,7 +469,7 @@ export class User extends BaseEntity { String.fromCharCode( ...new Uint8Array( // why is exporting a key so hard - await crypto.subtle.exportKey("spki", keys.publicKey) + await crypto.subtle.exportKey("raw", keys.publicKey) ) ) ); @@ -431,7 +526,7 @@ export class User extends BaseEntity { followers_count: follower_count, following_count: following_count, statuses_count: statusCount, - emojis: [], + emojis: await Promise.all(this.emojis.map(emoji => emoji.toAPI())), fields: [], bot: false, source: isOwnAccount ? this.source : undefined, @@ -451,4 +546,83 @@ export class User extends BaseEntity { role: undefined, }; } + + /** + * Should only return local users + */ + toLysand(): LysandUser { + if (this.instance !== null) { + throw new Error("Cannot convert remote user to Lysand format"); + } + + return { + id: this.id, + type: "User", + uri: this.uri, + bio: [ + { + content: this.note, + content_type: "text/html", + }, + { + content: htmlToText(this.note), + content_type: "text/plain", + }, + ], + created_at: new Date(this.created_at).toISOString(), + disliked: `${this.uri}/disliked`, + featured: `${this.uri}/featured`, + liked: `${this.uri}/liked`, + followers: `${this.uri}/followers`, + following: `${this.uri}/following`, + inbox: `${this.uri}/inbox`, + outbox: `${this.uri}/outbox`, + indexable: false, + username: this.username, + avatar: [ + { + content: this.getAvatarUrl(getConfig()) || "", + content_type: `image/${this.avatar.split(".")[1]}`, + }, + ], + header: [ + { + content: this.getHeaderUrl(getConfig()) || "", + content_type: `image/${this.header.split(".")[1]}`, + }, + ], + display_name: this.display_name, + fields: this.source.fields.map(field => ({ + key: [ + { + content: field.name, + content_type: "text/html", + }, + { + content: htmlToText(field.name), + content_type: "text/plain", + }, + ], + value: [ + { + content: field.value, + content_type: "text/html", + }, + { + content: htmlToText(field.value), + content_type: "text/plain", + }, + ], + })), + public_key: { + actor: `${getConfig().http.base_url}/users/${this.id}`, + public_key: this.public_key, + }, + extensions: { + "org.lysand:custom_emojis": { + emojis: this.emojis.map(emoji => emoji.toLysand()), + }, + }, + }; + } } diff --git a/package.json b/package.json index 0311f2fc..4aca90e9 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ ], "devDependencies": { "@julr/unocss-preset-forms": "^0.0.5", + "@types/html-to-text": "^9.0.3", "@types/jsonld": "^1.5.9", "@typescript-eslint/eslint-plugin": "^6.6.0", "@typescript-eslint/parser": "^6.6.0", @@ -61,6 +62,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.429.0", "chalk": "^5.3.0", + "html-to-text": "^9.0.5", "ip-matching": "^2.1.2", "isomorphic-dompurify": "^1.9.0", "jsonld": "^8.3.1", diff --git a/server/api/.well-known/lysand.ts b/server/api/.well-known/lysand.ts new file mode 100644 index 00000000..dd685ce0 --- /dev/null +++ b/server/api/.well-known/lysand.ts @@ -0,0 +1,50 @@ +import { jsonResponse } from "@response"; +import { MatchedRoute } from "bun"; +import { getConfig } from "@config"; +import { applyConfig } from "@api"; + +export const meta = applyConfig({ + allowedMethods: ["GET"], + auth: { + required: false, + }, + ratelimits: { + duration: 60, + max: 60, + }, + route: "/.well-known/lysand", +}); + +/** + * Lysand instance metadata endpoint + */ +export default async ( + req: Request, + matchedRoute: MatchedRoute +): Promise => { + const config = getConfig(); + // In the format acct:name@example.com + return jsonResponse({ + type: "ServerMetadata", + name: config.instance.name, + version: "0.0.1", + description: config.instance.description, + logo: config.instance.logo ? [ + { + content: config.instance.logo, + content_type: `image/${config.instance.logo.split(".")[1]}`, + } + ] : undefined, + banner: config.instance.banner ? [ + { + content: config.instance.banner, + content_type: `image/${config.instance.banner.split(".")[1]}`, + } + ] : undefined, + supported_extensions: [ + "org.lysand:custom_emojis" + ], + website: "https://lysand.org", + // TODO: Add admins, moderators field + }) +}; diff --git a/server/api/api/v1/accounts/[id]/note.ts b/server/api/api/v1/accounts/[id]/note.ts index db7f76e9..76b1fdea 100644 --- a/server/api/api/v1/accounts/[id]/note.ts +++ b/server/api/api/v1/accounts/[id]/note.ts @@ -59,8 +59,6 @@ export default async ( relationship.note = comment ?? ""; - // TODO: Implement duration - await relationship.save(); return jsonResponse(await relationship.toAPI()); }; diff --git a/server/api/api/v1/accounts/[id]/statuses.ts b/server/api/api/v1/accounts/[id]/statuses.ts index 6d2bbee8..120cfc43 100644 --- a/server/api/api/v1/accounts/[id]/statuses.ts +++ b/server/api/api/v1/accounts/[id]/statuses.ts @@ -1,8 +1,10 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; import { Status, statusAndUserRelations } from "~database/entities/Status"; import { User, userRelations } from "~database/entities/User"; import { applyConfig } from "@api"; +import { FindManyOptions } from "typeorm"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -25,10 +27,13 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; + // TODO: Add pinned const { + max_id, + min_id, + since_id, limit, exclude_reblogs, - pinned, }: { max_id?: string; since_id?: string; @@ -51,12 +56,8 @@ export default async ( if (!user) return errorResponse("User not found", 404); - if (pinned) { - // TODO: Add pinned statuses - } - - // TODO: Check if status can be seen by this user - const statuses = await Status.find({ + // Get list of boosts for this status + let query: FindManyOptions = { where: { account: { id: user.id, @@ -64,13 +65,81 @@ export default async ( isReblog: exclude_reblogs ? true : undefined, }, relations: statusAndUserRelations, - order: { - created_at: "DESC", - }, take: limit ?? 20, - }); + order: { + id: "DESC", + }, + }; + + if (max_id) { + const maxStatus = await Status.findOneBy({ id: max_id }); + if (maxStatus) { + query = { + ...query, + where: { + ...query.where, + created_at: { + ...(query.where as any)?.created_at, + $lt: maxStatus.created_at, + }, + }, + }; + } + } + + if (since_id) { + const sinceStatus = await Status.findOneBy({ id: since_id }); + if (sinceStatus) { + query = { + ...query, + where: { + ...query.where, + created_at: { + ...(query.where as any)?.created_at, + $gt: sinceStatus.created_at, + }, + }, + }; + } + } + + if (min_id) { + const minStatus = await Status.findOneBy({ id: min_id }); + if (minStatus) { + query = { + ...query, + where: { + ...query.where, + created_at: { + ...(query.where as any)?.created_at, + $gte: minStatus.created_at, + }, + }, + }; + } + } + + const objects = await Status.find(query); + + // Constuct HTTP Link header (next and prev) + const linkHeader = []; + if (objects.length > 0) { + const urlWithoutQuery = req.url.split("?")[0]; + linkHeader.push( + `<${urlWithoutQuery}?max_id=${objects[0].id}&limit=${limit}>; rel="next"` + ); + linkHeader.push( + `<${urlWithoutQuery}?since_id=${ + objects[objects.length - 1].id + }&limit=${limit}>; rel="prev"` + ); + } return jsonResponse( - await Promise.all(statuses.map(async status => await status.toAPI())) + await Promise.all(objects.map(async status => await status.toAPI())), + 200, + { + Link: linkHeader.join(", "), + } ); }; diff --git a/server/api/api/v1/accounts/update_credentials/index.ts b/server/api/api/v1/accounts/update_credentials/index.ts index a88bb82a..105c6b07 100644 --- a/server/api/api/v1/accounts/update_credentials/index.ts +++ b/server/api/api/v1/accounts/update_credentials/index.ts @@ -6,6 +6,7 @@ import { applyConfig } from "@api"; import { sanitize } from "isomorphic-dompurify"; import { sanitizeHtml } from "@sanitization"; import { uploadFile } from "~classes/media"; +import { Emoji } from "~database/entities/Emoji"; export const meta = applyConfig({ allowedMethods: ["PATCH"], @@ -81,6 +82,9 @@ export default async (req: Request): Promise => { return errorResponse("Display name contains blocked words", 422); } + // Remove emojis + user.emojis = []; + user.display_name = sanitizedDisplayName; } @@ -102,6 +106,9 @@ export default async (req: Request): Promise => { return errorResponse("Bio contains blocked words", 422); } + // Remove emojis + user.emojis = []; + user.note = sanitizedNote; } @@ -193,6 +200,18 @@ export default async (req: Request): Promise => { // user.discoverable = discoverable === "true"; } + // Parse emojis + + const displaynameEmojis = await Emoji.parseEmojis(sanitizedDisplayName); + const noteEmojis = await Emoji.parseEmojis(sanitizedNote); + + user.emojis = [...displaynameEmojis, ...noteEmojis]; + + // Deduplicate emojis + user.emojis = user.emojis.filter( + (emoji, index, self) => self.findIndex(e => e.id === emoji.id) === index + ); + await user.save(); return jsonResponse(await user.toAPI()); diff --git a/server/api/users/[username]/actor/index.ts b/server/api/users/[username]/actor/index.ts deleted file mode 100644 index 669dc0cd..00000000 --- a/server/api/users/[username]/actor/index.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { errorResponse, jsonLdResponse } from "@response"; -import { MatchedRoute } from "bun"; -import { User, userRelations } from "~database/entities/User"; -import { getConfig, getHost } from "@config"; -import { applyConfig } from "@api"; - -export const meta = applyConfig({ - allowedMethods: ["GET"], - auth: { - required: false, - }, - ratelimits: { - duration: 60, - max: 500, - }, - route: "/users/:username/actor", -}); - -/** - * ActivityPub user actor endpoinmt - */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { - // Check for Accept header - const accept = req.headers.get("Accept"); - - if (!accept || !accept.includes("application/activity+json")) { - return errorResponse("This endpoint requires an Accept header", 406); - } - - const config = getConfig(); - - const username = matchedRoute.params.username; - - const user = await User.findOne({ - where: { username }, - relations: userRelations, - }); - - if (!user) { - return errorResponse("User not found", 404); - } - - return jsonLdResponse({ - "@context": [ - "https://www.w3.org/ns/activitystreams", - "https://w3id.org/security/v1", - { - manuallyApprovesFollowers: "as:manuallyApprovesFollowers", - toot: "http://joinmastodon.org/ns#", - featured: { - "@id": "toot:featured", - "@type": "@id", - }, - featuredTags: { - "@id": "toot:featuredTags", - "@type": "@id", - }, - alsoKnownAs: { - "@id": "as:alsoKnownAs", - "@type": "@id", - }, - movedTo: { - "@id": "as:movedTo", - "@type": "@id", - }, - schema: "http://schema.org#", - PropertyValue: "schema:PropertyValue", - value: "schema:value", - discoverable: "toot:discoverable", - Device: "toot:Device", - Ed25519Signature: "toot:Ed25519Signature", - Ed25519Key: "toot:Ed25519Key", - Curve25519Key: "toot:Curve25519Key", - EncryptedMessage: "toot:EncryptedMessage", - publicKeyBase64: "toot:publicKeyBase64", - deviceId: "toot:deviceId", - claim: { - "@type": "@id", - "@id": "toot:claim", - }, - fingerprintKey: { - "@type": "@id", - "@id": "toot:fingerprintKey", - }, - identityKey: { - "@type": "@id", - "@id": "toot:identityKey", - }, - devices: { - "@type": "@id", - "@id": "toot:devices", - }, - messageFranking: "toot:messageFranking", - messageType: "toot:messageType", - cipherText: "toot:cipherText", - suspended: "toot:suspended", - Emoji: "toot:Emoji", - focalPoint: { - "@container": "@list", - "@id": "toot:focalPoint", - }, - Hashtag: "as:Hashtag", - }, - ], - id: `${config.http.base_url}/users/${user.username}`, - type: "Person", - preferredUsername: user.username, // TODO: Add user display name - name: user.username, - summary: user.note, - icon: { - type: "Image", - url: user.avatar, - mediaType: "image/png", // TODO: Set user avatar mimetype - }, - image: { - type: "Image", - url: user.header, - mediaType: "image/png", // TODO: Set user header mimetype - }, - inbox: `${config.http.base_url}/users/${user.username}/inbox`, - outbox: `${config.http.base_url}/users/${user.username}/outbox`, - followers: `${config.http.base_url}/users/${user.username}/followers`, - following: `${config.http.base_url}/users/${user.username}/following`, - liked: `${config.http.base_url}/users/${user.username}/liked`, - discoverable: true, - alsoKnownAs: [ - // TODO: Add accounts from which the user migrated - ], - manuallyApprovesFollowers: false, // TODO: Change - publicKey: { - id: `${getHost()}${config.http.base_url}/users/${ - user.username - }/actor#main-key`, - owner: `${config.http.base_url}/users/${user.username}`, - // Split the public key into PEM format - publicKeyPem: `-----BEGIN PUBLIC KEY-----\n${user.public_key - .match(/.{1,64}/g) - ?.join("\n")}\n-----END PUBLIC KEY-----`, - }, - tag: [ - // TODO: Add emojis here, and hashtags - ], - attachment: [ - // TODO: Add user attachments (I.E. profile metadata) - ], - endpoints: { - sharedInbox: `${config.http.base_url}/inbox`, - }, - }); -}; diff --git a/server/api/users/[username]/inbox/index.ts b/server/api/users/[username]/inbox/index.ts deleted file mode 100644 index 0faa8517..00000000 --- a/server/api/users/[username]/inbox/index.ts +++ /dev/null @@ -1,305 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-unused-vars */ -import { applyConfig } from "@api"; -import { getConfig } from "@config"; -import { errorResponse, jsonResponse } from "@response"; -import { - APAccept, - APActivity, - APActor, - APCreate, - APDelete, - APFollow, - APObject, - APReject, - APTombstone, - APUpdate, -} from "activitypub-types"; -import { MatchedRoute } from "bun"; -import { RawActivity } from "~database/entities/RawActivity"; -import { RawActor } from "~database/entities/RawActor"; -import { User } from "~database/entities/User"; - -export const meta = applyConfig({ - allowedMethods: ["POST"], - auth: { - required: false, - }, - ratelimits: { - duration: 60, - max: 500, - }, - route: "/users/:username/inbox", -}); - -/** - * ActivityPub user inbox endpoint - */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { - const username = matchedRoute.params.username; - - const config = getConfig(); - - try { - if ( - config.activitypub.reject_activities.includes( - new URL(req.headers.get("Origin") ?? "").hostname - ) - ) { - // Discard request - return jsonResponse({}); - } - } catch (e) { - console.error( - `[-] Error parsing Origin header of incoming Activity from ${req.headers.get( - "Origin" - )}` - ); - console.error(e); - } - - // Process request body - const body: APActivity = await req.json(); - - // Verify HTTP signature - if (config.activitypub.authorized_fetch) { - // Check if date is older than 30 seconds - const date = new Date(req.headers.get("Date") ?? ""); - - if (date.getTime() < Date.now() - 30000) { - return errorResponse("Date is too old (max 30 seconds)", 401); - } - - const signature = req.headers.get("Signature") ?? ""; - const signatureParams = signature - .split(",") - .reduce>((params, param) => { - const [key, value] = param.split("="); - params[key] = value.replace(/"/g, ""); - return params; - }, {}); - - const signedString = `(request-target): post /users/${username}/inbox\nhost: ${ - config.http.base_url - }\ndate: ${req.headers.get("Date")}`; - const signatureBuffer = new TextEncoder().encode( - signatureParams.signature - ); - const signatureBytes = new Uint8Array(signatureBuffer).buffer; - const publicKeyBuffer = (body.actor as any).publicKey.publicKeyPem; - const publicKey = await crypto.subtle.importKey( - "spki", - publicKeyBuffer, - { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, - false, - ["verify"] - ); - const verified = await crypto.subtle.verify( - { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, - publicKey, - signatureBytes, - new TextEncoder().encode(signedString) - ); - - if (!verified) { - return errorResponse("Invalid signature", 401); - } - } - - // Get the object's ActivityPub type - const type = body.type; - - switch (type) { - case "Create" as APCreate: { - // Body is an APCreate object - // Store the Create object in database - // TODO: Add authentication - - // Check is Activity already exists - const activity = await RawActivity.createIfNotExists(body); - - if (activity instanceof Response) { - return activity; - } - break; - } - case "Update" as APUpdate: { - // Body is an APUpdate object - // Replace the object in database with the new provided object - // TODO: Add authentication - - try { - if ( - config.activitypub.discard_updates.includes( - new URL(req.headers.get("Origin") ?? "").hostname - ) - ) { - // Discard request - return jsonResponse({}); - } - } catch (e) { - console.error( - `[-] Error parsing Origin header of incoming Update Activity from ${req.headers.get( - "Origin" - )}` - ); - console.error(e); - } - - const object = await RawActivity.updateObjectIfExists( - body.object as APObject - ); - - if (object instanceof Response) { - return object; - } - - const activity = await RawActivity.createIfNotExists(body, object); - - if (activity instanceof Response) { - return activity; - } - - break; - } - case "Delete" as APDelete: { - // Body is an APDelete object - // Delete the object from database - // TODO: Add authentication - - try { - if ( - config.activitypub.discard_deletes.includes( - new URL(req.headers.get("Origin") ?? "").hostname - ) - ) { - // Discard request - return jsonResponse({}); - } - } catch (e) { - console.error( - `[-] Error parsing Origin header of incoming Delete Activity from ${req.headers.get( - "Origin" - )}` - ); - console.error(e); - } - - const response = await RawActivity.deleteObjectIfExists( - body.object as APObject - ); - - if (response instanceof Response) { - return response; - } - - // Store the Delete event in the database - const activity = await RawActivity.createIfNotExists(body); - - if (activity instanceof Response) { - return activity; - } - break; - } - case "Accept" as APAccept: { - // Body is an APAccept object - // Add the actor to the object actor's followers list - - if ((body.object as APFollow).type === "Follow") { - const user = await User.getByActorId( - ((body.object as APFollow).actor as APActor).id ?? "" - ); - - if (!user) { - return errorResponse("User not found", 404); - } - - const actor = await RawActor.addIfNotExists( - body.actor as APActor - ); - - if (actor instanceof Response) { - return actor; - } - - // TODO: Add follower - - await user.save(); - } - break; - } - case "Reject" as APReject: { - // Body is an APReject object - // Mark the follow request as not pending - - if ((body.object as APFollow).type === "Follow") { - const user = await User.getByActorId( - ((body.object as APFollow).actor as APActor).id ?? "" - ); - - if (!user) { - return errorResponse("User not found", 404); - } - - const actor = await RawActor.addIfNotExists( - body.actor as APActor - ); - - if (actor instanceof Response) { - return actor; - } - - // TODO: Remove follower - - await user.save(); - } - break; - } - case "Follow" as APFollow: { - // Body is an APFollow object - // Add the actor to the object actor's followers list - - try { - if ( - config.activitypub.discard_follows.includes( - new URL(req.headers.get("Origin") ?? "").hostname - ) - ) { - // Reject request - return jsonResponse({}); - } - } catch (e) { - console.error( - `[-] Error parsing Origin header of incoming Delete Activity from ${req.headers.get( - "Origin" - )}` - ); - console.error(e); - } - - const user = await User.getByActorId( - (body.actor as APActor).id ?? "" - ); - - if (!user) { - return errorResponse("User not found", 404); - } - - const actor = await RawActor.addIfNotExists(body.actor as APActor); - - if (actor instanceof Response) { - return actor; - } - - // TODO: Add follower - - await user.save(); - break; - } - } - - return jsonResponse({}); -}; diff --git a/server/api/users/[username]/outbox/index.ts b/server/api/users/[username]/outbox/index.ts deleted file mode 100644 index 3a855163..00000000 --- a/server/api/users/[username]/outbox/index.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { errorResponse, jsonLdResponse } from "@response"; -import { MatchedRoute } from "bun"; -import { User, userRelations } from "~database/entities/User"; -import { getHost } from "@config"; -import { NodeObject, compact } from "jsonld"; -import { RawActivity } from "~database/entities/RawActivity"; -import { applyConfig } from "@api"; - -export const meta = applyConfig({ - allowedMethods: ["GET"], - auth: { - required: false, - }, - ratelimits: { - duration: 60, - max: 500, - }, - route: "/users/:username/outbox", -}); - -/** - * ActivityPub user outbox endpoint - */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { - const username = matchedRoute.params.username.split("@")[0]; - const page = Boolean(matchedRoute.query.page || "false"); - const min_id = matchedRoute.query.min_id || false; - const max_id = matchedRoute.query.max_id || false; - - const user = await User.findOne({ - where: { username }, - relations: userRelations, - }); - - if (!user) { - return errorResponse("User not found", 404); - } - - // Get the user's corresponding ActivityPub notes - const count = await RawActivity.count({ - where: { - data: { - attributedTo: `${getHost()}/@${user.username}`, - }, - }, - order: { - data: { - published: "DESC", - }, - }, - }); - - const lastPost = ( - await RawActivity.find({ - where: { - data: { - attributedTo: `${getHost()}/@${user.username}`, - }, - }, - order: { - data: { - published: "ASC", - }, - }, - take: 1, - }) - )[0]; - - if (!page) - return jsonLdResponse( - await compact({ - "@context": [ - "https://www.w3.org/ns/activitystreams", - "https://w3id.org/security/v1", - ], - id: `${getHost()}/@${user.username}/inbox`, - type: "OrderedCollection", - totalItems: count, - first: `${getHost()}/@${user.username}/outbox?page=true`, - last: `${getHost()}/@${user.username}/outbox?min_id=${ - lastPost.id - }&page=true`, - }) - ); - else { - let posts: RawActivity[] = []; - - if (min_id) { - posts = await RawActivity.find({ - where: { - data: { - attributedTo: `${getHost()}/@${user.username}`, - id: min_id, - }, - }, - order: { - data: { - published: "DESC", - }, - }, - take: 11, // Take one extra to have the ID of the next post - }); - } else if (max_id) { - posts = await RawActivity.find({ - where: { - data: { - attributedTo: `${getHost()}/@${user.username}`, - id: max_id, - }, - }, - order: { - data: { - published: "ASC", - }, - }, - take: 10, - }); - } - - return jsonLdResponse( - await compact({ - "@context": [ - "https://www.w3.org/ns/activitystreams", - { - ostatus: "http://ostatus.org#", - atomUri: "ostatus:atomUri", - inReplyToAtomUri: "ostatus:inReplyToAtomUri", - conversation: "ostatus:conversation", - sensitive: "as:sensitive", - toot: "http://joinmastodon.org/ns#", - votersCount: "toot:votersCount", - litepub: "http://litepub.social/ns#", - directMessage: "litepub:directMessage", - Emoji: "toot:Emoji", - focalPoint: { - "@container": "@list", - "@id": "toot:focalPoint", - }, - blurhash: "toot:blurhash", - }, - ], - id: `${getHost()}/@${user.username}/inbox`, - type: "OrderedCollectionPage", - totalItems: count, - partOf: `${getHost()}/@${user.username}/inbox`, - // Next is less recent posts chronologically, uses min_id - next: `${getHost()}/@${user.username}/outbox?min_id=${ - posts[posts.length - 1].id - }&page=true`, - // Prev is more recent posts chronologically, uses max_id - prev: `${getHost()}/@${user.username}/outbox?max_id=${ - posts[0].id - }&page=true`, - orderedItems: posts - .slice(0, 10) - .map(post => post.data) as NodeObject[], - }) - ); - } -}; diff --git a/server/api/users/uuid/inbox/index.ts b/server/api/users/uuid/inbox/index.ts new file mode 100644 index 00000000..bc58339c --- /dev/null +++ b/server/api/users/uuid/inbox/index.ts @@ -0,0 +1,224 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { applyConfig } from "@api"; +import { getConfig } from "@config"; +import { errorResponse, jsonResponse } from "@response"; +import { MatchedRoute } from "bun"; +import { Status } from "~database/entities/Status"; +import { User, userRelations } from "~database/entities/User"; +import { + ContentFormat, + LysandAction, + LysandObjectType, + LysandPublication, +} from "~types/lysand/Object"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + auth: { + required: false, + }, + ratelimits: { + duration: 60, + max: 500, + }, + route: "/users/:username/inbox", +}); + +/** + * ActivityPub user inbox endpoint + */ +export default async ( + req: Request, + matchedRoute: MatchedRoute +): Promise => { + const username = matchedRoute.params.username; + + const config = getConfig(); + + try { + if ( + config.activitypub.reject_activities.includes( + new URL(req.headers.get("Origin") ?? "").hostname + ) + ) { + // Discard request + return jsonResponse({}); + } + } catch (e) { + console.error( + `[-] Error parsing Origin header of incoming Activity from ${req.headers.get( + "Origin" + )}` + ); + console.error(e); + } + + // Process request body + const body = (await req.json()) as LysandPublication | LysandAction; + + const author = await User.findOne({ + where: { + uri: body.author, + }, + relations: userRelations, + }); + + if (!author) { + // TODO: Add new author to database + return errorResponse("Author not found", 404); + } + + // Verify HTTP signature + if (config.activitypub.authorized_fetch) { + // Check if date is older than 30 seconds + const origin = req.headers.get("Origin"); + + if (!origin) { + return errorResponse("Origin header is required", 401); + } + + const date = req.headers.get("Date"); + + if (!date) { + return errorResponse("Date header is required", 401); + } + + if (new Date(date).getTime() < Date.now() - 30000) { + return errorResponse("Date is too old (max 30 seconds)", 401); + } + + const signatureHeader = req.headers.get("Signature"); + + if (!signatureHeader) { + return errorResponse("Signature header is required", 401); + } + + const signature = signatureHeader + .split("signature=")[1] + .replace(/"/g, ""); + + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(await req.text()) + ); + + const expectedSignedString = + `(request-target): ${req.method.toLowerCase()} ${req.url}\n` + + `host: ${req.url}\n` + + `date: ${date}\n` + + `digest: SHA-256=${Buffer.from(digest).toString("base64")}`; + + // author.public_key is base64 encoded raw public key + const publicKey = await crypto.subtle.importKey( + "raw", + Buffer.from(author.public_key, "base64"), + { + name: "ed25519", + }, + false, + ["verify"] + ); + + // Check if signed string is valid + const isValid = await crypto.subtle.verify( + { + name: "ed25519", + saltLength: 0, + }, + publicKey, + new TextEncoder().encode(signature), + new TextEncoder().encode(expectedSignedString) + ); + + if (!isValid) { + throw new Error("Invalid signature"); + } + } + + // Get the object's ActivityPub type + const type = body.type; + + switch (type) { + case "Note": { + let content: ContentFormat | null; + + // Find the best content and content type + if ( + body.contents.find( + c => c.content_type === "text/x.misskeymarkdown" + ) + ) { + content = + body.contents.find( + c => c.content_type === "text/x.misskeymarkdown" + ) || null; + } else if ( + body.contents.find(c => c.content_type === "text/html") + ) { + content = + body.contents.find(c => c.content_type === "text/html") || + null; + } else if ( + body.contents.find(c => c.content_type === "text/markdown") + ) { + content = + body.contents.find( + c => c.content_type === "text/markdown" + ) || null; + } else if ( + body.contents.find(c => c.content_type === "text/plain") + ) { + content = + body.contents.find(c => c.content_type === "text/plain") || + null; + } else { + content = body.contents[0] || null; + } + + const status = await Status.createNew({ + account: author, + content: content?.content || "", + content_type: content?.content_type, + application: null, + // TODO: Add visibility + visibility: "public", + spoiler_text: body.subject || "", + sensitive: body.is_sensitive, + // TODO: Add emojis + emojis: [], + }); + + break; + } + case "Patch": { + break; + } + case "Like": { + break; + } + case "Dislike": { + break; + } + case "Follow": { + break; + } + case "FollowAccept": { + break; + } + case "FollowReject": { + break; + } + case "Announce": { + break; + } + case "Undo": { + break; + } + default: { + return errorResponse("Invalid type", 400); + } + } + + return jsonResponse({}); +}; diff --git a/server/api/users/uuid/index.ts b/server/api/users/uuid/index.ts new file mode 100644 index 00000000..8c6bc1c9 --- /dev/null +++ b/server/api/users/uuid/index.ts @@ -0,0 +1,44 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { applyConfig } from "@api"; +import { getConfig } from "@config"; +import { errorResponse, jsonResponse } from "@response"; +import { MatchedRoute } from "bun"; +import { User, userRelations } from "~database/entities/User"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + auth: { + required: false, + }, + ratelimits: { + duration: 60, + max: 500, + }, + route: "/users/:uuid", +}); + +/** + * ActivityPub user inbox endpoint + */ +export default async ( + req: Request, + matchedRoute: MatchedRoute +): Promise => { + const uuid = matchedRoute.params.uuid; + + const config = getConfig(); + + const user = await User.findOne({ + where: { + id: uuid, + }, + relations: userRelations, + }); + + if (!user) { + return errorResponse("User not found", 404); + } + + return jsonResponse(user.toLysand()); +}; diff --git a/server/api/users/uuid/outbox/index.ts b/server/api/users/uuid/outbox/index.ts new file mode 100644 index 00000000..16a1fa0e --- /dev/null +++ b/server/api/users/uuid/outbox/index.ts @@ -0,0 +1,67 @@ +import { jsonResponse } from "@response"; +import { MatchedRoute } from "bun"; +import { userRelations } from "~database/entities/User"; +import { getHost } from "@config"; +import { applyConfig } from "@api"; +import { Status } from "~database/entities/Status"; +import { In } from "typeorm"; + +export const meta = applyConfig({ + allowedMethods: ["GET"], + auth: { + required: false, + }, + ratelimits: { + duration: 60, + max: 500, + }, + route: "/users/:uuid/outbox", +}); + +/** + * ActivityPub user outbox endpoint + */ +export default async ( + req: Request, + matchedRoute: MatchedRoute +): Promise => { + const uuid = matchedRoute.params.uuid; + const pageNumber = Number(matchedRoute.query.page) || 1; + + const statuses = await Status.find({ + where: { + account: { + id: uuid, + }, + visibility: In(["public", "unlisted"]), + }, + relations: userRelations, + take: 20, + skip: 20 * (pageNumber - 1), + }); + + const totalStatuses = await Status.count({ + where: { + account: { + id: uuid, + }, + visibility: In(["public", "unlisted"]), + }, + relations: userRelations, + }); + + return jsonResponse({ + first: `${getHost()}/users/${uuid}/outbox?page=1`, + last: `${getHost()}/users/${uuid}/outbox?page=1`, + total_items: totalStatuses, + next: + statuses.length === 20 + ? `${getHost()}/users/${uuid}/outbox?page=${pageNumber + 1}` + : undefined, + prev: + pageNumber > 1 + ? `${getHost()}/users/${uuid}/outbox?page=${pageNumber - 1}` + : undefined, + items: statuses.map(s => s.toLysand()), + }); +}; diff --git a/types/lysand/Extension.ts b/types/lysand/Extension.ts new file mode 100644 index 00000000..cf3645a0 --- /dev/null +++ b/types/lysand/Extension.ts @@ -0,0 +1,6 @@ +import { LysandObjectType } from "./Object"; + +export interface ExtensionType extends LysandObjectType { + type: "Extension"; + extension_type: string; +} diff --git a/types/lysand/Object.ts b/types/lysand/Object.ts new file mode 100644 index 00000000..ee7fe964 --- /dev/null +++ b/types/lysand/Object.ts @@ -0,0 +1,164 @@ +import { Emoji } from "./extensions/org.lysand/custom_emojis"; + +export interface LysandObjectType { + type: string; + id: string; // Either a UUID or some kind of time-based UUID-compatible system + uri: string; // URI to the note + created_at: string; + extensions?: { + // Should be in the format + // "organization:extension_name": value + // Example: "org.joinmastodon:spoiler_text": "This is a spoiler!" + "org.lysand:custom_emojis"?: { + emojis: Emoji[]; + }; + "org.lysand:reactions"?: { + reactions: string; + }; + "org.lysand:polls"?: { + poll: { + options: ContentFormat[][]; + votes: number[]; + expires_at: string; + multiple_choice: boolean; + }; + }; + + [key: string]: any; + }; +} + +export interface ActorPublicKeyData { + public_key: string; + actor: string; +} + +export interface Collection { + first: string; + last: string; + next?: string; + prev?: string; + items: T[]; +} + +export interface User extends LysandObjectType { + type: "User"; + bio: ContentFormat[]; + + inbox: string; + outbox: string; + followers: string; + following: string; + liked: string; + disliked: string; + featured: string; + + indexable: boolean; + fields?: { + key: ContentFormat[]; + value: ContentFormat[]; + }[]; + display_name?: string; + public_key?: ActorPublicKeyData; + username: string; + avatar?: ContentFormat[]; + header?: ContentFormat[]; +} + +export interface LysandPublication extends LysandObjectType { + type: "Note" | "Patch"; + author: string; + contents: ContentFormat[]; + mentions: string[]; + replies_to: string[]; + quotes: string[]; + is_sensitive: boolean; + subject: string; + attachments: ContentFormat[][]; +} + +export interface LysandAction extends LysandObjectType { + type: + | "Like" + | "Dislike" + | "Follow" + | "FollowAccept" + | "FollowReject" + | "Announce" + | "Undo"; + author: string; +} + +/** + * A Note is a publication on the network, such as a post or comment + */ +export interface Note extends LysandPublication { + type: "Note"; +} + +/** + * A Patch is an edit to a Note + */ +export interface Patch extends LysandPublication { + type: "Patch"; + patched_id: string; + patched_at: string; +} + +export interface Like extends LysandAction { + type: "Like"; + object: string; +} + +export interface Dislike extends LysandAction { + type: "Dislike"; + object: string; +} + +export interface Announce extends LysandAction { + type: "Announce"; + object: string; +} + +export interface Undo extends LysandAction { + type: "Undo"; + object: string; +} + +export interface Follow extends LysandAction { + type: "Follow"; + followee: string; +} + +export interface FollowAccept extends LysandAction { + type: "FollowAccept"; + follower: string; +} + +export interface FollowReject extends LysandAction { + type: "FollowReject"; + follower: string; +} + +export interface ServerMetadata extends LysandObjectType { + type: "ServerMetadata"; + name: string; + version?: string; + description?: string; + website?: string; + moderators?: string[]; + admins?: string[]; + logo?: ContentFormat[]; + banner?: ContentFormat[]; + supported_extensions?: string[]; +} + +/** + * Content format is an array of objects that contain the content and the content type. + */ +export interface ContentFormat { + content: string; + content_type: string; + description?: string; + size?: string; +} diff --git a/types/lysand/extensions/org.lysand/custom_emojis.ts b/types/lysand/extensions/org.lysand/custom_emojis.ts new file mode 100644 index 00000000..f47f770f --- /dev/null +++ b/types/lysand/extensions/org.lysand/custom_emojis.ts @@ -0,0 +1,7 @@ +import { ContentFormat } from "../../Object"; + +export interface Emoji { + name: string; + url: ContentFormat[]; + alt?: string; +} diff --git a/types/lysand/extensions/org.lysand/polls.ts b/types/lysand/extensions/org.lysand/polls.ts new file mode 100644 index 00000000..af157364 --- /dev/null +++ b/types/lysand/extensions/org.lysand/polls.ts @@ -0,0 +1,14 @@ +import { ExtensionType } from "../../Extension"; + +export interface OrgLysandPollsVoteType extends ExtensionType { + extension_type: "org.lysand:polls/Vote"; + author: string; + poll: string; + option: number; +} + +export interface OrgLysandPollsVoteResultType extends ExtensionType { + extension_type: "org.lysand:polls/VoteResult"; + poll: string; + votes: number[]; +} diff --git a/types/lysand/extensions/org.lysand/reactions.ts b/types/lysand/extensions/org.lysand/reactions.ts new file mode 100644 index 00000000..b18d5256 --- /dev/null +++ b/types/lysand/extensions/org.lysand/reactions.ts @@ -0,0 +1,8 @@ +import { ExtensionType } from "../../Extension"; + +export interface OrgLysandReactionsType extends ExtensionType { + extension_type: "org.lysand:reactions/Reaction"; + author: string; + object: string; + content: string; +} \ No newline at end of file diff --git a/utils/config.ts b/utils/config.ts index 7f851f5f..fb3d7179 100644 --- a/utils/config.ts +++ b/utils/config.ts @@ -16,6 +16,13 @@ export interface ConfigType { banned_ips: string[]; }; + instance: { + name: string; + description: string; + banner: string; + logo: string; + }; + smtp: { server: string; port: number; @@ -133,6 +140,12 @@ export const configDefaults: ConfigType = { password: "postgres", database: "lysand", }, + instance: { + banner: "", + description: "", + logo: "", + name: "", + }, smtp: { password: "", port: 465,