Replace eslint and prettier with Biome

This commit is contained in:
Jesse Wierzbinski 2024-04-06 19:30:49 -10:00
parent 4a5a2ea590
commit af0d627f19
No known key found for this signature in database
199 changed files with 16493 additions and 16361 deletions

View file

@ -1,22 +1,22 @@
module.exports = { module.exports = {
extends: [ extends: [
"eslint:recommended", "eslint:recommended",
"plugin:@typescript-eslint/strict-type-checked", "plugin:@typescript-eslint/strict-type-checked",
"plugin:@typescript-eslint/stylistic", "plugin:@typescript-eslint/stylistic",
"plugin:prettier/recommended", "plugin:prettier/recommended",
], ],
parser: "@typescript-eslint/parser", parser: "@typescript-eslint/parser",
parserOptions: { parserOptions: {
project: "./tsconfig.json", project: "./tsconfig.json",
}, },
ignorePatterns: ["node_modules/", "dist/", ".eslintrc.cjs"], ignorePatterns: ["node_modules/", "dist/", ".eslintrc.cjs"],
plugins: ["@typescript-eslint"], plugins: ["@typescript-eslint"],
root: true, root: true,
rules: { rules: {
"@typescript-eslint/no-unsafe-assignment": "off", "@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-argument": "off", "@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/consistent-type-exports": "error", "@typescript-eslint/consistent-type-exports": "error",
"@typescript-eslint/consistent-type-imports": "error" "@typescript-eslint/consistent-type-imports": "error",
}, },
}; };

7
.vscode/launch.json vendored
View file

@ -4,10 +4,7 @@
"type": "node", "type": "node",
"name": "vscode-jest-tests.v2.lysand", "name": "vscode-jest-tests.v2.lysand",
"request": "launch", "request": "launch",
"args": [ "args": ["test", "${jest.testFile}"],
"test",
"${jest.testFile}"
],
"cwd": "/home/jessew/Dev/lysand", "cwd": "/home/jessew/Dev/lysand",
"console": "integratedTerminal", "console": "integratedTerminal",
"internalConsoleOptions": "neverOpen", "internalConsoleOptions": "neverOpen",
@ -15,4 +12,4 @@
"program": "/home/jessew/.bun/bin/bun" "program": "/home/jessew/.bun/bin/bun"
} }
] ]
} }

View file

@ -1,5 +1,5 @@
{ {
"typescript.tsdk": "node_modules/typescript/lib", "typescript.tsdk": "node_modules/typescript/lib",
"jest.jestCommandLine": "/home/jessew/.bun/bin/bun test", "jest.jestCommandLine": "/home/jessew/.bun/bin/bun test",
"jest.rootPath": "." "jest.rootPath": "."
} }

View file

@ -4,11 +4,11 @@ const requests: Promise<Response>[] = [];
// Repeat 1000 times // Repeat 1000 times
for (let i = 0; i < 1000; i++) { for (let i = 0; i < 1000; i++) {
requests.push( requests.push(
fetch(`https://mastodon.social`, { fetch("https://mastodon.social", {
method: "GET", method: "GET",
}) }),
); );
} }
await Promise.all(requests); await Promise.all(requests);

View file

@ -9,46 +9,46 @@ const token = process.env.TOKEN;
const requestCount = Number(process.argv[2]) || 100; const requestCount = Number(process.argv[2]) || 100;
if (!token) { if (!token) {
console.log( console.log(
`${chalk.red( `${chalk.red(
"✗" "✗",
)} No token provided. Provide one via the TOKEN environment variable.` )} No token provided. Provide one via the TOKEN environment variable.`,
); );
process.exit(1); process.exit(1);
} }
const fetchTimeline = () => const fetchTimeline = () =>
fetch(`${config.http.base_url}/api/v1/timelines/home`, { fetch(`${config.http.base_url}/api/v1/timelines/home`, {
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
}).then(res => res.ok); }).then((res) => res.ok);
const timeNow = performance.now(); const timeNow = performance.now();
const requests = Array.from({ length: requestCount }, () => fetchTimeline()); const requests = Array.from({ length: requestCount }, () => fetchTimeline());
Promise.all(requests) Promise.all(requests)
.then(results => { .then((results) => {
const timeTaken = performance.now() - timeNow; const timeTaken = performance.now() - timeNow;
if (results.every(t => t)) { if (results.every((t) => t)) {
console.log(`${chalk.green("✓")} All requests succeeded`); console.log(`${chalk.green("✓")} All requests succeeded`);
} else { } else {
console.log( console.log(
`${chalk.red("✗")} ${ `${chalk.red("✗")} ${
results.filter(t => !t).length results.filter((t) => !t).length
} requests failed` } requests failed`,
); );
} }
console.log( console.log(
`${chalk.green("✓")} ${ `${chalk.green("✓")} ${
requests.length requests.length
} requests fulfilled in ${chalk.bold( } requests fulfilled in ${chalk.bold(
(timeTaken / 1000).toFixed(5) (timeTaken / 1000).toFixed(5),
)}s` )}s`,
); );
}) })
.catch(err => { .catch((err) => {
console.log(`${chalk.red("✗")} ${err}`); console.log(`${chalk.red("✗")} ${err}`);
process.exit(1); process.exit(1);
}); });

View file

@ -1,17 +1,20 @@
{ {
"$schema": "https://biomejs.dev/schemas/1.6.4/schema.json", "$schema": "https://biomejs.dev/schemas/1.6.4/schema.json",
"organizeImports": { "organizeImports": {
"enabled": true "enabled": true,
}, "ignore": ["node_modules/**/*", "dist/**/*"]
"linter": { },
"enabled": true, "linter": {
"rules": { "enabled": true,
"recommended": true "rules": {
} "recommended": true
}, },
"formatter": { "ignore": ["node_modules/**/*", "dist/**/*"]
"enabled": true, },
"indentStyle": "space", "formatter": {
"indentWidth": 4 "enabled": true,
} "indentStyle": "space",
"indentWidth": 4,
"ignore": ["node_modules/**/*", "dist/**/*"]
}
} }

View file

@ -1,52 +1,52 @@
// Delete dist directory // Delete dist directory
import { rm, cp, mkdir, exists } from "fs/promises"; import { cp, exists, mkdir, rm } from "node:fs/promises";
import { rawRoutes } from "~routes"; import { rawRoutes } from "~routes";
if (!(await exists("./pages/dist"))) { if (!(await exists("./pages/dist"))) {
console.log("Please build the Vite server first, or use `bun prod-build`"); console.log("Please build the Vite server first, or use `bun prod-build`");
process.exit(1); process.exit(1);
} }
console.log(`Building at ${process.cwd()}`); console.log(`Building at ${process.cwd()}`);
await rm("./dist", { recursive: true }); await rm("./dist", { recursive: true });
await mkdir(process.cwd() + "/dist"); await mkdir(`${process.cwd()}/dist`);
//bun build --entrypoints ./index.ts ./prisma.ts ./cli.ts --outdir dist --target bun --splitting --minify --external bullmq,@prisma/client //bun build --entrypoints ./index.ts ./prisma.ts ./cli.ts --outdir dist --target bun --splitting --minify --external bullmq,@prisma/client
await Bun.build({ await Bun.build({
entrypoints: [ entrypoints: [
process.cwd() + "/index.ts", `${process.cwd()}/index.ts`,
process.cwd() + "/prisma.ts", `${process.cwd()}/prisma.ts`,
process.cwd() + "/cli.ts", `${process.cwd()}/cli.ts`,
// Force Bun to include endpoints // Force Bun to include endpoints
...Object.values(rawRoutes), ...Object.values(rawRoutes),
], ],
outdir: process.cwd() + "/dist", outdir: `${process.cwd()}/dist`,
target: "bun", target: "bun",
splitting: true, splitting: true,
minify: true, minify: true,
external: ["bullmq"], external: ["bullmq"],
}).then(output => { }).then((output) => {
if (!output.success) { if (!output.success) {
console.log(output.logs); console.log(output.logs);
} }
}); });
// Create pages directory // Create pages directory
// mkdir ./dist/pages // mkdir ./dist/pages
await mkdir(process.cwd() + "/dist/pages"); await mkdir(`${process.cwd()}/dist/pages`);
// Copy Vite build output to dist // Copy Vite build output to dist
// cp -r ./pages/dist ./dist/pages // cp -r ./pages/dist ./dist/pages
await cp(process.cwd() + "/pages/dist", process.cwd() + "/dist/pages/", { await cp(`${process.cwd()}/pages/dist`, `${process.cwd()}/dist/pages/`, {
recursive: true, recursive: true,
}); });
// Copy the Bee Movie script from pages // Copy the Bee Movie script from pages
await cp( await cp(
process.cwd() + "/pages/beemovie.txt", `${process.cwd()}/pages/beemovie.txt`,
process.cwd() + "/dist/pages/beemovie.txt" `${process.cwd()}/dist/pages/beemovie.txt`,
); );
console.log(`Built!`); console.log("Built!");

BIN
bun.lockb

Binary file not shown.

View file

@ -1,64 +0,0 @@
import type { APActivity, APActor } from "activitypub-types";
export class RemoteActor {
private actorData: APActor | null;
private actorUri: string;
constructor(actor: APActor | string) {
if (typeof actor === "string") {
this.actorUri = actor;
this.actorData = null;
} else {
this.actorUri = actor.id || "";
this.actorData = actor;
}
}
public async fetch() {
const response = await fetch(this.actorUri);
const actorJson = (await response.json()) as APActor;
this.actorData = actorJson;
}
public getData() {
return this.actorData;
}
}
export class RemoteActivity {
private data: APActivity | null;
private uri: string;
constructor(uri: string, data: APActivity | null) {
this.uri = uri;
this.data = data;
}
public async fetch() {
const response = await fetch(this.uri);
const json = (await response.json()) as APActivity;
this.data = json;
}
public getData() {
return this.data;
}
public async getActor() {
if (!this.data) {
throw new Error("No data");
}
if (Array.isArray(this.data.actor)) {
throw new Error("Multiple actors");
}
if (typeof this.data.actor === "string") {
const actor = new RemoteActor(this.data.actor);
await actor.fetch();
return actor.getData();
}
return new RemoteActor(this.data.actor as any);
}
}

3540
cli.ts

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@ import { PrismaClient } from "@prisma/client";
import { config } from "config-manager"; import { config } from "config-manager";
const client = new PrismaClient({ const client = new PrismaClient({
datasourceUrl: `postgresql://${config.database.username}:${config.database.password}@${config.database.host}:${config.database.port}/${config.database.database}`, datasourceUrl: `postgresql://${config.database.username}:${config.database.password}@${config.database.host}:${config.database.port}/${config.database.database}`,
}); });
/* const federationQueue = new Queue("federation", { /* const federationQueue = new Queue("federation", {

View file

@ -1,6 +1,6 @@
import type { APIApplication } from "~types/entities/application";
import type { Application } from "@prisma/client"; import type { Application } from "@prisma/client";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import type { APIApplication } from "~types/entities/application";
/** /**
* Represents an application that can authenticate with the API. * Represents an application that can authenticate with the API.
@ -12,18 +12,18 @@ import { client } from "~database/datasource";
* @returns The application associated with the given access token, or null if no such application exists. * @returns The application associated with the given access token, or null if no such application exists.
*/ */
export const getFromToken = async ( export const getFromToken = async (
token: string token: string,
): Promise<Application | null> => { ): Promise<Application | null> => {
const dbToken = await client.token.findFirst({ const dbToken = await client.token.findFirst({
where: { where: {
access_token: token, access_token: token,
}, },
include: { include: {
application: true, application: true,
}, },
}); });
return dbToken?.application || null; return dbToken?.application || null;
}; };
/** /**
@ -31,9 +31,9 @@ export const getFromToken = async (
* @returns The API application representation of this application. * @returns The API application representation of this application.
*/ */
export const applicationToAPI = (app: Application): APIApplication => { export const applicationToAPI = (app: Application): APIApplication => {
return { return {
name: app.name, name: app.name,
website: app.website, website: app.website,
vapid_key: app.vapid_key, vapid_key: app.vapid_key,
}; };
}; };

View file

@ -1,69 +1,70 @@
import type { Attachment } from "@prisma/client"; import type { Attachment } from "@prisma/client";
import type { ConfigType } from "config-manager"; import type { Config } from "config-manager";
import { MediaBackendType } from "media-manager"; import { MediaBackendType } from "media-manager";
import type { APIAsyncAttachment } from "~types/entities/async_attachment"; import type { APIAsyncAttachment } from "~types/entities/async_attachment";
import type { APIAttachment } from "~types/entities/attachment"; import type { APIAttachment } from "~types/entities/attachment";
export const attachmentToAPI = ( export const attachmentToAPI = (
attachment: Attachment attachment: Attachment,
): APIAsyncAttachment | APIAttachment => { ): APIAsyncAttachment | APIAttachment => {
let type = "unknown"; let type = "unknown";
if (attachment.mime_type.startsWith("image/")) { if (attachment.mime_type.startsWith("image/")) {
type = "image"; type = "image";
} else if (attachment.mime_type.startsWith("video/")) { } else if (attachment.mime_type.startsWith("video/")) {
type = "video"; type = "video";
} else if (attachment.mime_type.startsWith("audio/")) { } else if (attachment.mime_type.startsWith("audio/")) {
type = "audio"; type = "audio";
} }
return { return {
id: attachment.id, id: attachment.id,
type: type as any, type: type as "image" | "video" | "audio" | "unknown",
url: attachment.url, url: attachment.url,
remote_url: attachment.remote_url, remote_url: attachment.remote_url,
preview_url: attachment.thumbnail_url, preview_url: attachment.thumbnail_url,
text_url: null, text_url: null,
meta: { meta: {
width: attachment.width || undefined, width: attachment.width || undefined,
height: attachment.height || undefined, height: attachment.height || undefined,
fps: attachment.fps || undefined, fps: attachment.fps || undefined,
size: size:
attachment.width && attachment.height attachment.width && attachment.height
? `${attachment.width}x${attachment.height}` ? `${attachment.width}x${attachment.height}`
: undefined, : undefined,
duration: attachment.duration || undefined, duration: attachment.duration || undefined,
length: attachment.size?.toString() || undefined, length: attachment.size?.toString() || undefined,
aspect: aspect:
attachment.width && attachment.height attachment.width && attachment.height
? attachment.width / attachment.height ? attachment.width / attachment.height
: undefined, : undefined,
original: { original: {
width: attachment.width || undefined, width: attachment.width || undefined,
height: attachment.height || undefined, height: attachment.height || undefined,
size: size:
attachment.width && attachment.height attachment.width && attachment.height
? `${attachment.width}x${attachment.height}` ? `${attachment.width}x${attachment.height}`
: undefined, : undefined,
aspect: aspect:
attachment.width && attachment.height attachment.width && attachment.height
? attachment.width / attachment.height ? attachment.width / attachment.height
: undefined, : undefined,
}, },
// Idk whether size or length is the right value // Idk whether size or length is the right value
}, },
description: attachment.description, description: attachment.description,
blurhash: attachment.blurhash, blurhash: attachment.blurhash,
}; };
}; };
export const getUrl = (name: string, config: ConfigType) => { export const getUrl = (name: string, config: Config) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
if (config.media.backend === MediaBackendType.LOCAL) { if (config.media.backend === MediaBackendType.LOCAL) {
return `${config.http.base_url}/media/${name}`; return `${config.http.base_url}/media/${name}`;
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison, @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison, @typescript-eslint/no-unnecessary-condition
} else if (config.media.backend === MediaBackendType.S3) { }
return `${config.s3.public_url}/${name}`; if (config.media.backend === MediaBackendType.S3) {
} return `${config.s3.public_url}/${name}`;
return ""; }
return "";
}; };

View file

@ -1,7 +1,7 @@
import type { Emoji } from "@prisma/client";
import { client } from "~database/datasource";
import type { APIEmoji } from "~types/entities/emoji"; import type { APIEmoji } from "~types/entities/emoji";
import type { Emoji as LysandEmoji } from "~types/lysand/extensions/org.lysand/custom_emojis"; import type { Emoji as LysandEmoji } from "~types/lysand/extensions/org.lysand/custom_emojis";
import { client } from "~database/datasource";
import type { Emoji } from "@prisma/client";
/** /**
* Represents an emoji entity in the database. * Represents an emoji entity in the database.
@ -13,41 +13,41 @@ import type { Emoji } from "@prisma/client";
* @returns An array of emojis * @returns An array of emojis
*/ */
export const parseEmojis = async (text: string): Promise<Emoji[]> => { export const parseEmojis = async (text: string): Promise<Emoji[]> => {
const regex = /:[a-zA-Z0-9_]+:/g; const regex = /:[a-zA-Z0-9_]+:/g;
const matches = text.match(regex); const matches = text.match(regex);
if (!matches) return []; if (!matches) return [];
return await client.emoji.findMany({ return await client.emoji.findMany({
where: { where: {
shortcode: { shortcode: {
in: matches.map(match => match.replace(/:/g, "")), in: matches.map((match) => match.replace(/:/g, "")),
}, },
instanceId: null, instanceId: null,
}, },
include: { include: {
instance: true, instance: true,
}, },
}); });
}; };
export const addEmojiIfNotExists = async (emoji: LysandEmoji) => { export const addEmojiIfNotExists = async (emoji: LysandEmoji) => {
const existingEmoji = await client.emoji.findFirst({ const existingEmoji = await client.emoji.findFirst({
where: { where: {
shortcode: emoji.name, shortcode: emoji.name,
instance: null, instance: null,
}, },
}); });
if (existingEmoji) return existingEmoji; if (existingEmoji) return existingEmoji;
return await client.emoji.create({ return await client.emoji.create({
data: { data: {
shortcode: emoji.name, shortcode: emoji.name,
url: emoji.url[0].content, url: emoji.url[0].content,
alt: emoji.alt || null, alt: emoji.alt || null,
content_type: emoji.url[0].content_type, content_type: emoji.url[0].content_type,
visible_in_picker: true, visible_in_picker: true,
}, },
}); });
}; };
/** /**
@ -55,43 +55,43 @@ export const addEmojiIfNotExists = async (emoji: LysandEmoji) => {
* @returns The APIEmoji object. * @returns The APIEmoji object.
*/ */
export const emojiToAPI = (emoji: Emoji): APIEmoji => { export const emojiToAPI = (emoji: Emoji): APIEmoji => {
return { return {
shortcode: emoji.shortcode, shortcode: emoji.shortcode,
static_url: emoji.url, // TODO: Add static version static_url: emoji.url, // TODO: Add static version
url: emoji.url, url: emoji.url,
visible_in_picker: emoji.visible_in_picker, visible_in_picker: emoji.visible_in_picker,
category: undefined, category: undefined,
}; };
}; };
export const emojiToLysand = (emoji: Emoji): LysandEmoji => { export const emojiToLysand = (emoji: Emoji): LysandEmoji => {
return { return {
name: emoji.shortcode, name: emoji.shortcode,
url: [ url: [
{ {
content: emoji.url, content: emoji.url,
content_type: emoji.content_type, content_type: emoji.content_type,
}, },
], ],
alt: emoji.alt || undefined, alt: emoji.alt || undefined,
}; };
}; };
/** /**
* Converts the emoji to an ActivityPub object. * Converts the emoji to an ActivityPub object.
* @returns The ActivityPub object. * @returns The ActivityPub object.
*/ */
export const emojiToActivityPub = (emoji: Emoji): any => { export const emojiToActivityPub = (emoji: Emoji): object => {
// replace any with your ActivityPub Emoji type // replace any with your ActivityPub Emoji type
return { return {
type: "Emoji", type: "Emoji",
name: `:${emoji.shortcode}:`, name: `:${emoji.shortcode}:`,
updated: new Date().toISOString(), updated: new Date().toISOString(),
icon: { icon: {
type: "Image", type: "Image",
url: emoji.url, url: emoji.url,
mediaType: emoji.content_type, mediaType: emoji.content_type,
alt: emoji.alt || undefined, alt: emoji.alt || undefined,
}, },
}; };
}; };

View file

@ -12,38 +12,38 @@ import type { ServerMetadata } from "~types/lysand/Object";
* @returns Either the database instance if it already exists, or a newly created instance. * @returns Either the database instance if it already exists, or a newly created instance.
*/ */
export const addInstanceIfNotExists = async ( export const addInstanceIfNotExists = async (
url: string url: string,
): Promise<Instance> => { ): Promise<Instance> => {
const origin = new URL(url).origin; const origin = new URL(url).origin;
const hostname = new URL(url).hostname; const hostname = new URL(url).hostname;
const found = await client.instance.findFirst({ const found = await client.instance.findFirst({
where: { where: {
base_url: hostname, base_url: hostname,
}, },
}); });
if (found) return found; if (found) return found;
// Fetch the instance configuration // Fetch the instance configuration
const metadata = (await fetch(`${origin}/.well-known/lysand`).then(res => const metadata = (await fetch(`${origin}/.well-known/lysand`).then((res) =>
res.json() res.json(),
)) as Partial<ServerMetadata>; )) as Partial<ServerMetadata>;
if (metadata.type !== "ServerMetadata") { if (metadata.type !== "ServerMetadata") {
throw new Error("Invalid instance metadata"); throw new Error("Invalid instance metadata");
} }
if (!(metadata.name && metadata.version)) { if (!(metadata.name && metadata.version)) {
throw new Error("Invalid instance metadata"); throw new Error("Invalid instance metadata");
} }
return await client.instance.create({ return await client.instance.create({
data: { data: {
base_url: hostname, base_url: hostname,
name: metadata.name, name: metadata.name,
version: metadata.version, version: metadata.version,
logo: metadata.logo as any, logo: metadata.logo,
}, },
}); });
}; };

View file

@ -1,23 +1,25 @@
import type { Like, Prisma } from "@prisma/client";
import { config } from "config-manager";
import { client } from "~database/datasource";
/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */
import type { Like as LysandLike } from "~types/lysand/Object"; import type { Like as LysandLike } from "~types/lysand/Object";
import type { Like } from "@prisma/client";
import { client } from "~database/datasource";
import type { UserWithRelations } from "./User";
import type { StatusWithRelations } from "./Status"; import type { StatusWithRelations } from "./Status";
import { config } from "config-manager"; import type { UserWithRelations } from "./User";
/** /**
* Represents a Like entity in the database. * Represents a Like entity in the database.
*/ */
export const toLysand = (like: Like): LysandLike => { export const toLysand = (like: Like): LysandLike => {
return { return {
id: like.id, id: like.id,
author: (like as any).liker?.uri, // biome-ignore lint/suspicious/noExplicitAny: to be rewritten
type: "Like", author: (like as any).liker?.uri,
created_at: new Date(like.createdAt).toISOString(), type: "Like",
object: (like as any).liked?.uri, created_at: new Date(like.createdAt).toISOString(),
uri: `${config.http.base_url}/actions/${like.id}`, // biome-ignore lint/suspicious/noExplicitAny: to be rewritten
}; object: (like as any).liked?.uri,
uri: `${config.http.base_url}/actions/${like.id}`,
};
}; };
/** /**
@ -26,29 +28,29 @@ export const toLysand = (like: Like): LysandLike => {
* @param status Status being liked * @param status Status being liked
*/ */
export const createLike = async ( export const createLike = async (
user: UserWithRelations, user: UserWithRelations,
status: StatusWithRelations status: StatusWithRelations,
) => { ) => {
await client.like.create({ await client.like.create({
data: { data: {
likedId: status.id, likedId: status.id,
likerId: user.id, likerId: user.id,
}, },
}); });
if (status.author.instanceId === user.instanceId) { if (status.author.instanceId === user.instanceId) {
// Notify the user that their post has been favourited // Notify the user that their post has been favourited
await client.notification.create({ await client.notification.create({
data: { data: {
accountId: user.id, accountId: user.id,
type: "favourite", type: "favourite",
notifiedId: status.authorId, notifiedId: status.authorId,
statusId: status.id, statusId: status.id,
}, },
}); });
} else { } else {
// TODO: Add database jobs for federating this // TODO: Add database jobs for federating this
} }
}; };
/** /**
@ -57,28 +59,28 @@ export const createLike = async (
* @param status Status being unliked * @param status Status being unliked
*/ */
export const deleteLike = async ( export const deleteLike = async (
user: UserWithRelations, user: UserWithRelations,
status: StatusWithRelations status: StatusWithRelations,
) => { ) => {
await client.like.deleteMany({ await client.like.deleteMany({
where: { where: {
likedId: status.id, likedId: status.id,
likerId: user.id, likerId: user.id,
}, },
}); });
// Notify the user that their post has been favourited // Notify the user that their post has been favourited
await client.notification.deleteMany({ await client.notification.deleteMany({
where: { where: {
accountId: user.id, accountId: user.id,
type: "favourite", type: "favourite",
notifiedId: status.authorId, notifiedId: status.authorId,
statusId: status.id, statusId: status.id,
}, },
}); });
if (user.instanceId === null && status.author.instanceId !== null) { if (user.instanceId === null && status.author.instanceId !== null) {
// User is local, federate the delete // User is local, federate the delete
// TODO: Federate this // TODO: Federate this
} }
}; };

View file

@ -4,20 +4,20 @@ import { type StatusWithRelations, statusToAPI } from "./Status";
import { type UserWithRelations, userToAPI } from "./User"; import { type UserWithRelations, userToAPI } from "./User";
export type NotificationWithRelations = Notification & { export type NotificationWithRelations = Notification & {
status: StatusWithRelations | null; status: StatusWithRelations | null;
account: UserWithRelations; account: UserWithRelations;
}; };
export const notificationToAPI = async ( export const notificationToAPI = async (
notification: NotificationWithRelations notification: NotificationWithRelations,
): Promise<APINotification> => { ): Promise<APINotification> => {
return { return {
account: userToAPI(notification.account), account: userToAPI(notification.account),
created_at: new Date(notification.createdAt).toISOString(), created_at: new Date(notification.createdAt).toISOString(),
id: notification.id, id: notification.id,
type: notification.type, type: notification.type,
status: notification.status status: notification.status
? await statusToAPI(notification.status, notification.account) ? await statusToAPI(notification.status, notification.account)
: undefined, : undefined,
}; };
}; };

View file

@ -9,79 +9,79 @@ import type { LysandObjectType } from "~types/lysand/Object";
*/ */
export const createFromObject = async (object: LysandObjectType) => { export const createFromObject = async (object: LysandObjectType) => {
const foundObject = await client.lysandObject.findFirst({ const foundObject = await client.lysandObject.findFirst({
where: { remote_id: object.id }, where: { remote_id: object.id },
include: { include: {
author: true, author: true,
}, },
}); });
if (foundObject) { if (foundObject) {
return foundObject; return foundObject;
} }
const author = await client.lysandObject.findFirst({ const author = await client.lysandObject.findFirst({
where: { uri: (object as any).author }, // biome-ignore lint/suspicious/noExplicitAny: <explanation>
}); where: { uri: (object as any).author },
});
return await client.lysandObject.create({ return await client.lysandObject.create({
data: { data: {
authorId: author?.id, authorId: author?.id,
created_at: new Date(object.created_at), created_at: new Date(object.created_at),
extensions: object.extensions || {}, extensions: object.extensions || {},
remote_id: object.id, remote_id: object.id,
type: object.type, type: object.type,
uri: object.uri, uri: object.uri,
// Rest of data (remove id, author, created_at, extensions, type, uri) // Rest of data (remove id, author, created_at, extensions, type, uri)
extra_data: Object.fromEntries( extra_data: Object.fromEntries(
Object.entries(object).filter( Object.entries(object).filter(
([key]) => ([key]) =>
![ ![
"id", "id",
"author", "author",
"created_at", "created_at",
"extensions", "extensions",
"type", "type",
"uri", "uri",
].includes(key) ].includes(key),
) ),
), ),
}, },
}); });
}; };
export const toLysand = (lyObject: LysandObject): LysandObjectType => { export const toLysand = (lyObject: LysandObject): LysandObjectType => {
return { return {
id: lyObject.remote_id || lyObject.id, id: lyObject.remote_id || lyObject.id,
created_at: new Date(lyObject.created_at).toISOString(), created_at: new Date(lyObject.created_at).toISOString(),
type: lyObject.type, type: lyObject.type,
uri: lyObject.uri, uri: lyObject.uri,
// @ts-expect-error This works, I promise ...lyObject.extra_data,
...lyObject.extra_data, extensions: lyObject.extensions,
extensions: lyObject.extensions, };
};
}; };
export const isPublication = (lyObject: LysandObject): boolean => { export const isPublication = (lyObject: LysandObject): boolean => {
return lyObject.type === "Note" || lyObject.type === "Patch"; return lyObject.type === "Note" || lyObject.type === "Patch";
}; };
export const isAction = (lyObject: LysandObject): boolean => { export const isAction = (lyObject: LysandObject): boolean => {
return [ return [
"Like", "Like",
"Follow", "Follow",
"Dislike", "Dislike",
"FollowAccept", "FollowAccept",
"FollowReject", "FollowReject",
"Undo", "Undo",
"Announce", "Announce",
].includes(lyObject.type); ].includes(lyObject.type);
}; };
export const isActor = (lyObject: LysandObject): boolean => { export const isActor = (lyObject: LysandObject): boolean => {
return lyObject.type === "User"; return lyObject.type === "User";
}; };
export const isExtension = (lyObject: LysandObject): boolean => { export const isExtension = (lyObject: LysandObject): boolean => {
return lyObject.type === "Extension"; return lyObject.type === "Extension";
}; };

View file

@ -1,7 +1,7 @@
// import { Worker } from "bullmq";
import { statusToLysand, type StatusWithRelations } from "./Status";
import type { User } from "@prisma/client"; import type { User } from "@prisma/client";
import { config } from "config-manager"; import { config } from "config-manager";
// import { Worker } from "bullmq";
import { type StatusWithRelations, statusToLysand } from "./Status";
/* export const federationWorker = new Worker( /* export const federationWorker = new Worker(
"federation", "federation",
@ -123,68 +123,68 @@ import { config } from "config-manager";
* from https://developers.google.com/web/updates/2012/06/How-to-convert-ArrayBuffer-to-and-from-String * from https://developers.google.com/web/updates/2012/06/How-to-convert-ArrayBuffer-to-and-from-String
*/ */
export const str2ab = (str: string) => { export const str2ab = (str: string) => {
const buf = new ArrayBuffer(str.length); const buf = new ArrayBuffer(str.length);
const bufView = new Uint8Array(buf); const bufView = new Uint8Array(buf);
for (let i = 0, strLen = str.length; i < strLen; i++) { for (let i = 0, strLen = str.length; i < strLen; i++) {
bufView[i] = str.charCodeAt(i); bufView[i] = str.charCodeAt(i);
} }
return buf; return buf;
}; };
export const federateStatusTo = async ( export const federateStatusTo = async (
status: StatusWithRelations, status: StatusWithRelations,
sender: User, sender: User,
user: User user: User,
) => { ) => {
const privateKey = await crypto.subtle.importKey( const privateKey = await crypto.subtle.importKey(
"pkcs8", "pkcs8",
str2ab(atob(user.privateKey ?? "")), str2ab(atob(user.privateKey ?? "")),
"Ed25519", "Ed25519",
false, false,
["sign"] ["sign"],
); );
const digest = await crypto.subtle.digest( const digest = await crypto.subtle.digest(
"SHA-256", "SHA-256",
new TextEncoder().encode("request_body") new TextEncoder().encode("request_body"),
); );
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const userInbox = new URL(user.endpoints.inbox); const userInbox = new URL(user.endpoints.inbox);
const date = new Date(); const date = new Date();
const signature = await crypto.subtle.sign( const signature = await crypto.subtle.sign(
"Ed25519", "Ed25519",
privateKey, privateKey,
new TextEncoder().encode( new TextEncoder().encode(
`(request-target): post ${userInbox.pathname}\n` + `(request-target): post ${userInbox.pathname}\n` +
`host: ${userInbox.host}\n` + `host: ${userInbox.host}\n` +
`date: ${date.toUTCString()}\n` + `date: ${date.toUTCString()}\n` +
`digest: SHA-256=${btoa( `digest: SHA-256=${btoa(
String.fromCharCode(...new Uint8Array(digest)) String.fromCharCode(...new Uint8Array(digest)),
)}\n` )}\n`,
) ),
); );
const signatureBase64 = btoa( const signatureBase64 = btoa(
String.fromCharCode(...new Uint8Array(signature)) String.fromCharCode(...new Uint8Array(signature)),
); );
return fetch(userInbox, { return fetch(userInbox, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Date: date.toUTCString(), Date: date.toUTCString(),
Origin: config.http.base_url, Origin: config.http.base_url,
Signature: `keyId="${sender.uri}",algorithm="ed25519",headers="(request-target) host date digest",signature="${signatureBase64}"`, Signature: `keyId="${sender.uri}",algorithm="ed25519",headers="(request-target) host date digest",signature="${signatureBase64}"`,
}, },
body: JSON.stringify(statusToLysand(status)), body: JSON.stringify(statusToLysand(status)),
}); });
}; };
export const addStatusFederationJob = async (statusId: string) => { export const addStatusFederationJob = async (statusId: string) => {
/* await federationQueue.add("federation", { /* await federationQueue.add("federation", {
id: statusId, id: statusId,
}); */ }); */
}; };

View file

@ -1,6 +1,6 @@
import type { Relationship, User } from "@prisma/client"; import type { Relationship, User } from "@prisma/client";
import type { APIRelationship } from "~types/entities/relationship";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import type { APIRelationship } from "~types/entities/relationship";
/** /**
* Stores Mastodon API relationships * Stores Mastodon API relationships
@ -13,55 +13,55 @@ import { client } from "~database/datasource";
* @returns The newly created relationship. * @returns The newly created relationship.
*/ */
export const createNewRelationship = async ( export const createNewRelationship = async (
owner: User, owner: User,
other: User other: User,
): Promise<Relationship> => { ): Promise<Relationship> => {
return await client.relationship.create({ return await client.relationship.create({
data: { data: {
ownerId: owner.id, ownerId: owner.id,
subjectId: other.id, subjectId: other.id,
languages: [], languages: [],
following: false, following: false,
showingReblogs: false, showingReblogs: false,
notifying: false, notifying: false,
followedBy: false, followedBy: false,
blocking: false, blocking: false,
blockedBy: false, blockedBy: false,
muting: false, muting: false,
mutingNotifications: false, mutingNotifications: false,
requested: false, requested: false,
domainBlocking: false, domainBlocking: false,
endorsed: false, endorsed: false,
note: "", note: "",
}, },
}); });
}; };
export const checkForBidirectionalRelationships = async ( export const checkForBidirectionalRelationships = async (
user1: User, user1: User,
user2: User, user2: User,
createIfNotExists = true createIfNotExists = true,
): Promise<boolean> => { ): Promise<boolean> => {
const relationship1 = await client.relationship.findFirst({ const relationship1 = await client.relationship.findFirst({
where: { where: {
ownerId: user1.id, ownerId: user1.id,
subjectId: user2.id, subjectId: user2.id,
}, },
}); });
const relationship2 = await client.relationship.findFirst({ const relationship2 = await client.relationship.findFirst({
where: { where: {
ownerId: user2.id, ownerId: user2.id,
subjectId: user1.id, subjectId: user1.id,
}, },
}); });
if (!relationship1 && !relationship2 && createIfNotExists) { if (!relationship1 && !relationship2 && createIfNotExists) {
await createNewRelationship(user1, user2); await createNewRelationship(user1, user2);
await createNewRelationship(user2, user1); await createNewRelationship(user2, user1);
} }
return !!relationship1 && !!relationship2; return !!relationship1 && !!relationship2;
}; };
/** /**
@ -69,20 +69,20 @@ export const checkForBidirectionalRelationships = async (
* @returns The API-friendly relationship. * @returns The API-friendly relationship.
*/ */
export const relationshipToAPI = (rel: Relationship): APIRelationship => { export const relationshipToAPI = (rel: Relationship): APIRelationship => {
return { return {
blocked_by: rel.blockedBy, blocked_by: rel.blockedBy,
blocking: rel.blocking, blocking: rel.blocking,
domain_blocking: rel.domainBlocking, domain_blocking: rel.domainBlocking,
endorsed: rel.endorsed, endorsed: rel.endorsed,
followed_by: rel.followedBy, followed_by: rel.followedBy,
following: rel.following, following: rel.following,
id: rel.subjectId, id: rel.subjectId,
muting: rel.muting, muting: rel.muting,
muting_notifications: rel.mutingNotifications, muting_notifications: rel.mutingNotifications,
notifying: rel.notifying, notifying: rel.notifying,
requested: rel.requested, requested: rel.requested,
showing_reblogs: rel.showingReblogs, showing_reblogs: rel.showingReblogs,
languages: rel.languages, languages: rel.languages,
note: rel.note, note: rel.note,
}; };
}; };

View file

@ -1,37 +1,37 @@
import { getBestContentType } from "@content_types";
import { addStausToMeilisearch } from "@meilisearch";
import {
type Application,
type Emoji,
Prisma,
type Relationship,
type Status,
type User,
} from "@prisma/client";
import { sanitizeHtml } from "@sanitization";
import { config } from "config-manager";
import { htmlToText } from "html-to-text";
import linkifyHtml from "linkify-html";
import linkifyStr from "linkify-string";
import { parse } from "marked";
import { client } from "~database/datasource";
import type { APIAttachment } from "~types/entities/attachment";
import type { APIStatus } from "~types/entities/status";
import type { LysandPublication, Note } from "~types/lysand/Object";
import { applicationToAPI } from "./Application";
import { attachmentToAPI } from "./Attachment";
import { emojiToAPI, emojiToLysand, parseEmojis } from "./Emoji";
/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */
import type { UserWithRelations } from "./User"; import type { UserWithRelations } from "./User";
import { fetchRemoteUser, parseMentionsUris, userToAPI } from "./User"; import { fetchRemoteUser, parseMentionsUris, userToAPI } from "./User";
import { client } from "~database/datasource";
import type { LysandPublication, Note } from "~types/lysand/Object";
import { htmlToText } from "html-to-text";
import { getBestContentType } from "@content_types";
import {
Prisma,
type Application,
type Emoji,
type Relationship,
type Status,
type User,
} from "@prisma/client";
import { emojiToAPI, emojiToLysand, parseEmojis } from "./Emoji";
import type { APIStatus } from "~types/entities/status";
import { applicationToAPI } from "./Application";
import { attachmentToAPI } from "./Attachment";
import type { APIAttachment } from "~types/entities/attachment";
import { sanitizeHtml } from "@sanitization";
import { parse } from "marked";
import linkifyStr from "linkify-string";
import linkifyHtml from "linkify-html";
import { addStausToMeilisearch } from "@meilisearch";
import { config } from "config-manager";
import { statusAndUserRelations, userRelations } from "./relations"; import { statusAndUserRelations, userRelations } from "./relations";
const statusRelations = Prisma.validator<Prisma.StatusDefaultArgs>()({ const statusRelations = Prisma.validator<Prisma.StatusDefaultArgs>()({
include: statusAndUserRelations, include: statusAndUserRelations,
}); });
export type StatusWithRelations = Prisma.StatusGetPayload< export type StatusWithRelations = Prisma.StatusGetPayload<
typeof statusRelations typeof statusRelations
>; >;
/** /**
@ -44,76 +44,75 @@ export type StatusWithRelations = Prisma.StatusGetPayload<
* @returns Whether this status is viewable by the user. * @returns Whether this status is viewable by the user.
*/ */
export const isViewableByUser = (status: Status, user: User | null) => { export const isViewableByUser = (status: Status, user: User | null) => {
if (status.authorId === user?.id) return true; if (status.authorId === user?.id) return true;
if (status.visibility === "public") return true; if (status.visibility === "public") return true;
else if (status.visibility === "unlisted") return true; if (status.visibility === "unlisted") return true;
else if (status.visibility === "private") { if (status.visibility === "private") {
// @ts-expect-error Prisma TypeScript types dont include relations // @ts-expect-error Prisma TypeScript types dont include relations
return !!(user?.relationships as Relationship[]).find( return !!(user?.relationships as Relationship[]).find(
rel => rel.id === status.authorId (rel) => rel.id === status.authorId,
); );
} else { }
// @ts-expect-error Prisma TypeScript types dont include relations // @ts-expect-error Prisma TypeScript types dont include relations
return user && (status.mentions as User[]).includes(user); return user && (status.mentions as User[]).includes(user);
}
}; };
export const fetchFromRemote = async (uri: string): Promise<Status | null> => { export const fetchFromRemote = async (uri: string): Promise<Status | null> => {
// Check if already in database // Check if already in database
const existingStatus: StatusWithRelations | null = const existingStatus: StatusWithRelations | null =
await client.status.findFirst({ await client.status.findFirst({
where: { where: {
uri: uri, uri: uri,
}, },
include: statusAndUserRelations, include: statusAndUserRelations,
}); });
if (existingStatus) return existingStatus; if (existingStatus) return existingStatus;
const status = await fetch(uri); const status = await fetch(uri);
if (status.status === 404) return null; if (status.status === 404) return null;
const body = (await status.json()) as LysandPublication; const body = (await status.json()) as LysandPublication;
const content = getBestContentType(body.contents); const content = getBestContentType(body.contents);
const emojis = await parseEmojis(content?.content || ""); const emojis = await parseEmojis(content?.content || "");
const author = await fetchRemoteUser(body.author); const author = await fetchRemoteUser(body.author);
let replyStatus: Status | null = null; let replyStatus: Status | null = null;
let quotingStatus: Status | null = null; let quotingStatus: Status | null = null;
if (body.replies_to.length > 0) { if (body.replies_to.length > 0) {
replyStatus = await fetchFromRemote(body.replies_to[0]); replyStatus = await fetchFromRemote(body.replies_to[0]);
} }
if (body.quotes.length > 0) { if (body.quotes.length > 0) {
quotingStatus = await fetchFromRemote(body.quotes[0]); quotingStatus = await fetchFromRemote(body.quotes[0]);
} }
return await createNewStatus({ return await createNewStatus({
account: author, account: author,
content: content?.content || "", content: content?.content || "",
content_type: content?.content_type, content_type: content?.content_type,
application: null, application: null,
// TODO: Add visibility // TODO: Add visibility
visibility: "public", visibility: "public",
spoiler_text: body.subject || "", spoiler_text: body.subject || "",
uri: body.uri, uri: body.uri,
sensitive: body.is_sensitive, sensitive: body.is_sensitive,
emojis: emojis, emojis: emojis,
mentions: await parseMentionsUris(body.mentions), mentions: await parseMentionsUris(body.mentions),
reply: replyStatus reply: replyStatus
? { ? {
status: replyStatus, status: replyStatus,
user: (replyStatus as any).author, user: (replyStatus as StatusWithRelations).author,
} }
: undefined, : undefined,
quote: quotingStatus || undefined, quote: quotingStatus || undefined,
}); });
}; };
/** /**
@ -121,34 +120,34 @@ export const fetchFromRemote = async (uri: string): Promise<Status | null> => {
*/ */
// eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars
export const getAncestors = async ( export const getAncestors = async (
status: StatusWithRelations, status: StatusWithRelations,
fetcher: UserWithRelations | null fetcher: UserWithRelations | null,
) => { ) => {
const ancestors: StatusWithRelations[] = []; const ancestors: StatusWithRelations[] = [];
let currentStatus = status; let currentStatus = status;
while (currentStatus.inReplyToPostId) { while (currentStatus.inReplyToPostId) {
const parent = await client.status.findFirst({ const parent = await client.status.findFirst({
where: { where: {
id: currentStatus.inReplyToPostId, id: currentStatus.inReplyToPostId,
}, },
include: statusAndUserRelations, include: statusAndUserRelations,
}); });
if (!parent) break; if (!parent) break;
ancestors.push(parent); ancestors.push(parent);
currentStatus = parent; currentStatus = parent;
} }
// Filter for posts that are viewable by the user // Filter for posts that are viewable by the user
const viewableAncestors = ancestors.filter(ancestor => const viewableAncestors = ancestors.filter((ancestor) =>
isViewableByUser(ancestor, fetcher) isViewableByUser(ancestor, fetcher),
); );
return viewableAncestors; return viewableAncestors;
}; };
/** /**
@ -157,42 +156,42 @@ export const getAncestors = async (
*/ */
// eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars
export const getDescendants = async ( export const getDescendants = async (
status: StatusWithRelations, status: StatusWithRelations,
fetcher: UserWithRelations | null, fetcher: UserWithRelations | null,
depth = 0 depth = 0,
) => { ) => {
const descendants: StatusWithRelations[] = []; const descendants: StatusWithRelations[] = [];
const currentStatus = status; const currentStatus = status;
// Fetch all children of children of children recursively calling getDescendants // Fetch all children of children of children recursively calling getDescendants
const children = await client.status.findMany({ const children = await client.status.findMany({
where: { where: {
inReplyToPostId: currentStatus.id, inReplyToPostId: currentStatus.id,
}, },
include: statusAndUserRelations, include: statusAndUserRelations,
}); });
for (const child of children) { for (const child of children) {
descendants.push(child); descendants.push(child);
if (depth < 20) { if (depth < 20) {
const childDescendants = await getDescendants( const childDescendants = await getDescendants(
child, child,
fetcher, fetcher,
depth + 1 depth + 1,
); );
descendants.push(...childDescendants); descendants.push(...childDescendants);
} }
} }
// Filter for posts that are viewable by the user // Filter for posts that are viewable by the user
const viewableDescendants = descendants.filter(descendant => const viewableDescendants = descendants.filter((descendant) =>
isViewableByUser(descendant, fetcher) isViewableByUser(descendant, fetcher),
); );
return viewableDescendants; return viewableDescendants;
}; };
/** /**
@ -201,250 +200,250 @@ export const getDescendants = async (
* @returns A promise that resolves with the new status. * @returns A promise that resolves with the new status.
*/ */
export const createNewStatus = async (data: { export const createNewStatus = async (data: {
account: User; account: User;
application: Application | null; application: Application | null;
content: string; content: string;
visibility: APIStatus["visibility"]; visibility: APIStatus["visibility"];
sensitive: boolean; sensitive: boolean;
spoiler_text: string; spoiler_text: string;
emojis?: Emoji[]; emojis?: Emoji[];
content_type?: string; content_type?: string;
uri?: string; uri?: string;
mentions?: User[]; mentions?: User[];
media_attachments?: string[]; media_attachments?: string[];
reply?: { reply?: {
status: Status; status: Status;
user: User; user: User;
}; };
quote?: Status; quote?: Status;
}) => { }) => {
// Get people mentioned in the content (match @username or @username@domain.com mentions) // Get people mentioned in the content (match @username or @username@domain.com mentions)
const mentionedPeople = const mentionedPeople =
data.content.match(/@[a-zA-Z0-9_]+(@[a-zA-Z0-9_]+)?/g) ?? []; data.content.match(/@[a-zA-Z0-9_]+(@[a-zA-Z0-9_]+)?/g) ?? [];
let mentions = data.mentions || []; let mentions = data.mentions || [];
// Parse emojis // Parse emojis
const emojis = await parseEmojis(data.content); const emojis = await parseEmojis(data.content);
data.emojis = data.emojis ? [...data.emojis, ...emojis] : emojis; data.emojis = data.emojis ? [...data.emojis, ...emojis] : emojis;
// Get list of mentioned users // Get list of mentioned users
if (mentions.length === 0) { if (mentions.length === 0) {
mentions = await client.user.findMany({ mentions = await client.user.findMany({
where: { where: {
OR: mentionedPeople.map(person => ({ OR: mentionedPeople.map((person) => ({
username: person.split("@")[1], username: person.split("@")[1],
instance: { instance: {
base_url: person.split("@")[2], base_url: person.split("@")[2],
}, },
})), })),
}, },
include: userRelations, include: userRelations,
}); });
} }
let formattedContent; let formattedContent = "";
// Get HTML version of content // Get HTML version of content
if (data.content_type === "text/markdown") { if (data.content_type === "text/markdown") {
formattedContent = linkifyHtml( formattedContent = linkifyHtml(
await sanitizeHtml(await parse(data.content)) await sanitizeHtml(await parse(data.content)),
); );
} else if (data.content_type === "text/x.misskeymarkdown") { } else if (data.content_type === "text/x.misskeymarkdown") {
// Parse as MFM // Parse as MFM
} else { } else {
// Parse as plaintext // Parse as plaintext
formattedContent = linkifyStr(data.content); formattedContent = linkifyStr(data.content);
// Split by newline and add <p> tags // Split by newline and add <p> tags
formattedContent = formattedContent formattedContent = formattedContent
.split("\n") .split("\n")
.map(line => `<p>${line}</p>`) .map((line) => `<p>${line}</p>`)
.join("\n"); .join("\n");
} }
let status = await client.status.create({ let status = await client.status.create({
data: { data: {
authorId: data.account.id, authorId: data.account.id,
applicationId: data.application?.id, applicationId: data.application?.id,
content: formattedContent, content: formattedContent,
contentSource: data.content, contentSource: data.content,
contentType: data.content_type, contentType: data.content_type,
visibility: data.visibility, visibility: data.visibility,
sensitive: data.sensitive, sensitive: data.sensitive,
spoilerText: data.spoiler_text, spoilerText: data.spoiler_text,
emojis: { emojis: {
connect: data.emojis.map(emoji => { connect: data.emojis.map((emoji) => {
return { return {
id: emoji.id, id: emoji.id,
}; };
}), }),
}, },
attachments: data.media_attachments attachments: data.media_attachments
? { ? {
connect: data.media_attachments.map(attachment => { connect: data.media_attachments.map((attachment) => {
return { return {
id: attachment, id: attachment,
}; };
}), }),
} }
: undefined, : undefined,
inReplyToPostId: data.reply?.status.id, inReplyToPostId: data.reply?.status.id,
quotingPostId: data.quote?.id, quotingPostId: data.quote?.id,
instanceId: data.account.instanceId || undefined, instanceId: data.account.instanceId || undefined,
isReblog: false, isReblog: false,
uri: uri:
data.uri || data.uri ||
`${config.http.base_url}/statuses/FAKE-${crypto.randomUUID()}`, `${config.http.base_url}/statuses/FAKE-${crypto.randomUUID()}`,
mentions: { mentions: {
connect: mentions.map(mention => { connect: mentions.map((mention) => {
return { return {
id: mention.id, id: mention.id,
}; };
}), }),
}, },
}, },
include: statusAndUserRelations, include: statusAndUserRelations,
}); });
// Update URI // Update URI
status = await client.status.update({ status = await client.status.update({
where: { where: {
id: status.id, id: status.id,
}, },
data: { data: {
uri: data.uri || `${config.http.base_url}/statuses/${status.id}`, uri: data.uri || `${config.http.base_url}/statuses/${status.id}`,
}, },
include: statusAndUserRelations, include: statusAndUserRelations,
}); });
// Create notification // Create notification
if (status.inReplyToPost) { if (status.inReplyToPost) {
await client.notification.create({ await client.notification.create({
data: { data: {
notifiedId: status.inReplyToPost.authorId, notifiedId: status.inReplyToPost.authorId,
accountId: status.authorId, accountId: status.authorId,
type: "mention", type: "mention",
statusId: status.id, statusId: status.id,
}, },
}); });
} }
// Add to search index // Add to search index
await addStausToMeilisearch(status); await addStausToMeilisearch(status);
return status; return status;
}; };
export const editStatus = async ( export const editStatus = async (
status: StatusWithRelations, status: StatusWithRelations,
data: { data: {
content: string; content: string;
visibility?: APIStatus["visibility"]; visibility?: APIStatus["visibility"];
sensitive: boolean; sensitive: boolean;
spoiler_text: string; spoiler_text: string;
emojis?: Emoji[]; emojis?: Emoji[];
content_type?: string; content_type?: string;
uri?: string; uri?: string;
mentions?: User[]; mentions?: User[];
media_attachments?: string[]; media_attachments?: string[];
} },
) => { ) => {
// Get people mentioned in the content (match @username or @username@domain.com mentions // Get people mentioned in the content (match @username or @username@domain.com mentions
const mentionedPeople = const mentionedPeople =
data.content.match(/@[a-zA-Z0-9_]+(@[a-zA-Z0-9_]+)?/g) ?? []; data.content.match(/@[a-zA-Z0-9_]+(@[a-zA-Z0-9_]+)?/g) ?? [];
let mentions = data.mentions || []; let mentions = data.mentions || [];
// Parse emojis // Parse emojis
const emojis = await parseEmojis(data.content); const emojis = await parseEmojis(data.content);
data.emojis = data.emojis ? [...data.emojis, ...emojis] : emojis; data.emojis = data.emojis ? [...data.emojis, ...emojis] : emojis;
// Get list of mentioned users // Get list of mentioned users
if (mentions.length === 0) { if (mentions.length === 0) {
mentions = await client.user.findMany({ mentions = await client.user.findMany({
where: { where: {
OR: mentionedPeople.map(person => ({ OR: mentionedPeople.map((person) => ({
username: person.split("@")[1], username: person.split("@")[1],
instance: { instance: {
base_url: person.split("@")[2], base_url: person.split("@")[2],
}, },
})), })),
}, },
include: userRelations, include: userRelations,
}); });
} }
let formattedContent; let formattedContent = "";
// Get HTML version of content // Get HTML version of content
if (data.content_type === "text/markdown") { if (data.content_type === "text/markdown") {
formattedContent = linkifyHtml( formattedContent = linkifyHtml(
await sanitizeHtml(await parse(data.content)) await sanitizeHtml(await parse(data.content)),
); );
} else if (data.content_type === "text/x.misskeymarkdown") { } else if (data.content_type === "text/x.misskeymarkdown") {
// Parse as MFM // Parse as MFM
} else { } else {
// Parse as plaintext // Parse as plaintext
formattedContent = linkifyStr(data.content); formattedContent = linkifyStr(data.content);
// Split by newline and add <p> tags // Split by newline and add <p> tags
formattedContent = formattedContent formattedContent = formattedContent
.split("\n") .split("\n")
.map(line => `<p>${line}</p>`) .map((line) => `<p>${line}</p>`)
.join("\n"); .join("\n");
} }
const newStatus = await client.status.update({ const newStatus = await client.status.update({
where: { where: {
id: status.id, id: status.id,
}, },
data: { data: {
content: formattedContent, content: formattedContent,
contentSource: data.content, contentSource: data.content,
contentType: data.content_type, contentType: data.content_type,
visibility: data.visibility, visibility: data.visibility,
sensitive: data.sensitive, sensitive: data.sensitive,
spoilerText: data.spoiler_text, spoilerText: data.spoiler_text,
emojis: { emojis: {
connect: data.emojis.map(emoji => { connect: data.emojis.map((emoji) => {
return { return {
id: emoji.id, id: emoji.id,
}; };
}), }),
}, },
attachments: data.media_attachments attachments: data.media_attachments
? { ? {
connect: data.media_attachments.map(attachment => { connect: data.media_attachments.map((attachment) => {
return { return {
id: attachment, id: attachment,
}; };
}), }),
} }
: undefined, : undefined,
mentions: { mentions: {
connect: mentions.map(mention => { connect: mentions.map((mention) => {
return { return {
id: mention.id, id: mention.id,
}; };
}), }),
}, },
}, },
include: statusAndUserRelations, include: statusAndUserRelations,
}); });
return newStatus; return newStatus;
}; };
export const isFavouritedBy = async (status: Status, user: User) => { export const isFavouritedBy = async (status: Status, user: User) => {
return !!(await client.like.findFirst({ return !!(await client.like.findFirst({
where: { where: {
likerId: user.id, likerId: user.id,
likedId: status.id, likedId: status.id,
}, },
})); }));
}; };
/** /**
@ -452,67 +451,67 @@ export const isFavouritedBy = async (status: Status, user: User) => {
* @returns A promise that resolves with the API status. * @returns A promise that resolves with the API status.
*/ */
export const statusToAPI = async ( export const statusToAPI = async (
status: StatusWithRelations, status: StatusWithRelations,
user?: UserWithRelations user?: UserWithRelations,
): Promise<APIStatus> => { ): Promise<APIStatus> => {
return { return {
id: status.id, id: status.id,
in_reply_to_id: status.inReplyToPostId || null, in_reply_to_id: status.inReplyToPostId || null,
in_reply_to_account_id: status.inReplyToPost?.authorId || null, in_reply_to_account_id: status.inReplyToPost?.authorId || null,
// @ts-expect-error Prisma TypeScript types dont include relations // @ts-expect-error Prisma TypeScript types dont include relations
account: userToAPI(status.author), account: userToAPI(status.author),
created_at: new Date(status.createdAt).toISOString(), created_at: new Date(status.createdAt).toISOString(),
application: status.application application: status.application
? applicationToAPI(status.application) ? applicationToAPI(status.application)
: null, : null,
card: null, card: null,
content: status.content, content: status.content,
emojis: status.emojis.map(emoji => emojiToAPI(emoji)), emojis: status.emojis.map((emoji) => emojiToAPI(emoji)),
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
favourited: !!(status.likes ?? []).find( favourited: !!(status.likes ?? []).find(
like => like.likerId === user?.id (like) => like.likerId === user?.id,
), ),
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
favourites_count: (status.likes ?? []).length, favourites_count: (status.likes ?? []).length,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
media_attachments: (status.attachments ?? []).map( media_attachments: (status.attachments ?? []).map(
a => attachmentToAPI(a) as APIAttachment (a) => attachmentToAPI(a) as APIAttachment,
), ),
// @ts-expect-error Prisma TypeScript types dont include relations // @ts-expect-error Prisma TypeScript types dont include relations
mentions: status.mentions.map(mention => userToAPI(mention)), mentions: status.mentions.map((mention) => userToAPI(mention)),
language: null, language: null,
muted: user muted: user
? user.relationships.find(r => r.subjectId == status.authorId) ? user.relationships.find((r) => r.subjectId === status.authorId)
?.muting || false ?.muting || false
: false, : false,
pinned: status.pinnedBy.find(u => u.id === user?.id) ? true : false, pinned: status.pinnedBy.find((u) => u.id === user?.id) ? true : false,
// TODO: Add pols // TODO: Add pols
poll: null, poll: null,
reblog: status.reblog reblog: status.reblog
? await statusToAPI(status.reblog as unknown as StatusWithRelations) ? await statusToAPI(status.reblog as unknown as StatusWithRelations)
: null, : null,
reblogged: !!(await client.status.findFirst({ reblogged: !!(await client.status.findFirst({
where: { where: {
authorId: user?.id, authorId: user?.id,
reblogId: status.id, reblogId: status.id,
}, },
})), })),
reblogs_count: status._count.reblogs, reblogs_count: status._count.reblogs,
replies_count: status._count.replies, replies_count: status._count.replies,
sensitive: status.sensitive, sensitive: status.sensitive,
spoiler_text: status.spoilerText, spoiler_text: status.spoilerText,
tags: [], tags: [],
uri: `${config.http.base_url}/statuses/${status.id}`, uri: `${config.http.base_url}/statuses/${status.id}`,
visibility: "public", visibility: "public",
url: `${config.http.base_url}/statuses/${status.id}`, url: `${config.http.base_url}/statuses/${status.id}`,
bookmarked: false, bookmarked: false,
quote: status.quotingPost quote: status.quotingPost
? await statusToAPI( ? await statusToAPI(
status.quotingPost as unknown as StatusWithRelations status.quotingPost as unknown as StatusWithRelations,
) )
: null, : null,
quote_id: status.quotingPost?.id || undefined, quote_id: status.quotingPost?.id || undefined,
}; };
}; };
/* export const statusToActivityPub = async ( /* export const statusToActivityPub = async (
@ -563,35 +562,35 @@ export const statusToAPI = async (
}; */ }; */
export const statusToLysand = (status: StatusWithRelations): Note => { export const statusToLysand = (status: StatusWithRelations): Note => {
return { return {
type: "Note", type: "Note",
created_at: new Date(status.createdAt).toISOString(), created_at: new Date(status.createdAt).toISOString(),
id: status.id, id: status.id,
author: status.authorId, author: status.authorId,
uri: `${config.http.base_url}/users/${status.authorId}/statuses/${status.id}`, uri: `${config.http.base_url}/users/${status.authorId}/statuses/${status.id}`,
contents: [ contents: [
{ {
content: status.content, content: status.content,
content_type: "text/html", content_type: "text/html",
}, },
{ {
// Content converted to plaintext // Content converted to plaintext
content: htmlToText(status.content), content: htmlToText(status.content),
content_type: "text/plain", content_type: "text/plain",
}, },
], ],
// TODO: Add attachments // TODO: Add attachments
attachments: [], attachments: [],
is_sensitive: status.sensitive, is_sensitive: status.sensitive,
mentions: status.mentions.map(mention => mention.uri), mentions: status.mentions.map((mention) => mention.uri),
quotes: status.quotingPost ? [status.quotingPost.uri] : [], quotes: status.quotingPost ? [status.quotingPost.uri] : [],
replies_to: status.inReplyToPostId ? [status.inReplyToPostId] : [], replies_to: status.inReplyToPostId ? [status.inReplyToPostId] : [],
subject: status.spoilerText, subject: status.spoilerText,
extensions: { extensions: {
"org.lysand:custom_emojis": { "org.lysand:custom_emojis": {
emojis: status.emojis.map(emoji => emojiToLysand(emoji)), emojis: status.emojis.map((emoji) => emojiToLysand(emoji)),
}, },
// TODO: Add polls and reactions // TODO: Add polls and reactions
}, },
}; };
}; };

View file

@ -2,5 +2,5 @@
* The type of token. * The type of token.
*/ */
export enum TokenType { export enum TokenType {
BEARER = "Bearer", BEARER = "Bearer",
} }

View file

@ -1,20 +1,20 @@
import type { APIAccount } from "~types/entities/account"; import { addUserToMeilisearch } from "@meilisearch";
import type { LysandUser } from "~types/lysand/Object";
import { htmlToText } from "html-to-text";
import type { User } from "@prisma/client"; import type { User } from "@prisma/client";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { type Config, config } from "config-manager";
import { htmlToText } from "html-to-text";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { MediaBackendType } from "~packages/media-manager";
import type { APIAccount } from "~types/entities/account";
import type { APISource } from "~types/entities/source";
import type { LysandUser } from "~types/lysand/Object";
import { addEmojiIfNotExists, emojiToAPI, emojiToLysand } from "./Emoji"; import { addEmojiIfNotExists, emojiToAPI, emojiToLysand } from "./Emoji";
import { addInstanceIfNotExists } from "./Instance"; import { addInstanceIfNotExists } from "./Instance";
import type { APISource } from "~types/entities/source";
import { addUserToMeilisearch } from "@meilisearch";
import { config, type Config } from "config-manager";
import { userRelations } from "./relations"; import { userRelations } from "./relations";
import { MediaBackendType } from "~packages/media-manager";
export interface AuthData { export interface AuthData {
user: UserWithRelations | null; user: UserWithRelations | null;
token: string; token: string;
} }
/** /**
@ -23,7 +23,7 @@ export interface AuthData {
*/ */
const userRelations2 = Prisma.validator<Prisma.UserDefaultArgs>()({ const userRelations2 = Prisma.validator<Prisma.UserDefaultArgs>()({
include: userRelations, include: userRelations,
}); });
export type UserWithRelations = Prisma.UserGetPayload<typeof userRelations2>; export type UserWithRelations = Prisma.UserGetPayload<typeof userRelations2>;
@ -34,14 +34,15 @@ export type UserWithRelations = Prisma.UserGetPayload<typeof userRelations2>;
* @returns The raw URL for the user's avatar * @returns The raw URL for the user's avatar
*/ */
export const getAvatarUrl = (user: User, config: Config) => { export const getAvatarUrl = (user: User, config: Config) => {
if (!user.avatar) return config.defaults.avatar; if (!user.avatar) return config.defaults.avatar;
if (config.media.backend === MediaBackendType.LOCAL) { if (config.media.backend === MediaBackendType.LOCAL) {
return `${config.http.base_url}/media/${user.avatar}`; return `${config.http.base_url}/media/${user.avatar}`;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
} else if (config.media.backend === MediaBackendType.S3) { }
return `${config.s3.public_url}/${user.avatar}`; if (config.media.backend === MediaBackendType.S3) {
} return `${config.s3.public_url}/${user.avatar}`;
return ""; }
return "";
}; };
/** /**
@ -50,128 +51,129 @@ export const getAvatarUrl = (user: User, config: Config) => {
* @returns The raw URL for the user's header * @returns The raw URL for the user's header
*/ */
export const getHeaderUrl = (user: User, config: Config) => { export const getHeaderUrl = (user: User, config: Config) => {
if (!user.header) return config.defaults.header; if (!user.header) return config.defaults.header;
if (config.media.backend === MediaBackendType.LOCAL) { if (config.media.backend === MediaBackendType.LOCAL) {
return `${config.http.base_url}/media/${user.header}`; return `${config.http.base_url}/media/${user.header}`;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
} else if (config.media.backend === MediaBackendType.S3) { }
return `${config.s3.public_url}/${user.header}`; if (config.media.backend === MediaBackendType.S3) {
} return `${config.s3.public_url}/${user.header}`;
return ""; }
return "";
}; };
export const getFromRequest = async (req: Request): Promise<AuthData> => { export const getFromRequest = async (req: Request): Promise<AuthData> => {
// Check auth token // Check auth token
const token = req.headers.get("Authorization")?.split(" ")[1] || ""; const token = req.headers.get("Authorization")?.split(" ")[1] || "";
return { user: await retrieveUserFromToken(token), token }; return { user: await retrieveUserFromToken(token), token };
}; };
export const fetchRemoteUser = async (uri: string) => { export const fetchRemoteUser = async (uri: string) => {
// Check if user not already in database // Check if user not already in database
const foundUser = await client.user.findUnique({ const foundUser = await client.user.findUnique({
where: { where: {
uri, uri,
}, },
include: userRelations, include: userRelations,
}); });
if (foundUser) return foundUser; if (foundUser) return foundUser;
const response = await fetch(uri, { const response = await fetch(uri, {
method: "GET", method: "GET",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Accept: "application/json", Accept: "application/json",
}, },
}); });
const data = (await response.json()) as Partial<LysandUser>; const data = (await response.json()) as Partial<LysandUser>;
if ( if (
!( !(
data.id && data.id &&
data.username && data.username &&
data.uri && data.uri &&
data.created_at && data.created_at &&
data.disliked && data.disliked &&
data.featured && data.featured &&
data.liked && data.liked &&
data.followers && data.followers &&
data.following && data.following &&
data.inbox && data.inbox &&
data.outbox && data.outbox &&
data.public_key data.public_key
) )
) { ) {
throw new Error("Invalid user data"); throw new Error("Invalid user data");
} }
// Parse emojis and add them to database // Parse emojis and add them to database
const userEmojis = const userEmojis =
data.extensions?.["org.lysand:custom_emojis"]?.emojis ?? []; data.extensions?.["org.lysand:custom_emojis"]?.emojis ?? [];
const user = await client.user.create({ const user = await client.user.create({
data: { data: {
username: data.username, username: data.username,
uri: data.uri, uri: data.uri,
createdAt: new Date(data.created_at), createdAt: new Date(data.created_at),
endpoints: { endpoints: {
disliked: data.disliked, disliked: data.disliked,
featured: data.featured, featured: data.featured,
liked: data.liked, liked: data.liked,
followers: data.followers, followers: data.followers,
following: data.following, following: data.following,
inbox: data.inbox, inbox: data.inbox,
outbox: data.outbox, outbox: data.outbox,
}, },
avatar: (data.avatar && data.avatar[0].content) || "", avatar: data.avatar?.[0].content || "",
header: (data.header && data.header[0].content) || "", header: data.header?.[0].content || "",
displayName: data.display_name ?? "", displayName: data.display_name ?? "",
note: data.bio?.[0].content ?? "", note: data.bio?.[0].content ?? "",
publicKey: data.public_key.public_key, publicKey: data.public_key.public_key,
source: { source: {
language: null, language: null,
note: "", note: "",
privacy: "public", privacy: "public",
sensitive: false, sensitive: false,
fields: [], fields: [],
}, },
}, },
}); });
// Add to Meilisearch // Add to Meilisearch
await addUserToMeilisearch(user); await addUserToMeilisearch(user);
const emojis = []; const emojis = [];
for (const emoji of userEmojis) { for (const emoji of userEmojis) {
emojis.push(await addEmojiIfNotExists(emoji)); emojis.push(await addEmojiIfNotExists(emoji));
} }
const uriData = new URL(data.uri); const uriData = new URL(data.uri);
return await client.user.update({ return await client.user.update({
where: { where: {
id: user.id, id: user.id,
}, },
data: { data: {
emojis: { emojis: {
connect: emojis.map(emoji => ({ connect: emojis.map((emoji) => ({
id: emoji.id, id: emoji.id,
})), })),
}, },
instanceId: (await addInstanceIfNotExists(uriData.origin)).id, instanceId: (await addInstanceIfNotExists(uriData.origin)).id,
}, },
include: userRelations, include: userRelations,
}); });
}; };
/** /**
* Fetches the list of followers associated with the actor and updates the user's followers * Fetches the list of followers associated with the actor and updates the user's followers
*/ */
export const fetchFollowers = () => { export const fetchFollowers = () => {
// //
}; };
/** /**
@ -180,75 +182,75 @@ export const fetchFollowers = () => {
* @returns The newly created user. * @returns The newly created user.
*/ */
export const createNewLocalUser = async (data: { export const createNewLocalUser = async (data: {
username: string; username: string;
display_name?: string; display_name?: string;
password: string; password: string;
email: string; email: string;
bio?: string; bio?: string;
avatar?: string; avatar?: string;
header?: string; header?: string;
admin?: boolean; admin?: boolean;
}) => { }) => {
const keys = await generateUserKeys(); const keys = await generateUserKeys();
const user = await client.user.create({ const user = await client.user.create({
data: { data: {
username: data.username, username: data.username,
displayName: data.display_name ?? data.username, displayName: data.display_name ?? data.username,
password: await Bun.password.hash(data.password), password: await Bun.password.hash(data.password),
email: data.email, email: data.email,
note: data.bio ?? "", note: data.bio ?? "",
avatar: data.avatar ?? config.defaults.avatar, avatar: data.avatar ?? config.defaults.avatar,
header: data.header ?? config.defaults.avatar, header: data.header ?? config.defaults.avatar,
isAdmin: data.admin ?? false, isAdmin: data.admin ?? false,
uri: "", uri: "",
publicKey: keys.public_key, publicKey: keys.public_key,
privateKey: keys.private_key, privateKey: keys.private_key,
source: { source: {
language: null, language: null,
note: "", note: "",
privacy: "public", privacy: "public",
sensitive: false, sensitive: false,
fields: [], fields: [],
}, },
}, },
}); });
// Add to Meilisearch // Add to Meilisearch
await addUserToMeilisearch(user); await addUserToMeilisearch(user);
return await client.user.update({ return await client.user.update({
where: { where: {
id: user.id, id: user.id,
}, },
data: { data: {
uri: `${config.http.base_url}/users/${user.id}`, uri: `${config.http.base_url}/users/${user.id}`,
endpoints: { endpoints: {
disliked: `${config.http.base_url}/users/${user.id}/disliked`, disliked: `${config.http.base_url}/users/${user.id}/disliked`,
featured: `${config.http.base_url}/users/${user.id}/featured`, featured: `${config.http.base_url}/users/${user.id}/featured`,
liked: `${config.http.base_url}/users/${user.id}/liked`, liked: `${config.http.base_url}/users/${user.id}/liked`,
followers: `${config.http.base_url}/users/${user.id}/followers`, followers: `${config.http.base_url}/users/${user.id}/followers`,
following: `${config.http.base_url}/users/${user.id}/following`, following: `${config.http.base_url}/users/${user.id}/following`,
inbox: `${config.http.base_url}/users/${user.id}/inbox`, inbox: `${config.http.base_url}/users/${user.id}/inbox`,
outbox: `${config.http.base_url}/users/${user.id}/outbox`, outbox: `${config.http.base_url}/users/${user.id}/outbox`,
}, },
}, },
include: userRelations, include: userRelations,
}); });
}; };
/** /**
* Parses mentions from a list of URIs * Parses mentions from a list of URIs
*/ */
export const parseMentionsUris = async (mentions: string[]) => { export const parseMentionsUris = async (mentions: string[]) => {
return await client.user.findMany({ return await client.user.findMany({
where: { where: {
uri: { uri: {
in: mentions, in: mentions,
}, },
}, },
include: userRelations, include: userRelations,
}); });
}; };
/** /**
@ -257,22 +259,22 @@ export const parseMentionsUris = async (mentions: string[]) => {
* @returns The user associated with the given access token. * @returns The user associated with the given access token.
*/ */
export const retrieveUserFromToken = async (access_token: string) => { export const retrieveUserFromToken = async (access_token: string) => {
if (!access_token) return null; if (!access_token) return null;
const token = await client.token.findFirst({ const token = await client.token.findFirst({
where: { where: {
access_token, access_token,
}, },
include: { include: {
user: { user: {
include: userRelations, include: userRelations,
}, },
}, },
}); });
if (!token) return null; if (!token) return null;
return token.user; return token.user;
}; };
/** /**
@ -281,174 +283,174 @@ export const retrieveUserFromToken = async (access_token: string) => {
* @returns The relationship to the other user. * @returns The relationship to the other user.
*/ */
export const getRelationshipToOtherUser = async ( export const getRelationshipToOtherUser = async (
user: UserWithRelations, user: UserWithRelations,
other: User other: User,
) => { ) => {
return await client.relationship.findFirst({ return await client.relationship.findFirst({
where: { where: {
ownerId: user.id, ownerId: user.id,
subjectId: other.id, subjectId: other.id,
}, },
}); });
}; };
/** /**
* Generates keys for the user. * Generates keys for the user.
*/ */
export const generateUserKeys = async () => { export const generateUserKeys = async () => {
const keys = await crypto.subtle.generateKey("Ed25519", true, [ const keys = await crypto.subtle.generateKey("Ed25519", true, [
"sign", "sign",
"verify", "verify",
]); ]);
const privateKey = btoa( const privateKey = btoa(
String.fromCharCode.apply(null, [ String.fromCharCode.apply(null, [
...new Uint8Array( ...new Uint8Array(
// jesus help me what do these letters mean // jesus help me what do these letters mean
await crypto.subtle.exportKey("pkcs8", keys.privateKey) await crypto.subtle.exportKey("pkcs8", keys.privateKey),
), ),
]) ]),
); );
const publicKey = btoa( const publicKey = btoa(
String.fromCharCode( String.fromCharCode(
...new Uint8Array( ...new Uint8Array(
// why is exporting a key so hard // why is exporting a key so hard
await crypto.subtle.exportKey("spki", keys.publicKey) await crypto.subtle.exportKey("spki", keys.publicKey),
) ),
) ),
); );
// Add header, footer and newlines later on // Add header, footer and newlines later on
// These keys are base64 encrypted // These keys are base64 encrypted
return { return {
private_key: privateKey, private_key: privateKey,
public_key: publicKey, public_key: publicKey,
}; };
}; };
export const userToAPI = ( export const userToAPI = (
user: UserWithRelations, user: UserWithRelations,
isOwnAccount = false isOwnAccount = false,
): APIAccount => { ): APIAccount => {
return { return {
id: user.id, id: user.id,
username: user.username, username: user.username,
display_name: user.displayName, display_name: user.displayName,
note: user.note, note: user.note,
url: user.uri, url: user.uri,
avatar: getAvatarUrl(user, config), avatar: getAvatarUrl(user, config),
header: getHeaderUrl(user, config), header: getHeaderUrl(user, config),
locked: user.isLocked, locked: user.isLocked,
created_at: new Date(user.createdAt).toISOString(), created_at: new Date(user.createdAt).toISOString(),
followers_count: user.relationshipSubjects.filter(r => r.following) followers_count: user.relationshipSubjects.filter((r) => r.following)
.length, .length,
following_count: user.relationships.filter(r => r.following).length, following_count: user.relationships.filter((r) => r.following).length,
statuses_count: user._count.statuses, statuses_count: user._count.statuses,
emojis: user.emojis.map(emoji => emojiToAPI(emoji)), emojis: user.emojis.map((emoji) => emojiToAPI(emoji)),
// TODO: Add fields // TODO: Add fields
fields: [], fields: [],
bot: user.isBot, bot: user.isBot,
source: source:
isOwnAccount && user.source isOwnAccount && user.source
? (user.source as APISource) ? (user.source as APISource)
: undefined, : undefined,
// TODO: Add static avatar and header // TODO: Add static avatar and header
avatar_static: "", avatar_static: "",
header_static: "", header_static: "",
acct: acct:
user.instance === null user.instance === null
? user.username ? user.username
: `${user.username}@${user.instance.base_url}`, : `${user.username}@${user.instance.base_url}`,
// TODO: Add these fields // TODO: Add these fields
limited: false, limited: false,
moved: null, moved: null,
noindex: false, noindex: false,
suspended: false, suspended: false,
discoverable: undefined, discoverable: undefined,
mute_expires_at: undefined, mute_expires_at: undefined,
group: false, group: false,
pleroma: { pleroma: {
is_admin: user.isAdmin, is_admin: user.isAdmin,
is_moderator: user.isAdmin, is_moderator: user.isAdmin,
}, },
}; };
}; };
/** /**
* Should only return local users * Should only return local users
*/ */
export const userToLysand = (user: UserWithRelations): LysandUser => { export const userToLysand = (user: UserWithRelations): LysandUser => {
if (user.instanceId !== null) { if (user.instanceId !== null) {
throw new Error("Cannot convert remote user to Lysand format"); throw new Error("Cannot convert remote user to Lysand format");
} }
return { return {
id: user.id, id: user.id,
type: "User", type: "User",
uri: user.uri, uri: user.uri,
bio: [ bio: [
{ {
content: user.note, content: user.note,
content_type: "text/html", content_type: "text/html",
}, },
{ {
content: htmlToText(user.note), content: htmlToText(user.note),
content_type: "text/plain", content_type: "text/plain",
}, },
], ],
created_at: new Date(user.createdAt).toISOString(), created_at: new Date(user.createdAt).toISOString(),
disliked: `${user.uri}/disliked`, disliked: `${user.uri}/disliked`,
featured: `${user.uri}/featured`, featured: `${user.uri}/featured`,
liked: `${user.uri}/liked`, liked: `${user.uri}/liked`,
followers: `${user.uri}/followers`, followers: `${user.uri}/followers`,
following: `${user.uri}/following`, following: `${user.uri}/following`,
inbox: `${user.uri}/inbox`, inbox: `${user.uri}/inbox`,
outbox: `${user.uri}/outbox`, outbox: `${user.uri}/outbox`,
indexable: false, indexable: false,
username: user.username, username: user.username,
avatar: [ avatar: [
{ {
content: getAvatarUrl(user, config) || "", content: getAvatarUrl(user, config) || "",
content_type: `image/${user.avatar.split(".")[1]}`, content_type: `image/${user.avatar.split(".")[1]}`,
}, },
], ],
header: [ header: [
{ {
content: getHeaderUrl(user, config) || "", content: getHeaderUrl(user, config) || "",
content_type: `image/${user.header.split(".")[1]}`, content_type: `image/${user.header.split(".")[1]}`,
}, },
], ],
display_name: user.displayName, display_name: user.displayName,
fields: (user.source as any as APISource).fields.map(field => ({ fields: (user.source as APISource).fields.map((field) => ({
key: [ key: [
{ {
content: field.name, content: field.name,
content_type: "text/html", content_type: "text/html",
}, },
{ {
content: htmlToText(field.name), content: htmlToText(field.name),
content_type: "text/plain", content_type: "text/plain",
}, },
], ],
value: [ value: [
{ {
content: field.value, content: field.value,
content_type: "text/html", content_type: "text/html",
}, },
{ {
content: htmlToText(field.value), content: htmlToText(field.value),
content_type: "text/plain", content_type: "text/plain",
}, },
], ],
})), })),
public_key: { public_key: {
actor: `${config.http.base_url}/users/${user.id}`, actor: `${config.http.base_url}/users/${user.id}`,
public_key: user.publicKey, public_key: user.publicKey,
}, },
extensions: { extensions: {
"org.lysand:custom_emojis": { "org.lysand:custom_emojis": {
emojis: user.emojis.map(emoji => emojiToLysand(emoji)), emojis: user.emojis.map((emoji) => emojiToLysand(emoji)),
}, },
}, },
}; };
}; };

View file

@ -1,111 +1,111 @@
import type { Prisma } from "@prisma/client"; import type { Prisma } from "@prisma/client";
export const userRelations: Prisma.UserInclude = { export const userRelations: Prisma.UserInclude = {
emojis: true, emojis: true,
instance: true, instance: true,
likes: true, likes: true,
relationships: true, relationships: true,
relationshipSubjects: true, relationshipSubjects: true,
pinnedNotes: true, pinnedNotes: true,
_count: { _count: {
select: { select: {
statuses: true, statuses: true,
likes: true, likes: true,
}, },
}, },
}; };
export const statusAndUserRelations: Prisma.StatusInclude = { export const statusAndUserRelations: Prisma.StatusInclude = {
author: { author: {
include: userRelations, include: userRelations,
}, },
application: true, application: true,
emojis: true, emojis: true,
inReplyToPost: { inReplyToPost: {
include: { include: {
author: { author: {
include: userRelations, include: userRelations,
}, },
application: true, application: true,
emojis: true, emojis: true,
inReplyToPost: { inReplyToPost: {
include: { include: {
author: true, author: true,
}, },
}, },
instance: true, instance: true,
mentions: true, mentions: true,
pinnedBy: true, pinnedBy: true,
_count: { _count: {
select: { select: {
replies: true, replies: true,
}, },
}, },
}, },
}, },
reblogs: true, reblogs: true,
attachments: true, attachments: true,
instance: true, instance: true,
mentions: { mentions: {
include: userRelations, include: userRelations,
}, },
pinnedBy: true, pinnedBy: true,
_count: { _count: {
select: { select: {
replies: true, replies: true,
likes: true, likes: true,
reblogs: true, reblogs: true,
}, },
}, },
reblog: { reblog: {
include: { include: {
author: { author: {
include: userRelations, include: userRelations,
}, },
application: true, application: true,
emojis: true, emojis: true,
inReplyToPost: { inReplyToPost: {
include: { include: {
author: true, author: true,
}, },
}, },
instance: true, instance: true,
mentions: { mentions: {
include: userRelations, include: userRelations,
}, },
pinnedBy: true, pinnedBy: true,
_count: { _count: {
select: { select: {
replies: true, replies: true,
}, },
}, },
}, },
}, },
quotingPost: { quotingPost: {
include: { include: {
author: { author: {
include: userRelations, include: userRelations,
}, },
application: true, application: true,
emojis: true, emojis: true,
inReplyToPost: { inReplyToPost: {
include: { include: {
author: true, author: true,
}, },
}, },
instance: true, instance: true,
mentions: true, mentions: true,
pinnedBy: true, pinnedBy: true,
_count: { _count: {
select: { select: {
replies: true, replies: true,
}, },
}, },
}, },
}, },
likes: { likes: {
include: { include: {
liker: true, liker: true,
}, },
}, },
}; };

View file

@ -1,73 +1,75 @@
import { exists, mkdir } from "node:fs/promises";
import { connectMeili } from "@meilisearch";
import { moduleIsEntry } from "@module";
import type { PrismaClientInitializationError } from "@prisma/client/runtime/library"; import type { PrismaClientInitializationError } from "@prisma/client/runtime/library";
import { initializeRedisCache } from "@redis"; import { initializeRedisCache } from "@redis";
import { connectMeili } from "@meilisearch";
import { config } from "config-manager"; import { config } from "config-manager";
import { client } from "~database/datasource";
import { LogLevel, LogManager, MultiLogManager } from "log-manager"; import { LogLevel, LogManager, MultiLogManager } from "log-manager";
import { moduleIsEntry } from "@module"; import { client } from "~database/datasource";
import { createServer } from "~server"; import { createServer } from "~server";
import { exists, mkdir } from "fs/promises";
const timeAtStart = performance.now(); const timeAtStart = performance.now();
const requests_log = Bun.file(process.cwd() + "/logs/requests.log"); const requests_log = Bun.file(`${process.cwd()}/logs/requests.log`);
const isEntry = moduleIsEntry(import.meta.url); const isEntry = moduleIsEntry(import.meta.url);
// If imported as a module, redirect logs to /dev/null to not pollute console (e.g. in tests) // If imported as a module, redirect logs to /dev/null to not pollute console (e.g. in tests)
const logger = new LogManager(isEntry ? requests_log : Bun.file(`/dev/null`)); const logger = new LogManager(isEntry ? requests_log : Bun.file("/dev/null"));
const consoleLogger = new LogManager( const consoleLogger = new LogManager(
isEntry ? Bun.stdout : Bun.file(`/dev/null`) isEntry ? Bun.stdout : Bun.file("/dev/null"),
); );
const dualLogger = new MultiLogManager([logger, consoleLogger]); const dualLogger = new MultiLogManager([logger, consoleLogger]);
if (!(await exists(config.logging.storage.requests))) { if (!(await exists(config.logging.storage.requests))) {
await consoleLogger.log( await consoleLogger.log(
LogLevel.WARNING, LogLevel.WARNING,
"Lysand", "Lysand",
`Creating logs directory at ${process.cwd()}/logs/` `Creating logs directory at ${process.cwd()}/logs/`,
); );
await mkdir(process.cwd() + "/logs/"); await mkdir(`${process.cwd()}/logs/`);
} }
await dualLogger.log(LogLevel.INFO, "Lysand", "Starting Lysand..."); await dualLogger.log(LogLevel.INFO, "Lysand", "Starting Lysand...");
// NODE_ENV seems to be broken and output `development` even when set to production, so use the flag instead // NODE_ENV seems to be broken and output `development` even when set to production, so use the flag instead
const isProd = const isProd =
process.env.NODE_ENV === "production" || process.argv.includes("--prod"); process.env.NODE_ENV === "production" || process.argv.includes("--prod");
const redisCache = await initializeRedisCache(); const redisCache = await initializeRedisCache();
if (config.meilisearch.enabled) { if (config.meilisearch.enabled) {
await connectMeili(dualLogger); await connectMeili(dualLogger);
} }
if (redisCache) { if (redisCache) {
client.$use(redisCache); client.$use(redisCache);
} }
// Check if database is reachable // Check if database is reachable
let postCount = 0; let postCount = 0;
try { try {
postCount = await client.status.count(); postCount = await client.status.count();
} catch (e) { } catch (e) {
const error = e as PrismaClientInitializationError; const error = e as PrismaClientInitializationError;
await logger.logError(LogLevel.CRITICAL, "Database", error); await logger.logError(LogLevel.CRITICAL, "Database", error);
await consoleLogger.logError(LogLevel.CRITICAL, "Database", error); await consoleLogger.logError(LogLevel.CRITICAL, "Database", error);
process.exit(1); process.exit(1);
} }
const server = createServer(config, dualLogger, isProd); const server = createServer(config, dualLogger, isProd);
await dualLogger.log( await dualLogger.log(
LogLevel.INFO, LogLevel.INFO,
"Server", "Server",
`Lysand started at ${config.http.bind}:${config.http.bind_port} in ${(performance.now() - timeAtStart).toFixed(0)}ms` `Lysand started at ${config.http.bind}:${config.http.bind_port} in ${(
performance.now() - timeAtStart
).toFixed(0)}ms`,
); );
await dualLogger.log( await dualLogger.log(
LogLevel.INFO, LogLevel.INFO,
"Database", "Database",
`Database is online, now serving ${postCount} posts` `Database is online, now serving ${postCount} posts`,
); );
export { config, server }; export { config, server };

View file

@ -1,130 +1,118 @@
{ {
"name": "lysand", "name": "lysand",
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
"version": "0.3.0", "version": "0.3.0",
"description": "A project to build a federated social network", "description": "A project to build a federated social network",
"author": { "author": {
"email": "contact@cpluspatch.com", "email": "contact@cpluspatch.com",
"name": "CPlusPatch", "name": "CPlusPatch",
"url": "https://cpluspatch.com" "url": "https://cpluspatch.com"
}, },
"bugs": { "bugs": {
"url": "https://github.com/lysand-org/lysand/issues" "url": "https://github.com/lysand-org/lysand/issues"
}, },
"icon": "https://github.com/lysand-org/lysand", "icon": "https://github.com/lysand-org/lysand",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"keywords": [ "keywords": ["federated", "activitypub", "bun"],
"federated", "workspaces": ["packages/*"],
"activitypub", "maintainers": [
"bun" {
], "email": "contact@cpluspatch.com",
"workspaces": ["packages/*"], "name": "CPlusPatch",
"maintainers": [ "url": "https://cpluspatch.com"
{ }
"email": "contact@cpluspatch.com", ],
"name": "CPlusPatch", "repository": {
"url": "https://cpluspatch.com" "type": "git",
"url": "git+https://github.com/lysand-org/lysand.git"
},
"private": true,
"scripts": {
"dev": "bun run --watch index.ts",
"vite:dev": "bunx --bun vite pages",
"vite:build": "bunx --bun vite build pages",
"start": "NODE_ENV=production bun run dist/index.js --prod",
"migrate-dev": "bun prisma migrate dev",
"migrate": "bun prisma migrate deploy",
"lint": "bunx --bun eslint --config .eslintrc.cjs --ext .ts .",
"prod-build": "bunx --bun vite build pages && bun run build.ts",
"prisma": "DATABASE_URL=$(bun run prisma.ts) bunx prisma",
"generate": "bun prisma generate",
"benchmark:timeline": "bun run benchmarks/timelines.ts",
"cloc": "cloc . --exclude-dir node_modules,dist",
"cli": "bun run cli.ts"
},
"trustedDependencies": [
"@biomejs/biome",
"@prisma/client",
"@prisma/engines",
"esbuild",
"prisma",
"sharp"
],
"devDependencies": {
"@biomejs/biome": "1.6.4",
"@julr/unocss-preset-forms": "^0.1.0",
"@types/cli-table": "^0.3.4",
"@types/html-to-text": "^9.0.4",
"@types/ioredis": "^5.0.0",
"@types/jsonld": "^1.5.13",
"@typescript-eslint/eslint-plugin": "latest",
"@unocss/cli": "latest",
"@vitejs/plugin-vue": "latest",
"@vueuse/head": "^2.0.0",
"activitypub-types": "^1.0.3",
"bun-types": "latest",
"typescript": "latest",
"unocss": "latest",
"untyped": "^1.4.2",
"vite": "latest",
"vite-ssr": "^0.17.1",
"vue": "^3.3.9",
"vue-router": "^4.2.5",
"vue-tsc": "latest"
},
"peerDependencies": {
"typescript": "^5.3.2"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.461.0",
"@iarna/toml": "^2.2.5",
"@json2csv/plainjs": "^7.0.6",
"@prisma/client": "^5.6.0",
"blurhash": "^2.0.5",
"bullmq": "latest",
"c12": "^1.10.0",
"chalk": "^5.3.0",
"cli-parser": "workspace:*",
"cli-table": "^0.3.11",
"config-manager": "workspace:*",
"eventemitter3": "^5.0.1",
"extract-zip": "^2.0.1",
"html-to-text": "^9.0.5",
"ioredis": "^5.3.2",
"ip-matching": "^2.1.2",
"iso-639-1": "^3.1.0",
"isomorphic-dompurify": "latest",
"jsonld": "^8.3.1",
"linkify-html": "^4.1.3",
"linkify-string": "^4.1.3",
"linkifyjs": "^4.1.3",
"log-manager": "workspace:*",
"marked": "latest",
"media-manager": "workspace:*",
"megalodon": "^10.0.0",
"meilisearch": "latest",
"merge-deep-ts": "^1.2.6",
"next-route-matcher": "^1.0.1",
"oauth4webapi": "^2.4.0",
"prisma": "^5.6.0",
"prisma-json-types-generator": "^3.0.4",
"prisma-redis-middleware": "^4.8.0",
"request-parser": "workspace:*",
"semver": "^7.5.4",
"sharp": "^0.33.0-rc.2",
"strip-ansi": "^7.1.0"
} }
], }
"repository": {
"type": "git",
"url": "git+https://github.com/lysand-org/lysand.git"
},
"private": true,
"scripts": {
"dev": "bun run --watch index.ts",
"vite:dev": "bunx --bun vite pages",
"vite:build": "bunx --bun vite build pages",
"start": "NODE_ENV=production bun run dist/index.js --prod",
"migrate-dev": "bun prisma migrate dev",
"migrate": "bun prisma migrate deploy",
"lint": "bunx --bun eslint --config .eslintrc.cjs --ext .ts .",
"prod-build": "bunx --bun vite build pages && bun run build.ts",
"prisma": "DATABASE_URL=$(bun run prisma.ts) bunx prisma",
"generate": "bun prisma generate",
"benchmark:timeline": "bun run benchmarks/timelines.ts",
"cloc": "cloc . --exclude-dir node_modules,dist",
"cli": "bun run cli.ts"
},
"trustedDependencies": [
"@biomejs/biome",
"@prisma/client",
"@prisma/engines",
"esbuild",
"prisma",
"sharp"
],
"devDependencies": {
"@biomejs/biome": "1.6.4",
"@julr/unocss-preset-forms": "^0.1.0",
"@microsoft/eslint-formatter-sarif": "^3.0.0",
"@types/cli-table": "^0.3.4",
"@types/html-to-text": "^9.0.4",
"@types/ioredis": "^5.0.0",
"@types/jsonld": "^1.5.13",
"@typescript-eslint/eslint-plugin": "latest",
"@typescript-eslint/parser": "latest",
"@unocss/cli": "latest",
"@vitejs/plugin-vue": "latest",
"@vueuse/head": "^2.0.0",
"activitypub-types": "^1.0.3",
"bun-types": "latest",
"eslint": "^8.54.0",
"eslint-config-prettier": "^9.0.0",
"eslint-formatter-pretty": "^6.0.0",
"eslint-formatter-summary": "^1.1.0",
"eslint-plugin-prettier": "^5.0.1",
"prettier": "^3.1.0",
"typescript": "latest",
"unocss": "latest",
"untyped": "^1.4.2",
"vite": "latest",
"vite-ssr": "^0.17.1",
"vue": "^3.3.9",
"vue-router": "^4.2.5",
"vue-tsc": "latest"
},
"peerDependencies": {
"typescript": "^5.3.2"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.461.0",
"@iarna/toml": "^2.2.5",
"@json2csv/plainjs": "^7.0.6",
"@prisma/client": "^5.6.0",
"blurhash": "^2.0.5",
"bullmq": "latest",
"c12": "^1.10.0",
"chalk": "^5.3.0",
"cli-parser": "workspace:*",
"cli-table": "^0.3.11",
"config-manager": "workspace:*",
"eventemitter3": "^5.0.1",
"extract-zip": "^2.0.1",
"html-to-text": "^9.0.5",
"ioredis": "^5.3.2",
"ip-matching": "^2.1.2",
"iso-639-1": "^3.1.0",
"isomorphic-dompurify": "latest",
"jsonld": "^8.3.1",
"linkify-html": "^4.1.3",
"linkify-string": "^4.1.3",
"linkifyjs": "^4.1.3",
"log-manager": "workspace:*",
"marked": "latest",
"media-manager": "workspace:*",
"megalodon": "^10.0.0",
"meilisearch": "latest",
"merge-deep-ts": "^1.2.6",
"next-route-matcher": "^1.0.1",
"oauth4webapi": "^2.4.0",
"prisma": "^5.6.0",
"prisma-json-types-generator": "^3.0.4",
"prisma-redis-middleware": "^4.8.0",
"request-parser": "workspace:*",
"semver": "^7.5.4",
"sharp": "^0.33.0-rc.2",
"strip-ansi": "^7.1.0"
}
}

View file

@ -1,23 +1,23 @@
export interface CliParameter { export interface CliParameter {
name: string; name: string;
/* Like -v for --version */ /* Like -v for --version */
shortName?: string; shortName?: string;
/** /**
* If not positioned, the argument will need to be called with --name value instead of just value * If not positioned, the argument will need to be called with --name value instead of just value
* @default true * @default true
*/ */
positioned?: boolean; positioned?: boolean;
/* Whether the argument needs a value (requires positioned to be false) */ /* Whether the argument needs a value (requires positioned to be false) */
needsValue?: boolean; needsValue?: boolean;
optional?: true; optional?: true;
type: CliParameterType; type: CliParameterType;
description?: string; description?: string;
} }
export enum CliParameterType { export enum CliParameterType {
STRING = "string", STRING = "string",
NUMBER = "number", NUMBER = "number",
BOOLEAN = "boolean", BOOLEAN = "boolean",
ARRAY = "array", ARRAY = "array",
EMPTY = "empty", EMPTY = "empty",
} }

View file

@ -1,18 +1,18 @@
import { CliParameterType, type CliParameter } from "./cli-builder.type";
import chalk from "chalk"; import chalk from "chalk";
import strip from "strip-ansi"; import strip from "strip-ansi";
import { type CliParameter, CliParameterType } from "./cli-builder.type";
export function startsWithArray(fullArray: any[], startArray: any[]) { export function startsWithArray(fullArray: string[], startArray: string[]) {
if (startArray.length > fullArray.length) { if (startArray.length > fullArray.length) {
return false; return false;
} }
return fullArray return fullArray
.slice(0, startArray.length) .slice(0, startArray.length)
.every((value, index) => value === startArray[index]); .every((value, index) => value === startArray[index]);
} }
interface TreeType { interface TreeType {
[key: string]: CliCommand | TreeType; [key: string]: CliCommand | TreeType;
} }
/** /**
@ -20,178 +20,186 @@ interface TreeType {
* @param commands Array of commands to register * @param commands Array of commands to register
*/ */
export class CliBuilder { export class CliBuilder {
constructor(public commands: CliCommand[] = []) {} constructor(public commands: CliCommand[] = []) {}
/** /**
* Add command to the CLI * Add command to the CLI
* @throws Error if command already exists * @throws Error if command already exists
* @param command Command to add * @param command Command to add
*/ */
registerCommand(command: CliCommand) { registerCommand(command: CliCommand) {
if (this.checkIfCommandAlreadyExists(command)) { if (this.checkIfCommandAlreadyExists(command)) {
throw new Error( throw new Error(
`Command category '${command.categories.join(" ")}' already exists` `Command category '${command.categories.join(
); " ",
} )}' already exists`,
this.commands.push(command); );
} }
this.commands.push(command);
}
/** /**
* Add multiple commands to the CLI * Add multiple commands to the CLI
* @throws Error if command already exists * @throws Error if command already exists
* @param commands Commands to add * @param commands Commands to add
*/ */
registerCommands(commands: CliCommand[]) { registerCommands(commands: CliCommand[]) {
const existingCommand = commands.find(command => const existingCommand = commands.find((command) =>
this.checkIfCommandAlreadyExists(command) this.checkIfCommandAlreadyExists(command),
); );
if (existingCommand) { if (existingCommand) {
throw new Error( throw new Error(
`Command category '${existingCommand.categories.join(" ")}' already exists` `Command category '${existingCommand.categories.join(
); " ",
} )}' already exists`,
this.commands.push(...commands); );
} }
this.commands.push(...commands);
}
/** /**
* Remove command from the CLI * Remove command from the CLI
* @param command Command to remove * @param command Command to remove
*/ */
deregisterCommand(command: CliCommand) { deregisterCommand(command: CliCommand) {
this.commands = this.commands.filter( this.commands = this.commands.filter(
registeredCommand => registeredCommand !== command (registeredCommand) => registeredCommand !== command,
); );
} }
/** /**
* Remove multiple commands from the CLI * Remove multiple commands from the CLI
* @param commands Commands to remove * @param commands Commands to remove
*/ */
deregisterCommands(commands: CliCommand[]) { deregisterCommands(commands: CliCommand[]) {
this.commands = this.commands.filter( this.commands = this.commands.filter(
registeredCommand => !commands.includes(registeredCommand) (registeredCommand) => !commands.includes(registeredCommand),
); );
} }
checkIfCommandAlreadyExists(command: CliCommand) { checkIfCommandAlreadyExists(command: CliCommand) {
return this.commands.some( return this.commands.some(
registeredCommand => (registeredCommand) =>
registeredCommand.categories.length == registeredCommand.categories.length ===
command.categories.length && command.categories.length &&
registeredCommand.categories.every( registeredCommand.categories.every(
(category, index) => category === command.categories[index] (category, index) => category === command.categories[index],
) ),
); );
} }
/** /**
* Get relevant args for the command (without executable or runtime) * Get relevant args for the command (without executable or runtime)
* @param args Arguments passed to the CLI * @param args Arguments passed to the CLI
*/ */
private getRelevantArgs(args: string[]) { private getRelevantArgs(args: string[]) {
if (args[0].startsWith("./")) { if (args[0].startsWith("./")) {
// Formatted like ./cli.ts [command] // Formatted like ./cli.ts [command]
return args.slice(1); return args.slice(1);
} else if (args[0].includes("bun")) { }
// Formatted like bun cli.ts [command] if (args[0].includes("bun")) {
return args.slice(2); // Formatted like bun cli.ts [command]
} else { return args.slice(2);
return args; }
} return args;
} }
/** /**
* Turn raw system args into a CLI command and run it * Turn raw system args into a CLI command and run it
* @param args Args directly from process.argv * @param args Args directly from process.argv
*/ */
async processArgs(args: string[]) { async processArgs(args: string[]) {
const revelantArgs = this.getRelevantArgs(args); const revelantArgs = this.getRelevantArgs(args);
// Handle "-h", "--help" and "help" commands as special cases // Handle "-h", "--help" and "help" commands as special cases
if (revelantArgs.length === 1) { if (revelantArgs.length === 1) {
if (["-h", "--help", "help"].includes(revelantArgs[0])) { if (["-h", "--help", "help"].includes(revelantArgs[0])) {
this.displayHelp(); this.displayHelp();
return; return;
} }
} }
// Find revelant command // Find revelant command
// Search for a command with as many categories matching args as possible // Search for a command with as many categories matching args as possible
const matchingCommands = this.commands.filter(command => const matchingCommands = this.commands.filter((command) =>
startsWithArray(revelantArgs, command.categories) startsWithArray(revelantArgs, command.categories),
); );
if (matchingCommands.length === 0) { if (matchingCommands.length === 0) {
console.log( console.log(
`Invalid command "${revelantArgs.join(" ")}". Please use the ${chalk.bold("help")} command to see a list of commands` `Invalid command "${revelantArgs.join(
); " ",
return 0; )}". Please use the ${chalk.bold(
} "help",
)} command to see a list of commands`,
);
return 0;
}
// Get command with largest category size // Get command with largest category size
const command = matchingCommands.reduce((prev, current) => const command = matchingCommands.reduce((prev, current) =>
prev.categories.length > current.categories.length ? prev : current prev.categories.length > current.categories.length ? prev : current,
); );
const argsWithoutCategories = revelantArgs.slice( const argsWithoutCategories = revelantArgs.slice(
command.categories.length command.categories.length,
); );
return await command.run(argsWithoutCategories); return await command.run(argsWithoutCategories);
} }
/** /**
* Recursively urns the commands into a tree where subcategories mark each sub-branch * Recursively urns the commands into a tree where subcategories mark each sub-branch
* @example * @example
* ```txt * ```txt
* user verify * user verify
* user delete * user delete
* user new admin * user new admin
* user new * user new
* -> * ->
* user * user
* verify * verify
* delete * delete
* new * new
* admin * admin
* "" * ""
* ``` * ```
*/ */
getCommandTree(commands: CliCommand[]): TreeType { getCommandTree(commands: CliCommand[]): TreeType {
const tree: TreeType = {}; const tree: TreeType = {};
for (const command of commands) { for (const command of commands) {
let currentLevel = tree; // Start at the root let currentLevel = tree; // Start at the root
// Split the command into parts and iterate over them // Split the command into parts and iterate over them
for (const part of command.categories) { for (const part of command.categories) {
// If this part doesn't exist in the current level of the tree, add it (__proto__ check to prevent prototype pollution) // If this part doesn't exist in the current level of the tree, add it (__proto__ check to prevent prototype pollution)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!currentLevel[part] && part !== "__proto__") { if (!currentLevel[part] && part !== "__proto__") {
// If this is the last part of the command, add the command itself // If this is the last part of the command, add the command itself
if ( if (
part === part ===
command.categories[command.categories.length - 1] command.categories[command.categories.length - 1]
) { ) {
currentLevel[part] = command; currentLevel[part] = command;
break; break;
} }
currentLevel[part] = {}; currentLevel[part] = {};
} }
// Move down to the next level of the tree // Move down to the next level of the tree
currentLevel = currentLevel[part] as TreeType; currentLevel = currentLevel[part] as TreeType;
} }
} }
return tree; return tree;
} }
/** /**
* Display help for every command in a tree manner * Display help for every command in a tree manner
*/ */
displayHelp() { displayHelp() {
/* /*
user user
set set
admin: List of admin commands admin: List of admin commands
@ -204,217 +212,242 @@ export class CliBuilder {
verify verify
... ...
*/ */
const tree = this.getCommandTree(this.commands); const tree = this.getCommandTree(this.commands);
let writeBuffer = ""; let writeBuffer = "";
const displayTree = (tree: TreeType, depth = 0) => { const displayTree = (tree: TreeType, depth = 0) => {
for (const [key, value] of Object.entries(tree)) { for (const [key, value] of Object.entries(tree)) {
if (value instanceof CliCommand) { if (value instanceof CliCommand) {
writeBuffer += `${" ".repeat(depth)}${chalk.blue(key)}|${chalk.underline(value.description)}\n`; writeBuffer += `${" ".repeat(depth)}${chalk.blue(
const positionedArgs = value.argTypes.filter( key,
arg => arg.positioned ?? true )}|${chalk.underline(value.description)}\n`;
); const positionedArgs = value.argTypes.filter(
const unpositionedArgs = value.argTypes.filter( (arg) => arg.positioned ?? true,
arg => !(arg.positioned ?? true) );
); const unpositionedArgs = value.argTypes.filter(
(arg) => !(arg.positioned ?? true),
);
for (const arg of positionedArgs) { for (const arg of positionedArgs) {
writeBuffer += `${" ".repeat(depth + 1)}${chalk.green( writeBuffer += `${" ".repeat(
arg.name depth + 1,
)}|${ )}${chalk.green(arg.name)}|${
arg.description ?? "(no description)" arg.description ?? "(no description)"
} ${arg.optional ? chalk.gray("(optional)") : ""}\n`; } ${arg.optional ? chalk.gray("(optional)") : ""}\n`;
} }
for (const arg of unpositionedArgs) { for (const arg of unpositionedArgs) {
writeBuffer += `${" ".repeat(depth + 1)}${chalk.yellow("--" + arg.name)}${arg.shortName ? ", " + chalk.yellow("-" + arg.shortName) : ""}|${ writeBuffer += `${" ".repeat(
arg.description ?? "(no description)" depth + 1,
} ${arg.optional ? chalk.gray("(optional)") : ""}\n`; )}${chalk.yellow(`--${arg.name}`)}${
} arg.shortName
? `, ${chalk.yellow(`-${arg.shortName}`)}`
: ""
}|${arg.description ?? "(no description)"} ${
arg.optional ? chalk.gray("(optional)") : ""
}\n`;
}
if (value.example) { if (value.example) {
writeBuffer += `${" ".repeat(depth + 1)}${chalk.bold("Example:")} ${chalk.bgGray( writeBuffer += `${" ".repeat(depth + 1)}${chalk.bold(
value.example "Example:",
)}\n`; )} ${chalk.bgGray(value.example)}\n`;
} }
} else { } else {
writeBuffer += `${" ".repeat(depth)}${chalk.blue(key)}\n`; writeBuffer += `${" ".repeat(depth)}${chalk.blue(
displayTree(value, depth + 1); key,
} )}\n`;
} displayTree(value, depth + 1);
}; }
}
};
displayTree(tree); displayTree(tree);
// Replace all "|" with enough dots so that the text on the left + the dots = the same length // Replace all "|" with enough dots so that the text on the left + the dots = the same length
const optimal_length = Number( const optimal_length = Number(
// @ts-expect-error Slightly hacky but works writeBuffer
writeBuffer.split("\n").reduce((prev, current) => { .split("\n")
// If previousValue is empty // @ts-expect-error I don't know how this works and I don't want to know
if (!prev) .reduce((prev, current) => {
return current.includes("|") // If previousValue is empty
? current.split("|")[0].length if (!prev)
: 0; return current.includes("|")
if (!current.includes("|")) return prev; ? current.split("|")[0].length
const [left] = current.split("|"); : 0;
// Strip ANSI color codes or they mess up the length if (!current.includes("|")) return prev;
return Math.max(Number(prev), strip(left).length); const [left] = current.split("|");
}) // Strip ANSI color codes or they mess up the length
); return Math.max(Number(prev), Bun.stringWidth(left));
}),
);
for (const line of writeBuffer.split("\n")) { for (const line of writeBuffer.split("\n")) {
const [left, right] = line.split("|"); const [left, right] = line.split("|");
if (!right) { if (!right) {
console.log(left); console.log(left);
continue; continue;
} }
// Strip ANSI color codes or they mess up the length // Strip ANSI color codes or they mess up the length
const dots = ".".repeat(optimal_length + 5 - strip(left).length); const dots = ".".repeat(optimal_length + 5 - Bun.stringWidth(left));
console.log(`${left}${dots}${right}`); console.log(`${left}${dots}${right}`);
} }
} }
} }
type ExecuteFunction<T> = ( type ExecuteFunction<T> = (
instance: CliCommand, instance: CliCommand,
args: Partial<T> args: Partial<T>,
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
) => Promise<number> | Promise<void> | number | void; ) => Promise<number> | Promise<void> | number | void;
/** /**
* A command that can be executed from the command line * A command that can be executed from the command line
* @param categories Example: `["user", "create"]` for the command `./cli user create --name John` * @param categories Example: `["user", "create"]` for the command `./cli user create --name John`
*/ */
export class CliCommand<T = any> {
constructor(
public categories: string[],
public argTypes: CliParameter[],
private execute: ExecuteFunction<T>,
public description?: string,
public example?: string
) {}
/** // biome-ignore lint/suspicious/noExplicitAny: <explanation>
* Display help message for the command export class CliCommand<T = any> {
* formatted with Chalk and with emojis constructor(
*/ public categories: string[],
displayHelp() { public argTypes: CliParameter[],
const positionedArgs = this.argTypes.filter( private execute: ExecuteFunction<T>,
arg => arg.positioned ?? true public description?: string,
); public example?: string,
const unpositionedArgs = this.argTypes.filter( ) {}
arg => !(arg.positioned ?? true)
); /**
const helpMessage = ` * Display help message for the command
* formatted with Chalk and with emojis
*/
displayHelp() {
const positionedArgs = this.argTypes.filter(
(arg) => arg.positioned ?? true,
);
const unpositionedArgs = this.argTypes.filter(
(arg) => !(arg.positioned ?? true),
);
const helpMessage = `
${chalk.green("📚 Command:")} ${chalk.yellow(this.categories.join(" "))} ${chalk.green("📚 Command:")} ${chalk.yellow(this.categories.join(" "))}
${this.description ? `${chalk.cyan(this.description)}\n` : ""} ${this.description ? `${chalk.cyan(this.description)}\n` : ""}
${chalk.magenta("🔧 Arguments:")} ${chalk.magenta("🔧 Arguments:")}
${positionedArgs ${positionedArgs
.map( .map(
arg => (arg) =>
`${chalk.bold(arg.name)}: ${chalk.blue(arg.description ?? "(no description)")} ${ `${chalk.bold(arg.name)}: ${chalk.blue(
arg.optional ? chalk.gray("(optional)") : "" arg.description ?? "(no description)",
}` )} ${arg.optional ? chalk.gray("(optional)") : ""}`,
) )
.join("\n")} .join("\n")}
${unpositionedArgs ${unpositionedArgs
.map( .map(
arg => (arg) =>
`--${chalk.bold(arg.name)}${arg.shortName ? `, -${arg.shortName}` : ""}: ${chalk.blue(arg.description ?? "(no description)")} ${ `--${chalk.bold(arg.name)}${
arg.optional ? chalk.gray("(optional)") : "" arg.shortName ? `, -${arg.shortName}` : ""
}` }: ${chalk.blue(arg.description ?? "(no description)")} ${
) arg.optional ? chalk.gray("(optional)") : ""
.join( }`,
"\n" )
)}${this.example ? `\n${chalk.magenta("🚀 Example:")}\n${chalk.bgGray(this.example)}` : ""} .join("\n")}${
this.example
? `\n${chalk.magenta("🚀 Example:")}\n${chalk.bgGray(this.example)}`
: ""
}
`; `;
console.log(helpMessage); console.log(helpMessage);
} }
/** /**
* Parses string array arguments into a full JavaScript object * Parses string array arguments into a full JavaScript object
* @param argsWithoutCategories * @param argsWithoutCategories
* @returns * @returns
*/ */
private parseArgs(argsWithoutCategories: string[]): Record<string, any> { private parseArgs(
const parsedArgs: Record<string, any> = {}; argsWithoutCategories: string[],
let currentParameter: CliParameter | null = null; ): Record<string, string | number | boolean | string[]> {
const parsedArgs: Record<string, string | number | boolean | string[]> =
{};
let currentParameter: CliParameter | null = null;
for (let i = 0; i < argsWithoutCategories.length; i++) { for (let i = 0; i < argsWithoutCategories.length; i++) {
const arg = argsWithoutCategories[i]; const arg = argsWithoutCategories[i];
if (arg.startsWith("--")) { if (arg.startsWith("--")) {
const argName = arg.substring(2); const argName = arg.substring(2);
currentParameter = currentParameter =
this.argTypes.find(argType => argType.name === argName) || this.argTypes.find((argType) => argType.name === argName) ||
null; null;
if (currentParameter && !currentParameter.needsValue) { if (currentParameter && !currentParameter.needsValue) {
parsedArgs[argName] = true; parsedArgs[argName] = true;
currentParameter = null; currentParameter = null;
} else if (currentParameter && currentParameter.needsValue) { } else if (currentParameter?.needsValue) {
parsedArgs[argName] = this.castArgValue( parsedArgs[argName] = this.castArgValue(
argsWithoutCategories[i + 1], argsWithoutCategories[i + 1],
currentParameter.type currentParameter.type,
); );
i++; i++;
currentParameter = null; currentParameter = null;
} }
} else if (arg.startsWith("-")) { } else if (arg.startsWith("-")) {
const shortName = arg.substring(1); const shortName = arg.substring(1);
const argType = this.argTypes.find( const argType = this.argTypes.find(
argType => argType.shortName === shortName (argType) => argType.shortName === shortName,
); );
if (argType && !argType.needsValue) { if (argType && !argType.needsValue) {
parsedArgs[argType.name] = true; parsedArgs[argType.name] = true;
} else if (argType && argType.needsValue) { } else if (argType?.needsValue) {
parsedArgs[argType.name] = this.castArgValue( parsedArgs[argType.name] = this.castArgValue(
argsWithoutCategories[i + 1], argsWithoutCategories[i + 1],
argType.type argType.type,
); );
i++; i++;
} }
} else if (currentParameter) { } else if (currentParameter) {
parsedArgs[currentParameter.name] = this.castArgValue( parsedArgs[currentParameter.name] = this.castArgValue(
arg, arg,
currentParameter.type currentParameter.type,
); );
currentParameter = null; currentParameter = null;
} else { } else {
const positionedArgType = this.argTypes.find( const positionedArgType = this.argTypes.find(
argType => argType.positioned && !parsedArgs[argType.name] (argType) =>
); argType.positioned && !parsedArgs[argType.name],
if (positionedArgType) { );
parsedArgs[positionedArgType.name] = this.castArgValue( if (positionedArgType) {
arg, parsedArgs[positionedArgType.name] = this.castArgValue(
positionedArgType.type arg,
); positionedArgType.type,
} );
} }
} }
}
return parsedArgs; return parsedArgs;
} }
private castArgValue(value: string, type: CliParameter["type"]): any { private castArgValue(
switch (type) { value: string,
case CliParameterType.STRING: type: CliParameter["type"],
return value; ): string | number | boolean | string[] {
case CliParameterType.NUMBER: switch (type) {
return Number(value); case CliParameterType.STRING:
case CliParameterType.BOOLEAN: return value;
return value === "true"; case CliParameterType.NUMBER:
case CliParameterType.ARRAY: return Number(value);
return value.split(","); case CliParameterType.BOOLEAN:
default: return value === "true";
return value; case CliParameterType.ARRAY:
} return value.split(",");
} default:
return value;
}
}
/** /**
* Runs the execute function with the parsed parameters as an argument * Runs the execute function with the parsed parameters as an argument
*/ */
async run(argsWithoutCategories: string[]) { async run(argsWithoutCategories: string[]) {
const args = this.parseArgs(argsWithoutCategories); const args = this.parseArgs(argsWithoutCategories);
return await this.execute(this, args as any); return await this.execute(this, args as T);
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "cli-parser", "name": "cli-parser",
"version": "0.0.0", "version": "0.0.0",
"main": "index.ts", "main": "index.ts",
"dependencies": { "chalk": "^5.3.0", "strip-ansi": "^7.1.0" } "dependencies": { "chalk": "^5.3.0", "strip-ansi": "^7.1.0" }
} }

View file

@ -1,485 +1,488 @@
// FILEPATH: /home/jessew/Dev/lysand/packages/cli-parser/index.test.ts import { beforeEach, describe, expect, it, jest, spyOn } from "bun:test";
import { CliCommand, CliBuilder, startsWithArray } from "..";
import { describe, beforeEach, it, expect, jest, spyOn } from "bun:test";
import stripAnsi from "strip-ansi"; import stripAnsi from "strip-ansi";
// FILEPATH: /home/jessew/Dev/lysand/packages/cli-parser/index.test.ts
import { CliBuilder, CliCommand, startsWithArray } from "..";
import { CliParameterType } from "../cli-builder.type"; import { CliParameterType } from "../cli-builder.type";
describe("startsWithArray", () => { describe("startsWithArray", () => {
it("should return true when fullArray starts with startArray", () => { it("should return true when fullArray starts with startArray", () => {
const fullArray = ["a", "b", "c", "d", "e"]; const fullArray = ["a", "b", "c", "d", "e"];
const startArray = ["a", "b", "c"]; const startArray = ["a", "b", "c"];
expect(startsWithArray(fullArray, startArray)).toBe(true); expect(startsWithArray(fullArray, startArray)).toBe(true);
}); });
it("should return false when fullArray does not start with startArray", () => { it("should return false when fullArray does not start with startArray", () => {
const fullArray = ["a", "b", "c", "d", "e"]; const fullArray = ["a", "b", "c", "d", "e"];
const startArray = ["b", "c", "d"]; const startArray = ["b", "c", "d"];
expect(startsWithArray(fullArray, startArray)).toBe(false); expect(startsWithArray(fullArray, startArray)).toBe(false);
}); });
it("should return true when startArray is empty", () => { it("should return true when startArray is empty", () => {
const fullArray = ["a", "b", "c", "d", "e"]; const fullArray = ["a", "b", "c", "d", "e"];
const startArray: any[] = []; const startArray: string[] = [];
expect(startsWithArray(fullArray, startArray)).toBe(true); expect(startsWithArray(fullArray, startArray)).toBe(true);
}); });
it("should return false when fullArray is shorter than startArray", () => { it("should return false when fullArray is shorter than startArray", () => {
const fullArray = ["a", "b", "c"]; const fullArray = ["a", "b", "c"];
const startArray = ["a", "b", "c", "d", "e"]; const startArray = ["a", "b", "c", "d", "e"];
expect(startsWithArray(fullArray, startArray)).toBe(false); expect(startsWithArray(fullArray, startArray)).toBe(false);
}); });
}); });
describe("CliCommand", () => { describe("CliCommand", () => {
let cliCommand: CliCommand; let cliCommand: CliCommand;
beforeEach(() => { beforeEach(() => {
cliCommand = new CliCommand( cliCommand = new CliCommand(
["category1", "category2"], ["category1", "category2"],
[ [
{ {
name: "arg1", name: "arg1",
type: CliParameterType.STRING, type: CliParameterType.STRING,
needsValue: true, needsValue: true,
}, },
{ {
name: "arg2", name: "arg2",
shortName: "a", shortName: "a",
type: CliParameterType.NUMBER, type: CliParameterType.NUMBER,
needsValue: true, needsValue: true,
}, },
{ {
name: "arg3", name: "arg3",
type: CliParameterType.BOOLEAN, type: CliParameterType.BOOLEAN,
needsValue: false, needsValue: false,
}, },
{ {
name: "arg4", name: "arg4",
type: CliParameterType.ARRAY, type: CliParameterType.ARRAY,
needsValue: true, needsValue: true,
}, },
], ],
() => { () => {
// Do nothing // Do nothing
} },
); );
}); });
it("should parse string arguments correctly", () => { it("should parse string arguments correctly", () => {
const args = cliCommand["parseArgs"]([ // @ts-expect-error Testing private method
"--arg1", const args = cliCommand.parseArgs([
"value1", "--arg1",
"--arg2", "value1",
"42", "--arg2",
"--arg3", "42",
"--arg4", "--arg3",
"value1,value2", "--arg4",
]); "value1,value2",
expect(args).toEqual({ ]);
arg1: "value1", expect(args).toEqual({
arg2: 42, arg1: "value1",
arg3: true, arg2: 42,
arg4: ["value1", "value2"], arg3: true,
}); arg4: ["value1", "value2"],
}); });
});
it("should parse short names for arguments too", () => { it("should parse short names for arguments too", () => {
const args = cliCommand["parseArgs"]([ // @ts-expect-error Testing private method
"--arg1", const args = cliCommand.parseArgs([
"value1", "--arg1",
"-a", "value1",
"42", "-a",
"--arg3", "42",
"--arg4", "--arg3",
"value1,value2", "--arg4",
]); "value1,value2",
expect(args).toEqual({ ]);
arg1: "value1", expect(args).toEqual({
arg2: 42, arg1: "value1",
arg3: true, arg2: 42,
arg4: ["value1", "value2"], arg3: true,
}); arg4: ["value1", "value2"],
}); });
});
it("should cast argument values correctly", () => { it("should cast argument values correctly", () => {
expect(cliCommand["castArgValue"]("42", CliParameterType.NUMBER)).toBe( // @ts-expect-error Testing private method
42 expect(cliCommand.castArgValue("42", CliParameterType.NUMBER)).toBe(42);
); // @ts-expect-error Testing private method
expect( expect(cliCommand.castArgValue("true", CliParameterType.BOOLEAN)).toBe(
cliCommand["castArgValue"]("true", CliParameterType.BOOLEAN) true,
).toBe(true); );
expect( expect(
cliCommand["castArgValue"]("value1,value2", CliParameterType.ARRAY) // @ts-expect-error Testing private method
).toEqual(["value1", "value2"]); cliCommand.castArgValue("value1,value2", CliParameterType.ARRAY),
}); ).toEqual(["value1", "value2"]);
});
it("should run the execute function with the parsed parameters", async () => { it("should run the execute function with the parsed parameters", async () => {
const mockExecute = jest.fn(); const mockExecute = jest.fn();
cliCommand = new CliCommand( cliCommand = new CliCommand(
["category1", "category2"], ["category1", "category2"],
[ [
{ {
name: "arg1", name: "arg1",
type: CliParameterType.STRING, type: CliParameterType.STRING,
needsValue: true, needsValue: true,
}, },
{ {
name: "arg2", name: "arg2",
type: CliParameterType.NUMBER, type: CliParameterType.NUMBER,
needsValue: true, needsValue: true,
}, },
{ {
name: "arg3", name: "arg3",
type: CliParameterType.BOOLEAN, type: CliParameterType.BOOLEAN,
needsValue: false, needsValue: false,
}, },
{ {
name: "arg4", name: "arg4",
type: CliParameterType.ARRAY, type: CliParameterType.ARRAY,
needsValue: true, needsValue: true,
}, },
], ],
mockExecute mockExecute,
); );
await cliCommand.run([ await cliCommand.run([
"--arg1", "--arg1",
"value1", "value1",
"--arg2", "--arg2",
"42", "42",
"--arg3", "--arg3",
"--arg4", "--arg4",
"value1,value2", "value1,value2",
]); ]);
expect(mockExecute).toHaveBeenCalledWith(cliCommand, { expect(mockExecute).toHaveBeenCalledWith(cliCommand, {
arg1: "value1", arg1: "value1",
arg2: 42, arg2: 42,
arg3: true, arg3: true,
arg4: ["value1", "value2"], arg4: ["value1", "value2"],
}); });
}); });
it("should work with a mix of positioned and non-positioned arguments", async () => { it("should work with a mix of positioned and non-positioned arguments", async () => {
const mockExecute = jest.fn(); const mockExecute = jest.fn();
cliCommand = new CliCommand( cliCommand = new CliCommand(
["category1", "category2"], ["category1", "category2"],
[ [
{ {
name: "arg1", name: "arg1",
type: CliParameterType.STRING, type: CliParameterType.STRING,
needsValue: true, needsValue: true,
}, },
{ {
name: "arg2", name: "arg2",
type: CliParameterType.NUMBER, type: CliParameterType.NUMBER,
needsValue: true, needsValue: true,
}, },
{ {
name: "arg3", name: "arg3",
type: CliParameterType.BOOLEAN, type: CliParameterType.BOOLEAN,
needsValue: false, needsValue: false,
}, },
{ {
name: "arg4", name: "arg4",
type: CliParameterType.ARRAY, type: CliParameterType.ARRAY,
needsValue: true, needsValue: true,
}, },
{ {
name: "arg5", name: "arg5",
type: CliParameterType.STRING, type: CliParameterType.STRING,
needsValue: true, needsValue: true,
positioned: true, positioned: true,
}, },
], ],
mockExecute mockExecute,
); );
await cliCommand.run([ await cliCommand.run([
"--arg1", "--arg1",
"value1", "value1",
"--arg2", "--arg2",
"42", "42",
"--arg3", "--arg3",
"--arg4", "--arg4",
"value1,value2", "value1,value2",
"value5", "value5",
]); ]);
expect(mockExecute).toHaveBeenCalledWith(cliCommand, { expect(mockExecute).toHaveBeenCalledWith(cliCommand, {
arg1: "value1", arg1: "value1",
arg2: 42, arg2: 42,
arg3: true, arg3: true,
arg4: ["value1", "value2"], arg4: ["value1", "value2"],
arg5: "value5", arg5: "value5",
}); });
}); });
it("should display help message correctly", () => { it("should display help message correctly", () => {
const consoleLogSpy = spyOn(console, "log").mockImplementation(() => { const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {
// Do nothing // Do nothing
}); });
cliCommand = new CliCommand( cliCommand = new CliCommand(
["category1", "category2"], ["category1", "category2"],
[ [
{ {
name: "arg1", name: "arg1",
type: CliParameterType.STRING, type: CliParameterType.STRING,
needsValue: true, needsValue: true,
description: "Argument 1", description: "Argument 1",
optional: true, optional: true,
}, },
{ {
name: "arg2", name: "arg2",
type: CliParameterType.NUMBER, type: CliParameterType.NUMBER,
needsValue: true, needsValue: true,
description: "Argument 2", description: "Argument 2",
}, },
{ {
name: "arg3", name: "arg3",
type: CliParameterType.BOOLEAN, type: CliParameterType.BOOLEAN,
needsValue: false, needsValue: false,
description: "Argument 3", description: "Argument 3",
optional: true, optional: true,
positioned: false, positioned: false,
}, },
{ {
name: "arg4", name: "arg4",
type: CliParameterType.ARRAY, type: CliParameterType.ARRAY,
needsValue: true, needsValue: true,
description: "Argument 4", description: "Argument 4",
positioned: false, positioned: false,
}, },
], ],
() => { () => {
// Do nothing // Do nothing
}, },
"This is a test command", "This is a test command",
"category1 category2 --arg1 value1 --arg2 42 arg3 --arg4 value1,value2" "category1 category2 --arg1 value1 --arg2 42 arg3 --arg4 value1,value2",
); );
cliCommand.displayHelp(); cliCommand.displayHelp();
const loggedString = consoleLogSpy.mock.calls.map(call => const loggedString = consoleLogSpy.mock.calls.map((call) =>
stripAnsi(call[0]) stripAnsi(call[0]),
)[0]; )[0];
consoleLogSpy.mockRestore(); consoleLogSpy.mockRestore();
expect(loggedString).toContain("📚 Command: category1 category2"); expect(loggedString).toContain("📚 Command: category1 category2");
expect(loggedString).toContain("🔧 Arguments:"); expect(loggedString).toContain("🔧 Arguments:");
expect(loggedString).toContain("arg1: Argument 1 (optional)"); expect(loggedString).toContain("arg1: Argument 1 (optional)");
expect(loggedString).toContain("arg2: Argument 2"); expect(loggedString).toContain("arg2: Argument 2");
expect(loggedString).toContain("--arg3: Argument 3 (optional)"); expect(loggedString).toContain("--arg3: Argument 3 (optional)");
expect(loggedString).toContain("--arg4: Argument 4"); expect(loggedString).toContain("--arg4: Argument 4");
expect(loggedString).toContain("🚀 Example:"); expect(loggedString).toContain("🚀 Example:");
expect(loggedString).toContain( expect(loggedString).toContain(
"category1 category2 --arg1 value1 --arg2 42 arg3 --arg4 value1,value2" "category1 category2 --arg1 value1 --arg2 42 arg3 --arg4 value1,value2",
); );
}); });
}); });
describe("CliBuilder", () => { describe("CliBuilder", () => {
let cliBuilder: CliBuilder; let cliBuilder: CliBuilder;
let mockCommand1: CliCommand; let mockCommand1: CliCommand;
let mockCommand2: CliCommand; let mockCommand2: CliCommand;
beforeEach(() => { beforeEach(() => {
mockCommand1 = new CliCommand(["category1"], [], jest.fn()); mockCommand1 = new CliCommand(["category1"], [], jest.fn());
mockCommand2 = new CliCommand(["category2"], [], jest.fn()); mockCommand2 = new CliCommand(["category2"], [], jest.fn());
cliBuilder = new CliBuilder([mockCommand1]); cliBuilder = new CliBuilder([mockCommand1]);
}); });
it("should register a command correctly", () => { it("should register a command correctly", () => {
cliBuilder.registerCommand(mockCommand2); cliBuilder.registerCommand(mockCommand2);
expect(cliBuilder.commands).toContain(mockCommand2); expect(cliBuilder.commands).toContain(mockCommand2);
}); });
it("should register multiple commands correctly", () => { it("should register multiple commands correctly", () => {
const mockCommand3 = new CliCommand(["category3"], [], jest.fn()); const mockCommand3 = new CliCommand(["category3"], [], jest.fn());
cliBuilder.registerCommands([mockCommand2, mockCommand3]); cliBuilder.registerCommands([mockCommand2, mockCommand3]);
expect(cliBuilder.commands).toContain(mockCommand2); expect(cliBuilder.commands).toContain(mockCommand2);
expect(cliBuilder.commands).toContain(mockCommand3); expect(cliBuilder.commands).toContain(mockCommand3);
}); });
it("should error when adding duplicates", () => { it("should error when adding duplicates", () => {
expect(() => { expect(() => {
cliBuilder.registerCommand(mockCommand1); cliBuilder.registerCommand(mockCommand1);
}).toThrow(); }).toThrow();
expect(() => { expect(() => {
cliBuilder.registerCommands([mockCommand1]); cliBuilder.registerCommands([mockCommand1]);
}).toThrow(); }).toThrow();
}); });
it("should deregister a command correctly", () => { it("should deregister a command correctly", () => {
cliBuilder.deregisterCommand(mockCommand1); cliBuilder.deregisterCommand(mockCommand1);
expect(cliBuilder.commands).not.toContain(mockCommand1); expect(cliBuilder.commands).not.toContain(mockCommand1);
}); });
it("should deregister multiple commands correctly", () => { it("should deregister multiple commands correctly", () => {
cliBuilder.registerCommand(mockCommand2); cliBuilder.registerCommand(mockCommand2);
cliBuilder.deregisterCommands([mockCommand1, mockCommand2]); cliBuilder.deregisterCommands([mockCommand1, mockCommand2]);
expect(cliBuilder.commands).not.toContain(mockCommand1); expect(cliBuilder.commands).not.toContain(mockCommand1);
expect(cliBuilder.commands).not.toContain(mockCommand2); expect(cliBuilder.commands).not.toContain(mockCommand2);
}); });
it("should process args correctly", async () => { it("should process args correctly", async () => {
const mockExecute = jest.fn(); const mockExecute = jest.fn();
const mockCommand = new CliCommand( const mockCommand = new CliCommand(
["category1", "sub1"], ["category1", "sub1"],
[ [
{ {
name: "arg1", name: "arg1",
type: CliParameterType.STRING, type: CliParameterType.STRING,
needsValue: true, needsValue: true,
positioned: false, positioned: false,
}, },
], ],
mockExecute mockExecute,
); );
cliBuilder.registerCommand(mockCommand); cliBuilder.registerCommand(mockCommand);
await cliBuilder.processArgs([ await cliBuilder.processArgs([
"./cli.ts", "./cli.ts",
"category1", "category1",
"sub1", "sub1",
"--arg1", "--arg1",
"value1", "value1",
]); ]);
expect(mockExecute).toHaveBeenCalledWith(expect.anything(), { expect(mockExecute).toHaveBeenCalledWith(expect.anything(), {
arg1: "value1", arg1: "value1",
}); });
}); });
describe("should build command tree", () => { describe("should build command tree", () => {
let cliBuilder: CliBuilder; let cliBuilder: CliBuilder;
let mockCommand1: CliCommand; let mockCommand1: CliCommand;
let mockCommand2: CliCommand; let mockCommand2: CliCommand;
let mockCommand3: CliCommand; let mockCommand3: CliCommand;
let mockCommand4: CliCommand; let mockCommand4: CliCommand;
let mockCommand5: CliCommand; let mockCommand5: CliCommand;
beforeEach(() => { beforeEach(() => {
mockCommand1 = new CliCommand(["user", "verify"], [], jest.fn()); mockCommand1 = new CliCommand(["user", "verify"], [], jest.fn());
mockCommand2 = new CliCommand(["user", "delete"], [], jest.fn()); mockCommand2 = new CliCommand(["user", "delete"], [], jest.fn());
mockCommand3 = new CliCommand( mockCommand3 = new CliCommand(
["user", "new", "admin"], ["user", "new", "admin"],
[], [],
jest.fn() jest.fn(),
); );
mockCommand4 = new CliCommand(["user", "new"], [], jest.fn()); mockCommand4 = new CliCommand(["user", "new"], [], jest.fn());
mockCommand5 = new CliCommand(["admin", "delete"], [], jest.fn()); mockCommand5 = new CliCommand(["admin", "delete"], [], jest.fn());
cliBuilder = new CliBuilder([ cliBuilder = new CliBuilder([
mockCommand1, mockCommand1,
mockCommand2, mockCommand2,
mockCommand3, mockCommand3,
mockCommand4, mockCommand4,
mockCommand5, mockCommand5,
]); ]);
}); });
it("should build the command tree correctly", () => { it("should build the command tree correctly", () => {
const tree = cliBuilder.getCommandTree(cliBuilder.commands); const tree = cliBuilder.getCommandTree(cliBuilder.commands);
expect(tree).toEqual({ expect(tree).toEqual({
user: { user: {
verify: mockCommand1, verify: mockCommand1,
delete: mockCommand2, delete: mockCommand2,
new: { new: {
admin: mockCommand3, admin: mockCommand3,
}, },
}, },
admin: { admin: {
delete: mockCommand5, delete: mockCommand5,
}, },
}); });
}); });
it("should build the command tree correctly when there are no commands", () => { it("should build the command tree correctly when there are no commands", () => {
cliBuilder = new CliBuilder([]); cliBuilder = new CliBuilder([]);
const tree = cliBuilder.getCommandTree(cliBuilder.commands); const tree = cliBuilder.getCommandTree(cliBuilder.commands);
expect(tree).toEqual({}); expect(tree).toEqual({});
}); });
it("should build the command tree correctly when there is only one command", () => { it("should build the command tree correctly when there is only one command", () => {
cliBuilder = new CliBuilder([mockCommand1]); cliBuilder = new CliBuilder([mockCommand1]);
const tree = cliBuilder.getCommandTree(cliBuilder.commands); const tree = cliBuilder.getCommandTree(cliBuilder.commands);
expect(tree).toEqual({ expect(tree).toEqual({
user: { user: {
verify: mockCommand1, verify: mockCommand1,
}, },
}); });
}); });
}); });
it("should show help menu", () => { it("should show help menu", () => {
const consoleLogSpy = spyOn(console, "log").mockImplementation(() => { const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {
// Do nothing // Do nothing
}); });
const cliBuilder = new CliBuilder(); const cliBuilder = new CliBuilder();
const cliCommand = new CliCommand( const cliCommand = new CliCommand(
["category1", "category2"], ["category1", "category2"],
[ [
{ {
name: "name", name: "name",
type: CliParameterType.STRING, type: CliParameterType.STRING,
needsValue: true, needsValue: true,
description: "Name of new item", description: "Name of new item",
}, },
{ {
name: "delete-previous", name: "delete-previous",
type: CliParameterType.NUMBER, type: CliParameterType.NUMBER,
needsValue: false, needsValue: false,
positioned: false, positioned: false,
optional: true, optional: true,
description: "Also delete the previous item", description: "Also delete the previous item",
}, },
{ {
name: "arg3", name: "arg3",
type: CliParameterType.BOOLEAN, type: CliParameterType.BOOLEAN,
needsValue: false, needsValue: false,
}, },
{ {
name: "arg4", name: "arg4",
type: CliParameterType.ARRAY, type: CliParameterType.ARRAY,
needsValue: true, needsValue: true,
}, },
], ],
() => { () => {
// Do nothing // Do nothing
}, },
"I love sussy sauces", "I love sussy sauces",
"emoji add --url https://site.com/image.png" "emoji add --url https://site.com/image.png",
); );
cliBuilder.registerCommand(cliCommand); cliBuilder.registerCommand(cliCommand);
cliBuilder.displayHelp(); cliBuilder.displayHelp();
const loggedString = consoleLogSpy.mock.calls const loggedString = consoleLogSpy.mock.calls
.map(call => stripAnsi(call[0])) .map((call) => stripAnsi(call[0]))
.join("\n"); .join("\n");
consoleLogSpy.mockRestore(); consoleLogSpy.mockRestore();
expect(loggedString).toContain("category1"); expect(loggedString).toContain("category1");
expect(loggedString).toContain( expect(loggedString).toContain(
" category2.................I love sussy sauces" " category2.................I love sussy sauces",
); );
expect(loggedString).toContain( expect(loggedString).toContain(
" name..................Name of new item" " name..................Name of new item",
); );
expect(loggedString).toContain( expect(loggedString).toContain(
" arg3..................(no description)" " arg3..................(no description)",
); );
expect(loggedString).toContain( expect(loggedString).toContain(
" arg4..................(no description)" " arg4..................(no description)",
); );
expect(loggedString).toContain( expect(loggedString).toContain(
" --delete-previous.....Also delete the previous item (optional)" " --delete-previous.....Also delete the previous item (optional)",
); );
expect(loggedString).toContain( expect(loggedString).toContain(
" Example: emoji add --url https://site.com/image.png" " Example: emoji add --url https://site.com/image.png",
); );
}); });
}); });

File diff suppressed because it is too large Load diff

View file

@ -6,18 +6,18 @@
*/ */
import { watchConfig } from "c12"; import { watchConfig } from "c12";
import { defaultConfig, type Config } from "./config.type"; import { type Config, defaultConfig } from "./config.type";
const { config } = await watchConfig<Config>({ const { config } = await watchConfig<Config>({
configFile: "./config/config.toml", configFile: "./config/config.toml",
defaultConfig: defaultConfig, defaultConfig: defaultConfig,
overrides: overrides:
( (
await watchConfig<Config>({ await watchConfig<Config>({
configFile: "./config/config.internal.toml", configFile: "./config/config.internal.toml",
defaultConfig: {} as Config, defaultConfig: {} as Config,
}) })
).config ?? undefined, ).config ?? undefined,
}); });
const exportedConfig = config ?? defaultConfig; const exportedConfig = config ?? defaultConfig;

View file

@ -1,6 +1,6 @@
{ {
"name": "config-manager", "name": "config-manager",
"version": "0.0.0", "version": "0.0.0",
"main": "index.ts", "main": "index.ts",
"dependencies": { "@iarna/toml": "^2.2.5", "merge-deep-ts": "^1.2.6" } "dependencies": { "@iarna/toml": "^2.2.5", "merge-deep-ts": "^1.2.6" }
} }

View file

@ -1,12 +1,12 @@
import { appendFile } from "node:fs/promises";
import type { BunFile } from "bun"; import type { BunFile } from "bun";
import { appendFile } from "fs/promises";
export enum LogLevel { export enum LogLevel {
DEBUG = "debug", DEBUG = "debug",
INFO = "info", INFO = "info",
WARNING = "warning", WARNING = "warning",
ERROR = "error", ERROR = "error",
CRITICAL = "critical", CRITICAL = "critical",
} }
/** /**
@ -14,161 +14,165 @@ export enum LogLevel {
* @param output BunFile of output (can be a normal file or something like Bun.stdout) * @param output BunFile of output (can be a normal file or something like Bun.stdout)
*/ */
export class LogManager { export class LogManager {
constructor(private output: BunFile) { constructor(private output: BunFile) {
void this.write( void this.write(
`--- INIT LogManager at ${new Date().toISOString()} ---` `--- INIT LogManager at ${new Date().toISOString()} ---`,
); );
} }
/** /**
* Logs a message to the output * Logs a message to the output
* @param level Importance of the log * @param level Importance of the log
* @param entity Emitter of the log * @param entity Emitter of the log
* @param message Message to log * @param message Message to log
* @param showTimestamp Whether to show the timestamp in the log * @param showTimestamp Whether to show the timestamp in the log
*/ */
async log( async log(
level: LogLevel, level: LogLevel,
entity: string, entity: string,
message: string, message: string,
showTimestamp = true showTimestamp = true,
) { ) {
await this.write( await this.write(
`${showTimestamp ? new Date().toISOString() + " " : ""}[${level.toUpperCase()}] ${entity}: ${message}` `${
); showTimestamp ? `${new Date().toISOString()} ` : ""
} }[${level.toUpperCase()}] ${entity}: ${message}`,
);
}
private async write(text: string) { private async write(text: string) {
if (this.output == Bun.stdout) { if (this.output === Bun.stdout) {
await Bun.write(Bun.stdout, text + "\n"); await Bun.write(Bun.stdout, `${text}\n`);
} else { } else {
if (!(await this.output.exists())) { if (!(await this.output.exists())) {
// Create file if it doesn't exist // Create file if it doesn't exist
await Bun.write(this.output, "", { await Bun.write(this.output, "", {
createPath: true, createPath: true,
}); });
} }
await appendFile(this.output.name ?? "", text + "\n"); await appendFile(this.output.name ?? "", `${text}\n`);
} }
} }
/** /**
* Logs an error to the output, wrapper for log * Logs an error to the output, wrapper for log
* @param level Importance of the log * @param level Importance of the log
* @param entity Emitter of the log * @param entity Emitter of the log
* @param error Error to log * @param error Error to log
*/ */
async logError(level: LogLevel, entity: string, error: Error) { async logError(level: LogLevel, entity: string, error: Error) {
await this.log(level, entity, error.message); await this.log(level, entity, error.message);
} }
/** /**
* Logs a request to the output * Logs a request to the output
* @param req Request to log * @param req Request to log
* @param ip IP of the request * @param ip IP of the request
* @param logAllDetails Whether to log all details of the request * @param logAllDetails Whether to log all details of the request
*/ */
async logRequest(req: Request, ip?: string, logAllDetails = false) { async logRequest(req: Request, ip?: string, logAllDetails = false) {
let string = ip ? `${ip}: ` : ""; let string = ip ? `${ip}: ` : "";
string += `${req.method} ${req.url}`; string += `${req.method} ${req.url}`;
if (logAllDetails) { if (logAllDetails) {
string += `\n`; string += "\n";
string += ` [Headers]\n`; string += " [Headers]\n";
// Pretty print headers // Pretty print headers
for (const [key, value] of req.headers.entries()) { for (const [key, value] of req.headers.entries()) {
string += ` ${key}: ${value}\n`; string += ` ${key}: ${value}\n`;
} }
// Pretty print body // Pretty print body
string += ` [Body]\n`; string += " [Body]\n";
const content_type = req.headers.get("Content-Type"); const content_type = req.headers.get("Content-Type");
if (content_type && content_type.includes("application/json")) { if (content_type?.includes("application/json")) {
const json = await req.json(); const json = await req.json();
const stringified = JSON.stringify(json, null, 4) const stringified = JSON.stringify(json, null, 4)
.split("\n") .split("\n")
.map(line => ` ${line}`) .map((line) => ` ${line}`)
.join("\n"); .join("\n");
string += `${stringified}\n`; string += `${stringified}\n`;
} else if ( } else if (
content_type && content_type &&
(content_type.includes("application/x-www-form-urlencoded") || (content_type.includes("application/x-www-form-urlencoded") ||
content_type.includes("multipart/form-data")) content_type.includes("multipart/form-data"))
) { ) {
const formData = await req.formData(); const formData = await req.formData();
for (const [key, value] of formData.entries()) { for (const [key, value] of formData.entries()) {
if (value.toString().length < 300) { if (value.toString().length < 300) {
string += ` ${key}: ${value.toString()}\n`; string += ` ${key}: ${value.toString()}\n`;
} else { } else {
string += ` ${key}: <${value.toString().length} bytes>\n`; string += ` ${key}: <${
} value.toString().length
} } bytes>\n`;
} else { }
const text = await req.text(); }
string += ` ${text}\n`; } else {
} const text = await req.text();
} string += ` ${text}\n`;
await this.log(LogLevel.INFO, "Request", string); }
} }
await this.log(LogLevel.INFO, "Request", string);
}
} }
/** /**
* Outputs to multiple LogManager instances at once * Outputs to multiple LogManager instances at once
*/ */
export class MultiLogManager { export class MultiLogManager {
constructor(private logManagers: LogManager[]) {} constructor(private logManagers: LogManager[]) {}
/** /**
* Logs a message to all logManagers * Logs a message to all logManagers
* @param level Importance of the log * @param level Importance of the log
* @param entity Emitter of the log * @param entity Emitter of the log
* @param message Message to log * @param message Message to log
* @param showTimestamp Whether to show the timestamp in the log * @param showTimestamp Whether to show the timestamp in the log
*/ */
async log( async log(
level: LogLevel, level: LogLevel,
entity: string, entity: string,
message: string, message: string,
showTimestamp = true showTimestamp = true,
) { ) {
for (const logManager of this.logManagers) { for (const logManager of this.logManagers) {
await logManager.log(level, entity, message, showTimestamp); await logManager.log(level, entity, message, showTimestamp);
} }
} }
/** /**
* Logs an error to all logManagers * Logs an error to all logManagers
* @param level Importance of the log * @param level Importance of the log
* @param entity Emitter of the log * @param entity Emitter of the log
* @param error Error to log * @param error Error to log
*/ */
async logError(level: LogLevel, entity: string, error: Error) { async logError(level: LogLevel, entity: string, error: Error) {
for (const logManager of this.logManagers) { for (const logManager of this.logManagers) {
await logManager.logError(level, entity, error); await logManager.logError(level, entity, error);
} }
} }
/** /**
* Logs a request to all logManagers * Logs a request to all logManagers
* @param req Request to log * @param req Request to log
* @param ip IP of the request * @param ip IP of the request
* @param logAllDetails Whether to log all details of the request * @param logAllDetails Whether to log all details of the request
*/ */
async logRequest(req: Request, ip?: string, logAllDetails = false) { async logRequest(req: Request, ip?: string, logAllDetails = false) {
for (const logManager of this.logManagers) { for (const logManager of this.logManagers) {
await logManager.logRequest(req, ip, logAllDetails); await logManager.logRequest(req, ip, logAllDetails);
} }
} }
/** /**
* Create a MultiLogManager from multiple LogManager instances * Create a MultiLogManager from multiple LogManager instances
* @param logManagers LogManager instances to use * @param logManagers LogManager instances to use
* @returns * @returns
*/ */
static fromLogManagers(...logManagers: LogManager[]) { static fromLogManagers(...logManagers: LogManager[]) {
return new MultiLogManager(logManagers); return new MultiLogManager(logManagers);
} }
} }

View file

@ -2,5 +2,5 @@
"name": "log-manager", "name": "log-manager",
"version": "0.0.0", "version": "0.0.0",
"main": "index.ts", "main": "index.ts",
"dependencies": { } "dependencies": {}
} }

View file

@ -1,117 +1,117 @@
// FILEPATH: /home/jessew/Dev/lysand/packages/log-manager/log-manager.test.ts
import { LogManager, LogLevel, MultiLogManager } from "../index";
import type fs from "fs/promises";
import { import {
describe, type Mock,
it, beforeEach,
beforeEach, describe,
expect, expect,
jest, it,
mock, jest,
type Mock, mock,
test, test,
} from "bun:test"; } from "bun:test";
import type fs from "node:fs/promises";
import type { BunFile } from "bun"; import type { BunFile } from "bun";
// FILEPATH: /home/jessew/Dev/lysand/packages/log-manager/log-manager.test.ts
import { LogLevel, LogManager, MultiLogManager } from "../index";
describe("LogManager", () => { describe("LogManager", () => {
let logManager: LogManager; let logManager: LogManager;
let mockOutput: BunFile; let mockOutput: BunFile;
let mockAppend: Mock<typeof fs.appendFile>; let mockAppend: Mock<typeof fs.appendFile>;
beforeEach(async () => { beforeEach(async () => {
mockOutput = Bun.file("test.log"); mockOutput = Bun.file("test.log");
mockAppend = jest.fn(); mockAppend = jest.fn();
await mock.module("fs/promises", () => ({ await mock.module("fs/promises", () => ({
appendFile: mockAppend, appendFile: mockAppend,
})); }));
logManager = new LogManager(mockOutput); logManager = new LogManager(mockOutput);
}); });
it("should initialize and write init log", () => { it("should initialize and write init log", () => {
expect(mockAppend).toHaveBeenCalledWith( expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name, mockOutput.name,
expect.stringContaining("--- INIT LogManager at") expect.stringContaining("--- INIT LogManager at"),
); );
}); });
it("should log message with timestamp", async () => { it("should log message with timestamp", async () => {
await logManager.log(LogLevel.INFO, "TestEntity", "Test message"); await logManager.log(LogLevel.INFO, "TestEntity", "Test message");
expect(mockAppend).toHaveBeenCalledWith( expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name, mockOutput.name,
expect.stringContaining("[INFO] TestEntity: Test message") expect.stringContaining("[INFO] TestEntity: Test message"),
); );
}); });
it("should log message without timestamp", async () => { it("should log message without timestamp", async () => {
await logManager.log( await logManager.log(
LogLevel.INFO, LogLevel.INFO,
"TestEntity", "TestEntity",
"Test message", "Test message",
false false,
); );
expect(mockAppend).toHaveBeenCalledWith( expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name, mockOutput.name,
"[INFO] TestEntity: Test message\n" "[INFO] TestEntity: Test message\n",
); );
}); });
test.skip("should write to stdout", async () => { test.skip("should write to stdout", async () => {
logManager = new LogManager(Bun.stdout); logManager = new LogManager(Bun.stdout);
await logManager.log(LogLevel.INFO, "TestEntity", "Test message"); await logManager.log(LogLevel.INFO, "TestEntity", "Test message");
const writeMock = jest.fn(); const writeMock = jest.fn();
await mock.module("Bun", () => ({ await mock.module("Bun", () => ({
stdout: Bun.stdout, stdout: Bun.stdout,
write: writeMock, write: writeMock,
})); }));
expect(writeMock).toHaveBeenCalledWith( expect(writeMock).toHaveBeenCalledWith(
Bun.stdout, Bun.stdout,
expect.stringContaining("[INFO] TestEntity: Test message") expect.stringContaining("[INFO] TestEntity: Test message"),
); );
}); });
it("should throw error if output file does not exist", () => { it("should throw error if output file does not exist", () => {
mockAppend.mockImplementationOnce(() => { mockAppend.mockImplementationOnce(() => {
return Promise.reject( return Promise.reject(
new Error("Output file doesnt exist (and isnt stdout)") new Error("Output file doesnt exist (and isnt stdout)"),
); );
}); });
expect( expect(
logManager.log(LogLevel.INFO, "TestEntity", "Test message") logManager.log(LogLevel.INFO, "TestEntity", "Test message"),
).rejects.toThrow(Error); ).rejects.toThrow(Error);
}); });
it("should log error message", async () => { it("should log error message", async () => {
const error = new Error("Test error"); const error = new Error("Test error");
await logManager.logError(LogLevel.ERROR, "TestEntity", error); await logManager.logError(LogLevel.ERROR, "TestEntity", error);
expect(mockAppend).toHaveBeenCalledWith( expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name, mockOutput.name,
expect.stringContaining("[ERROR] TestEntity: Test error") expect.stringContaining("[ERROR] TestEntity: Test error"),
); );
}); });
it("should log basic request details", async () => { it("should log basic request details", async () => {
const req = new Request("http://localhost/test", { method: "GET" }); const req = new Request("http://localhost/test", { method: "GET" });
await logManager.logRequest(req, "127.0.0.1"); await logManager.logRequest(req, "127.0.0.1");
expect(mockAppend).toHaveBeenCalledWith( expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name, mockOutput.name,
expect.stringContaining("127.0.0.1: GET http://localhost/test") expect.stringContaining("127.0.0.1: GET http://localhost/test"),
); );
}); });
describe("Request logger", () => { describe("Request logger", () => {
it("should log all request details for JSON content type", async () => { it("should log all request details for JSON content type", async () => {
const req = new Request("http://localhost/test", { const req = new Request("http://localhost/test", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ test: "value" }), body: JSON.stringify({ test: "value" }),
}); });
await logManager.logRequest(req, "127.0.0.1", true); await logManager.logRequest(req, "127.0.0.1", true);
const expectedLog = `127.0.0.1: POST http://localhost/test const expectedLog = `127.0.0.1: POST http://localhost/test
[Headers] [Headers]
content-type: application/json content-type: application/json
[Body] [Body]
@ -120,112 +120,112 @@ describe("LogManager", () => {
} }
`; `;
expect(mockAppend).toHaveBeenCalledWith( expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name, mockOutput.name,
expect.stringContaining(expectedLog) expect.stringContaining(expectedLog),
); );
}); });
it("should log all request details for text content type", async () => { it("should log all request details for text content type", async () => {
const req = new Request("http://localhost/test", { const req = new Request("http://localhost/test", {
method: "POST", method: "POST",
headers: { "Content-Type": "text/plain" }, headers: { "Content-Type": "text/plain" },
body: "Test body", body: "Test body",
}); });
await logManager.logRequest(req, "127.0.0.1", true); await logManager.logRequest(req, "127.0.0.1", true);
const expectedLog = `127.0.0.1: POST http://localhost/test const expectedLog = `127.0.0.1: POST http://localhost/test
[Headers] [Headers]
content-type: text/plain content-type: text/plain
[Body] [Body]
Test body Test body
`; `;
expect(mockAppend).toHaveBeenCalledWith( expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name, mockOutput.name,
expect.stringContaining(expectedLog) expect.stringContaining(expectedLog),
); );
}); });
it("should log all request details for FormData content-type", async () => { it("should log all request details for FormData content-type", async () => {
const formData = new FormData(); const formData = new FormData();
formData.append("test", "value"); formData.append("test", "value");
const req = new Request("http://localhost/test", { const req = new Request("http://localhost/test", {
method: "POST", method: "POST",
body: formData, body: formData,
}); });
await logManager.logRequest(req, "127.0.0.1", true); await logManager.logRequest(req, "127.0.0.1", true);
const expectedLog = `127.0.0.1: POST http://localhost/test const expectedLog = `127.0.0.1: POST http://localhost/test
[Headers] [Headers]
content-type: multipart/form-data; boundary=${ content-type: multipart/form-data; boundary=${
req.headers.get("Content-Type")?.split("boundary=")[1] ?? "" req.headers.get("Content-Type")?.split("boundary=")[1] ?? ""
} }
[Body] [Body]
test: value test: value
`; `;
expect(mockAppend).toHaveBeenCalledWith( expect(mockAppend).toHaveBeenCalledWith(
mockOutput.name, mockOutput.name,
expect.stringContaining( expect.stringContaining(
expectedLog.replace("----", expect.any(String)) expectedLog.replace("----", expect.any(String)),
) ),
); );
}); });
}); });
}); });
describe("MultiLogManager", () => { describe("MultiLogManager", () => {
let multiLogManager: MultiLogManager; let multiLogManager: MultiLogManager;
let mockLogManagers: LogManager[]; let mockLogManagers: LogManager[];
let mockLog: jest.Mock; let mockLog: jest.Mock;
let mockLogError: jest.Mock; let mockLogError: jest.Mock;
let mockLogRequest: jest.Mock; let mockLogRequest: jest.Mock;
beforeEach(() => { beforeEach(() => {
mockLog = jest.fn(); mockLog = jest.fn();
mockLogError = jest.fn(); mockLogError = jest.fn();
mockLogRequest = jest.fn(); mockLogRequest = jest.fn();
mockLogManagers = [ mockLogManagers = [
{ {
log: mockLog, log: mockLog,
logError: mockLogError, logError: mockLogError,
logRequest: mockLogRequest, logRequest: mockLogRequest,
}, },
{ {
log: mockLog, log: mockLog,
logError: mockLogError, logError: mockLogError,
logRequest: mockLogRequest, logRequest: mockLogRequest,
}, },
] as unknown as LogManager[]; ] as unknown as LogManager[];
multiLogManager = MultiLogManager.fromLogManagers(...mockLogManagers); multiLogManager = MultiLogManager.fromLogManagers(...mockLogManagers);
}); });
it("should log message to all logManagers", async () => { it("should log message to all logManagers", async () => {
await multiLogManager.log(LogLevel.INFO, "TestEntity", "Test message"); await multiLogManager.log(LogLevel.INFO, "TestEntity", "Test message");
expect(mockLog).toHaveBeenCalledTimes(2); expect(mockLog).toHaveBeenCalledTimes(2);
expect(mockLog).toHaveBeenCalledWith( expect(mockLog).toHaveBeenCalledWith(
LogLevel.INFO, LogLevel.INFO,
"TestEntity", "TestEntity",
"Test message", "Test message",
true true,
); );
}); });
it("should log error to all logManagers", async () => { it("should log error to all logManagers", async () => {
const error = new Error("Test error"); const error = new Error("Test error");
await multiLogManager.logError(LogLevel.ERROR, "TestEntity", error); await multiLogManager.logError(LogLevel.ERROR, "TestEntity", error);
expect(mockLogError).toHaveBeenCalledTimes(2); expect(mockLogError).toHaveBeenCalledTimes(2);
expect(mockLogError).toHaveBeenCalledWith( expect(mockLogError).toHaveBeenCalledWith(
LogLevel.ERROR, LogLevel.ERROR,
"TestEntity", "TestEntity",
error error,
); );
}); });
it("should log request to all logManagers", async () => { it("should log request to all logManagers", async () => {
const req = new Request("http://localhost/test", { method: "GET" }); const req = new Request("http://localhost/test", { method: "GET" });
await multiLogManager.logRequest(req, "127.0.0.1", true); await multiLogManager.logRequest(req, "127.0.0.1", true);
expect(mockLogRequest).toHaveBeenCalledTimes(2); expect(mockLogRequest).toHaveBeenCalledTimes(2);
expect(mockLogRequest).toHaveBeenCalledWith(req, "127.0.0.1", true); expect(mockLogRequest).toHaveBeenCalledWith(req, "127.0.0.1", true);
}); });
}); });

View file

@ -1,64 +1,65 @@
import type { Config } from "config-manager";
import { MediaBackend, MediaBackendType, MediaHasher } from "..";
import type { ConvertableMediaFormats } from "../media-converter"; import type { ConvertableMediaFormats } from "../media-converter";
import { MediaConverter } from "../media-converter"; import { MediaConverter } from "../media-converter";
import { MediaBackend, MediaBackendType, MediaHasher } from "..";
import type { ConfigType } from "config-manager";
export class LocalMediaBackend extends MediaBackend { export class LocalMediaBackend extends MediaBackend {
constructor(config: ConfigType) { constructor(config: Config) {
super(config, MediaBackendType.LOCAL); super(config, MediaBackendType.LOCAL);
} }
public async addFile(file: File) { public async addFile(file: File) {
if (this.shouldConvertImages(this.config)) { let convertedFile = file;
const fileExtension = file.name.split(".").pop(); if (this.shouldConvertImages(this.config)) {
const mediaConverter = new MediaConverter( const fileExtension = file.name.split(".").pop();
fileExtension as ConvertableMediaFormats, const mediaConverter = new MediaConverter(
this.config.media.conversion fileExtension as ConvertableMediaFormats,
.convert_to as ConvertableMediaFormats this.config.media.conversion
); .convert_to as ConvertableMediaFormats,
file = await mediaConverter.convert(file); );
} convertedFile = await mediaConverter.convert(file);
}
const hash = await new MediaHasher().getMediaHash(file); const hash = await new MediaHasher().getMediaHash(convertedFile);
const newFile = Bun.file( const newFile = Bun.file(
`${this.config.media.local_uploads_folder}/${hash}` `${this.config.media.local_uploads_folder}/${hash}`,
); );
if (await newFile.exists()) { if (await newFile.exists()) {
throw new Error("File already exists"); throw new Error("File already exists");
} }
await Bun.write(newFile, file); await Bun.write(newFile, convertedFile);
return { return {
uploadedFile: file, uploadedFile: convertedFile,
path: `./uploads/${file.name}`, path: `./uploads/${convertedFile.name}`,
hash: hash, hash: hash,
}; };
} }
public async getFileByHash( public async getFileByHash(
hash: string, hash: string,
databaseHashFetcher: (sha256: string) => Promise<string | null> databaseHashFetcher: (sha256: string) => Promise<string | null>,
): Promise<File | null> { ): Promise<File | null> {
const filename = await databaseHashFetcher(hash); const filename = await databaseHashFetcher(hash);
if (!filename) return null; if (!filename) return null;
return this.getFile(filename); return this.getFile(filename);
} }
public async getFile(filename: string): Promise<File | null> { public async getFile(filename: string): Promise<File | null> {
const file = Bun.file( const file = Bun.file(
`${this.config.media.local_uploads_folder}/${filename}` `${this.config.media.local_uploads_folder}/${filename}`,
); );
if (!(await file.exists())) return null; if (!(await file.exists())) return null;
return new File([await file.arrayBuffer()], filename, { return new File([await file.arrayBuffer()], filename, {
type: file.type, type: file.type,
lastModified: file.lastModified, lastModified: file.lastModified,
}); });
} }
} }

View file

@ -1,69 +1,74 @@
import { S3Client } from "@jsr/bradenmacdonald__s3-lite-client"; import { S3Client } from "@jsr/bradenmacdonald__s3-lite-client";
import type { Config } from "config-manager";
import { MediaBackend, MediaBackendType, MediaHasher } from "..";
import type { ConvertableMediaFormats } from "../media-converter"; import type { ConvertableMediaFormats } from "../media-converter";
import { MediaConverter } from "../media-converter"; import { MediaConverter } from "../media-converter";
import { MediaBackend, MediaBackendType, MediaHasher } from "..";
import type { ConfigType } from "config-manager";
export class S3MediaBackend extends MediaBackend { export class S3MediaBackend extends MediaBackend {
constructor( constructor(
config: ConfigType, config: Config,
private s3Client = new S3Client({ private s3Client = new S3Client({
endPoint: config.s3.endpoint, endPoint: config.s3.endpoint,
useSSL: true, useSSL: true,
region: config.s3.region || "auto", region: config.s3.region || "auto",
bucket: config.s3.bucket_name, bucket: config.s3.bucket_name,
accessKey: config.s3.access_key, accessKey: config.s3.access_key,
secretKey: config.s3.secret_access_key, secretKey: config.s3.secret_access_key,
}) }),
) { ) {
super(config, MediaBackendType.S3); super(config, MediaBackendType.S3);
} }
public async addFile(file: File) { public async addFile(file: File) {
if (this.shouldConvertImages(this.config)) { let convertedFile = file;
const fileExtension = file.name.split(".").pop(); if (this.shouldConvertImages(this.config)) {
const mediaConverter = new MediaConverter( const fileExtension = file.name.split(".").pop();
fileExtension as ConvertableMediaFormats, const mediaConverter = new MediaConverter(
this.config.media.conversion fileExtension as ConvertableMediaFormats,
.convert_to as ConvertableMediaFormats this.config.media.conversion
); .convert_to as ConvertableMediaFormats,
file = await mediaConverter.convert(file); );
} convertedFile = await mediaConverter.convert(file);
}
const hash = await new MediaHasher().getMediaHash(file); const hash = await new MediaHasher().getMediaHash(convertedFile);
await this.s3Client.putObject(file.name, file.stream(), { await this.s3Client.putObject(
size: file.size, convertedFile.name,
}); convertedFile.stream(),
{
size: convertedFile.size,
},
);
return { return {
uploadedFile: file, uploadedFile: convertedFile,
hash: hash, hash: hash,
}; };
} }
public async getFileByHash( public async getFileByHash(
hash: string, hash: string,
databaseHashFetcher: (sha256: string) => Promise<string | null> databaseHashFetcher: (sha256: string) => Promise<string | null>,
): Promise<File | null> { ): Promise<File | null> {
const filename = await databaseHashFetcher(hash); const filename = await databaseHashFetcher(hash);
if (!filename) return null; if (!filename) return null;
return this.getFile(filename); return this.getFile(filename);
} }
public async getFile(filename: string): Promise<File | null> { public async getFile(filename: string): Promise<File | null> {
try { try {
await this.s3Client.statObject(filename); await this.s3Client.statObject(filename);
} catch { } catch {
return null; return null;
} }
const file = await this.s3Client.getObject(filename); const file = await this.s3Client.getObject(filename);
return new File([await file.arrayBuffer()], filename, { return new File([await file.arrayBuffer()], filename, {
type: file.headers.get("Content-Type") || "undefined", type: file.headers.get("Content-Type") || "undefined",
}); });
} }
} }

View file

@ -1,101 +1,101 @@
import type { ConfigType } from "config-manager"; import type { Config } from "config-manager";
export enum MediaBackendType { export enum MediaBackendType {
LOCAL = "local", LOCAL = "local",
S3 = "s3", S3 = "s3",
} }
interface UploadedFileMetadata { interface UploadedFileMetadata {
uploadedFile: File; uploadedFile: File;
path?: string; path?: string;
hash: string; hash: string;
} }
export class MediaHasher { export class MediaHasher {
/** /**
* Returns the SHA-256 hash of a file in hex format * Returns the SHA-256 hash of a file in hex format
* @param media The file to hash * @param media The file to hash
* @returns The SHA-256 hash of the file in hex format * @returns The SHA-256 hash of the file in hex format
*/ */
public async getMediaHash(media: File) { public async getMediaHash(media: File) {
const hash = new Bun.SHA256() const hash = new Bun.SHA256()
.update(await media.arrayBuffer()) .update(await media.arrayBuffer())
.digest("hex"); .digest("hex");
return hash; return hash;
} }
} }
export class MediaBackend { export class MediaBackend {
constructor( constructor(
public config: ConfigType, public config: Config,
public backend: MediaBackendType public backend: MediaBackendType,
) {} ) {}
static async fromBackendType( static async fromBackendType(
backend: MediaBackendType, backend: MediaBackendType,
config: ConfigType config: Config,
): Promise<MediaBackend> { ): Promise<MediaBackend> {
switch (backend) { switch (backend) {
case MediaBackendType.LOCAL: case MediaBackendType.LOCAL:
return new (await import("./backends/local")).LocalMediaBackend( return new (await import("./backends/local")).LocalMediaBackend(
config config,
); );
case MediaBackendType.S3: case MediaBackendType.S3:
return new (await import("./backends/s3")).S3MediaBackend( return new (await import("./backends/s3")).S3MediaBackend(
config config,
); );
default: default:
throw new Error(`Unknown backend type: ${backend as any}`); throw new Error(`Unknown backend type: ${backend as string}`);
} }
} }
public getBackendType() { public getBackendType() {
return this.backend; return this.backend;
} }
public shouldConvertImages(config: ConfigType) { public shouldConvertImages(config: Config) {
return config.media.conversion.convert_images; return config.media.conversion.convert_images;
} }
/** /**
* Fetches file from backend from SHA-256 hash * Fetches file from backend from SHA-256 hash
* @param file SHA-256 hash of wanted file * @param file SHA-256 hash of wanted file
* @param databaseHashFetcher Function that takes in a sha256 hash as input and outputs the filename of that file in the database * @param databaseHashFetcher Function that takes in a sha256 hash as input and outputs the filename of that file in the database
* @returns The file as a File object * @returns The file as a File object
*/ */
public getFileByHash( public getFileByHash(
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
file: string, file: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
databaseHashFetcher: (sha256: string) => Promise<string> databaseHashFetcher: (sha256: string) => Promise<string>,
): Promise<File | null> { ): Promise<File | null> {
return Promise.reject( return Promise.reject(
new Error("Do not call MediaBackend directly: use a subclass") new Error("Do not call MediaBackend directly: use a subclass"),
); );
} }
/** /**
* Fetches file from backend from filename * Fetches file from backend from filename
* @param filename File name * @param filename File name
* @returns The file as a File object * @returns The file as a File object
*/ */
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
public getFile(filename: string): Promise<File | null> { public getFile(filename: string): Promise<File | null> {
return Promise.reject( return Promise.reject(
new Error("Do not call MediaBackend directly: use a subclass") new Error("Do not call MediaBackend directly: use a subclass"),
); );
} }
/** /**
* Adds file to backend * Adds file to backend
* @param file File to add * @param file File to add
* @returns Metadata about the uploaded file * @returns Metadata about the uploaded file
*/ */
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
public addFile(file: File): Promise<UploadedFileMetadata> { public addFile(file: File): Promise<UploadedFileMetadata> {
return Promise.reject( return Promise.reject(
new Error("Do not call MediaBackend directly: use a subclass") new Error("Do not call MediaBackend directly: use a subclass"),
); );
} }
} }

View file

@ -6,89 +6,89 @@
import sharp from "sharp"; import sharp from "sharp";
export enum ConvertableMediaFormats { export enum ConvertableMediaFormats {
PNG = "png", PNG = "png",
WEBP = "webp", WEBP = "webp",
JPEG = "jpeg", JPEG = "jpeg",
JPG = "jpg", JPG = "jpg",
AVIF = "avif", AVIF = "avif",
JXL = "jxl", JXL = "jxl",
HEIF = "heif", HEIF = "heif",
} }
/** /**
* Handles media conversion between formats * Handles media conversion between formats
*/ */
export class MediaConverter { export class MediaConverter {
constructor( constructor(
public fromFormat: ConvertableMediaFormats, public fromFormat: ConvertableMediaFormats,
public toFormat: ConvertableMediaFormats public toFormat: ConvertableMediaFormats,
) {} ) {}
/** /**
* Returns whether the media is convertable * Returns whether the media is convertable
* @returns Whether the media is convertable * @returns Whether the media is convertable
*/ */
public isConvertable() { public isConvertable() {
return ( return (
this.fromFormat !== this.toFormat && this.fromFormat !== this.toFormat &&
Object.values(ConvertableMediaFormats).includes(this.fromFormat) Object.values(ConvertableMediaFormats).includes(this.fromFormat)
); );
} }
/** /**
* Returns the file name with the extension replaced * Returns the file name with the extension replaced
* @param fileName File name to replace * @param fileName File name to replace
* @returns File name with extension replaced * @returns File name with extension replaced
*/ */
private getReplacedFileName(fileName: string) { private getReplacedFileName(fileName: string) {
return this.extractFilenameFromPath(fileName).replace( return this.extractFilenameFromPath(fileName).replace(
new RegExp(`\\.${this.fromFormat}$`), new RegExp(`\\.${this.fromFormat}$`),
`.${this.toFormat}` `.${this.toFormat}`,
); );
} }
/** /**
* Extracts the filename from a path * Extracts the filename from a path
* @param path Path to extract filename from * @param path Path to extract filename from
* @returns Extracted filename * @returns Extracted filename
*/ */
private extractFilenameFromPath(path: string) { private extractFilenameFromPath(path: string) {
// Don't count escaped slashes as path separators // Don't count escaped slashes as path separators
const pathParts = path.split(/(?<!\\)\//); const pathParts = path.split(/(?<!\\)\//);
return pathParts[pathParts.length - 1]; return pathParts[pathParts.length - 1];
} }
/** /**
* Converts media to the specified format * Converts media to the specified format
* @param media Media to convert * @param media Media to convert
* @returns Converted media * @returns Converted media
*/ */
public async convert(media: File) { public async convert(media: File) {
if (!this.isConvertable()) { if (!this.isConvertable()) {
return media; return media;
} }
const sharpCommand = sharp(await media.arrayBuffer()); const sharpCommand = sharp(await media.arrayBuffer());
// Calculate newFilename before changing formats to prevent errors with jpg files // Calculate newFilename before changing formats to prevent errors with jpg files
const newFilename = this.getReplacedFileName(media.name); const newFilename = this.getReplacedFileName(media.name);
if (this.fromFormat === ConvertableMediaFormats.JPG) { if (this.fromFormat === ConvertableMediaFormats.JPG) {
this.fromFormat = ConvertableMediaFormats.JPEG; this.fromFormat = ConvertableMediaFormats.JPEG;
} }
if (this.toFormat === ConvertableMediaFormats.JPG) { if (this.toFormat === ConvertableMediaFormats.JPG) {
this.toFormat = ConvertableMediaFormats.JPEG; this.toFormat = ConvertableMediaFormats.JPEG;
} }
const convertedBuffer = await sharpCommand[this.toFormat]().toBuffer(); const convertedBuffer = await sharpCommand[this.toFormat]().toBuffer();
// Convert the buffer to a BlobPart // Convert the buffer to a BlobPart
const buffer = new Blob([convertedBuffer]); const buffer = new Blob([convertedBuffer]);
return new File([buffer], newFilename, { return new File([buffer], newFilename, {
type: `image/${this.toFormat}`, type: `image/${this.toFormat}`,
lastModified: Date.now(), lastModified: Date.now(),
}); });
} }
} }

View file

@ -1,9 +1,9 @@
{ {
"name": "media-manager", "name": "media-manager",
"version": "0.0.0", "version": "0.0.0",
"main": "index.ts", "main": "index.ts",
"dependencies": { "dependencies": {
"@jsr/bradenmacdonald__s3-lite-client": "npm:@jsr/bradenmacdonald__s3-lite-client", "@jsr/bradenmacdonald__s3-lite-client": "npm:@jsr/bradenmacdonald__s3-lite-client",
"config-manager": "workspace:*" "config-manager": "workspace:*"
} }
} }

View file

@ -1,276 +1,277 @@
import { beforeEach, describe, expect, it, jest, spyOn } from "bun:test";
import type { S3Client } from "@jsr/bradenmacdonald__s3-lite-client";
import type { Config } from "config-manager";
// FILEPATH: /home/jessew/Dev/lysand/packages/media-manager/backends/s3.test.ts // FILEPATH: /home/jessew/Dev/lysand/packages/media-manager/backends/s3.test.ts
import { MediaBackend, MediaBackendType, MediaHasher } from ".."; import { MediaBackend, MediaBackendType, MediaHasher } from "..";
import type { S3Client } from "@bradenmacdonald/s3-lite-client";
import { beforeEach, describe, jest, it, expect, spyOn } from "bun:test";
import { S3MediaBackend } from "../backends/s3";
import type { ConfigType } from "config-manager";
import { ConvertableMediaFormats, MediaConverter } from "../media-converter";
import { LocalMediaBackend } from "../backends/local"; import { LocalMediaBackend } from "../backends/local";
import { S3MediaBackend } from "../backends/s3";
import { ConvertableMediaFormats, MediaConverter } from "../media-converter";
type DeepPartial<T> = { type DeepPartial<T> = {
[P in keyof T]?: DeepPartial<T[P]>; [P in keyof T]?: DeepPartial<T[P]>;
}; };
describe("MediaBackend", () => { describe("MediaBackend", () => {
let mediaBackend: MediaBackend; let mediaBackend: MediaBackend;
let mockConfig: ConfigType; let mockConfig: Config;
beforeEach(() => { beforeEach(() => {
mockConfig = { mockConfig = {
media: { media: {
conversion: { conversion: {
convert_images: true, convert_images: true,
}, },
}, },
} as ConfigType; } as Config;
mediaBackend = new MediaBackend(mockConfig, MediaBackendType.S3); mediaBackend = new MediaBackend(mockConfig, MediaBackendType.S3);
}); });
it("should initialize with correct backend type", () => { it("should initialize with correct backend type", () => {
expect(mediaBackend.getBackendType()).toEqual(MediaBackendType.S3); expect(mediaBackend.getBackendType()).toEqual(MediaBackendType.S3);
}); });
describe("fromBackendType", () => { describe("fromBackendType", () => {
it("should return a LocalMediaBackend instance for LOCAL backend type", async () => { it("should return a LocalMediaBackend instance for LOCAL backend type", async () => {
const backend = await MediaBackend.fromBackendType( const backend = await MediaBackend.fromBackendType(
MediaBackendType.LOCAL, MediaBackendType.LOCAL,
mockConfig mockConfig,
); );
expect(backend).toBeInstanceOf(LocalMediaBackend); expect(backend).toBeInstanceOf(LocalMediaBackend);
}); });
it("should return a S3MediaBackend instance for S3 backend type", async () => { it("should return a S3MediaBackend instance for S3 backend type", async () => {
const backend = await MediaBackend.fromBackendType( const backend = await MediaBackend.fromBackendType(
MediaBackendType.S3, MediaBackendType.S3,
{ {
s3: { s3: {
endpoint: "localhost:4566", endpoint: "localhost:4566",
region: "us-east-1", region: "us-east-1",
bucket_name: "test-bucket", bucket_name: "test-bucket",
access_key: "test-access", access_key: "test-access",
public_url: "test", public_url: "test",
secret_access_key: "test-secret", secret_access_key: "test-secret",
}, },
} as ConfigType } as Config,
); );
expect(backend).toBeInstanceOf(S3MediaBackend); expect(backend).toBeInstanceOf(S3MediaBackend);
}); });
it("should throw an error for unknown backend type", () => { it("should throw an error for unknown backend type", () => {
expect( expect(
MediaBackend.fromBackendType("unknown" as any, mockConfig) // @ts-expect-error This is a test
).rejects.toThrow("Unknown backend type: unknown"); MediaBackend.fromBackendType("unknown", mockConfig),
}); ).rejects.toThrow("Unknown backend type: unknown");
}); });
});
it("should check if images should be converted", () => { it("should check if images should be converted", () => {
expect(mediaBackend.shouldConvertImages(mockConfig)).toBe(true); expect(mediaBackend.shouldConvertImages(mockConfig)).toBe(true);
mockConfig.media.conversion.convert_images = false; mockConfig.media.conversion.convert_images = false;
expect(mediaBackend.shouldConvertImages(mockConfig)).toBe(false); expect(mediaBackend.shouldConvertImages(mockConfig)).toBe(false);
}); });
it("should throw error when calling getFileByHash", () => { it("should throw error when calling getFileByHash", () => {
const mockHash = "test-hash"; const mockHash = "test-hash";
const databaseHashFetcher = jest.fn().mockResolvedValue("test.jpg"); const databaseHashFetcher = jest.fn().mockResolvedValue("test.jpg");
expect( expect(
mediaBackend.getFileByHash(mockHash, databaseHashFetcher) mediaBackend.getFileByHash(mockHash, databaseHashFetcher),
).rejects.toThrow(Error); ).rejects.toThrow(Error);
}); });
it("should throw error when calling getFile", () => { it("should throw error when calling getFile", () => {
const mockFilename = "test.jpg"; const mockFilename = "test.jpg";
expect(mediaBackend.getFile(mockFilename)).rejects.toThrow(Error); expect(mediaBackend.getFile(mockFilename)).rejects.toThrow(Error);
}); });
it("should throw error when calling addFile", () => { it("should throw error when calling addFile", () => {
const mockFile = new File([""], "test.jpg"); const mockFile = new File([""], "test.jpg");
expect(mediaBackend.addFile(mockFile)).rejects.toThrow(); expect(mediaBackend.addFile(mockFile)).rejects.toThrow();
}); });
}); });
describe("S3MediaBackend", () => { describe("S3MediaBackend", () => {
let s3MediaBackend: S3MediaBackend; let s3MediaBackend: S3MediaBackend;
let mockS3Client: Partial<S3Client>; let mockS3Client: Partial<S3Client>;
let mockConfig: DeepPartial<ConfigType>; let mockConfig: DeepPartial<Config>;
let mockFile: File; let mockFile: File;
let mockMediaHasher: MediaHasher; let mockMediaHasher: MediaHasher;
beforeEach(() => { beforeEach(() => {
mockConfig = { mockConfig = {
s3: { s3: {
endpoint: "http://localhost:4566", endpoint: "http://localhost:4566",
region: "us-east-1", region: "us-east-1",
bucket_name: "test-bucket", bucket_name: "test-bucket",
access_key: "test-access-key", access_key: "test-access-key",
secret_access_key: "test-secret-access-key", secret_access_key: "test-secret-access-key",
public_url: "test", public_url: "test",
}, },
media: { media: {
conversion: { conversion: {
convert_to: ConvertableMediaFormats.PNG, convert_to: ConvertableMediaFormats.PNG,
}, },
}, },
}; };
mockFile = new File([new TextEncoder().encode("test")], "test.jpg"); mockFile = new File([new TextEncoder().encode("test")], "test.jpg");
mockMediaHasher = new MediaHasher(); mockMediaHasher = new MediaHasher();
mockS3Client = { mockS3Client = {
putObject: jest.fn().mockResolvedValue({}), putObject: jest.fn().mockResolvedValue({}),
statObject: jest.fn().mockResolvedValue({}), statObject: jest.fn().mockResolvedValue({}),
getObject: jest.fn().mockResolvedValue({ getObject: jest.fn().mockResolvedValue({
blob: jest.fn().mockResolvedValue(new Blob()), blob: jest.fn().mockResolvedValue(new Blob()),
headers: new Headers({ "Content-Type": "image/jpeg" }), headers: new Headers({ "Content-Type": "image/jpeg" }),
}), }),
} as Partial<S3Client>; } as Partial<S3Client>;
s3MediaBackend = new S3MediaBackend( s3MediaBackend = new S3MediaBackend(
mockConfig as ConfigType, mockConfig as Config,
mockS3Client as S3Client mockS3Client as S3Client,
); );
}); });
it("should initialize with correct type", () => { it("should initialize with correct type", () => {
expect(s3MediaBackend.getBackendType()).toEqual(MediaBackendType.S3); expect(s3MediaBackend.getBackendType()).toEqual(MediaBackendType.S3);
}); });
it("should add file", async () => { it("should add file", async () => {
const mockHash = "test-hash"; const mockHash = "test-hash";
spyOn(mockMediaHasher, "getMediaHash").mockResolvedValue(mockHash); spyOn(mockMediaHasher, "getMediaHash").mockResolvedValue(mockHash);
const result = await s3MediaBackend.addFile(mockFile); const result = await s3MediaBackend.addFile(mockFile);
expect(result.uploadedFile).toEqual(mockFile); expect(result.uploadedFile).toEqual(mockFile);
expect(result.hash).toHaveLength(64); expect(result.hash).toHaveLength(64);
expect(mockS3Client.putObject).toHaveBeenCalledWith( expect(mockS3Client.putObject).toHaveBeenCalledWith(
mockFile.name, mockFile.name,
expect.any(ReadableStream), expect.any(ReadableStream),
{ size: mockFile.size } { size: mockFile.size },
); );
}); });
it("should get file by hash", async () => { it("should get file by hash", async () => {
const mockHash = "test-hash"; const mockHash = "test-hash";
const mockFilename = "test.jpg"; const mockFilename = "test.jpg";
const databaseHashFetcher = jest.fn().mockResolvedValue(mockFilename); const databaseHashFetcher = jest.fn().mockResolvedValue(mockFilename);
mockS3Client.statObject = jest.fn().mockResolvedValue({}); mockS3Client.statObject = jest.fn().mockResolvedValue({});
mockS3Client.getObject = jest.fn().mockResolvedValue({ mockS3Client.getObject = jest.fn().mockResolvedValue({
arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(10)), arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(10)),
headers: new Headers({ "Content-Type": "image/jpeg" }), headers: new Headers({ "Content-Type": "image/jpeg" }),
}); });
const file = await s3MediaBackend.getFileByHash( const file = await s3MediaBackend.getFileByHash(
mockHash, mockHash,
databaseHashFetcher databaseHashFetcher,
); );
expect(file).not.toBeNull(); expect(file).not.toBeNull();
expect(file?.name).toEqual(mockFilename); expect(file?.name).toEqual(mockFilename);
expect(file?.type).toEqual("image/jpeg"); expect(file?.type).toEqual("image/jpeg");
}); });
it("should get file", async () => { it("should get file", async () => {
const mockFilename = "test.jpg"; const mockFilename = "test.jpg";
mockS3Client.statObject = jest.fn().mockResolvedValue({}); mockS3Client.statObject = jest.fn().mockResolvedValue({});
mockS3Client.getObject = jest.fn().mockResolvedValue({ mockS3Client.getObject = jest.fn().mockResolvedValue({
arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(10)), arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(10)),
headers: new Headers({ "Content-Type": "image/jpeg" }), headers: new Headers({ "Content-Type": "image/jpeg" }),
}); });
const file = await s3MediaBackend.getFile(mockFilename); const file = await s3MediaBackend.getFile(mockFilename);
expect(file).not.toBeNull(); expect(file).not.toBeNull();
expect(file?.name).toEqual(mockFilename); expect(file?.name).toEqual(mockFilename);
expect(file?.type).toEqual("image/jpeg"); expect(file?.type).toEqual("image/jpeg");
}); });
}); });
describe("LocalMediaBackend", () => { describe("LocalMediaBackend", () => {
let localMediaBackend: LocalMediaBackend; let localMediaBackend: LocalMediaBackend;
let mockConfig: ConfigType; let mockConfig: Config;
let mockFile: File; let mockFile: File;
let mockMediaHasher: MediaHasher; let mockMediaHasher: MediaHasher;
beforeEach(() => { beforeEach(() => {
mockConfig = { mockConfig = {
media: { media: {
conversion: { conversion: {
convert_images: true, convert_images: true,
convert_to: ConvertableMediaFormats.PNG, convert_to: ConvertableMediaFormats.PNG,
}, },
local_uploads_folder: "./uploads", local_uploads_folder: "./uploads",
}, },
} as ConfigType; } as Config;
mockFile = Bun.file(__dirname + "/megamind.jpg") as unknown as File; mockFile = Bun.file(`${__dirname}/megamind.jpg`) as unknown as File;
mockMediaHasher = new MediaHasher(); mockMediaHasher = new MediaHasher();
localMediaBackend = new LocalMediaBackend(mockConfig); localMediaBackend = new LocalMediaBackend(mockConfig);
}); });
it("should initialize with correct type", () => { it("should initialize with correct type", () => {
expect(localMediaBackend.getBackendType()).toEqual( expect(localMediaBackend.getBackendType()).toEqual(
MediaBackendType.LOCAL MediaBackendType.LOCAL,
); );
}); });
it("should add file", async () => { it("should add file", async () => {
const mockHash = "test-hash"; const mockHash = "test-hash";
spyOn(mockMediaHasher, "getMediaHash").mockResolvedValue(mockHash); spyOn(mockMediaHasher, "getMediaHash").mockResolvedValue(mockHash);
const mockMediaConverter = new MediaConverter( const mockMediaConverter = new MediaConverter(
ConvertableMediaFormats.JPG, ConvertableMediaFormats.JPG,
ConvertableMediaFormats.PNG ConvertableMediaFormats.PNG,
); );
spyOn(mockMediaConverter, "convert").mockResolvedValue(mockFile); spyOn(mockMediaConverter, "convert").mockResolvedValue(mockFile);
// @ts-expect-error This is a mock // @ts-expect-error This is a mock
spyOn(Bun, "file").mockImplementationOnce(() => ({ spyOn(Bun, "file").mockImplementationOnce(() => ({
exists: () => Promise.resolve(false), exists: () => Promise.resolve(false),
})); }));
spyOn(Bun, "write").mockImplementationOnce(() => spyOn(Bun, "write").mockImplementationOnce(() =>
Promise.resolve(mockFile.size) Promise.resolve(mockFile.size),
); );
const result = await localMediaBackend.addFile(mockFile); const result = await localMediaBackend.addFile(mockFile);
expect(result.uploadedFile).toEqual(mockFile); expect(result.uploadedFile).toEqual(mockFile);
expect(result.path).toEqual(`./uploads/megamind.png`); expect(result.path).toEqual("./uploads/megamind.png");
expect(result.hash).toHaveLength(64); expect(result.hash).toHaveLength(64);
}); });
it("should get file by hash", async () => { it("should get file by hash", async () => {
const mockHash = "test-hash"; const mockHash = "test-hash";
const mockFilename = "test.jpg"; const mockFilename = "test.jpg";
const databaseHashFetcher = jest.fn().mockResolvedValue(mockFilename); const databaseHashFetcher = jest.fn().mockResolvedValue(mockFilename);
// @ts-expect-error This is a mock // @ts-expect-error This is a mock
spyOn(Bun, "file").mockImplementationOnce(() => ({ spyOn(Bun, "file").mockImplementationOnce(() => ({
exists: () => Promise.resolve(true), exists: () => Promise.resolve(true),
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
type: "image/jpeg", type: "image/jpeg",
lastModified: 123456789, lastModified: 123456789,
})); }));
const file = await localMediaBackend.getFileByHash( const file = await localMediaBackend.getFileByHash(
mockHash, mockHash,
databaseHashFetcher databaseHashFetcher,
); );
expect(file).not.toBeNull(); expect(file).not.toBeNull();
expect(file?.name).toEqual(mockFilename); expect(file?.name).toEqual(mockFilename);
expect(file?.type).toEqual("image/jpeg"); expect(file?.type).toEqual("image/jpeg");
}); });
it("should get file", async () => { it("should get file", async () => {
const mockFilename = "test.jpg"; const mockFilename = "test.jpg";
// @ts-expect-error This is a mock // @ts-expect-error This is a mock
spyOn(Bun, "file").mockImplementationOnce(() => ({ spyOn(Bun, "file").mockImplementationOnce(() => ({
exists: () => Promise.resolve(true), exists: () => Promise.resolve(true),
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
type: "image/jpeg", type: "image/jpeg",
lastModified: 123456789, lastModified: 123456789,
})); }));
const file = await localMediaBackend.getFile(mockFilename); const file = await localMediaBackend.getFile(mockFilename);
expect(file).not.toBeNull(); expect(file).not.toBeNull();
expect(file?.name).toEqual(mockFilename); expect(file?.name).toEqual(mockFilename);
expect(file?.type).toEqual("image/jpeg"); expect(file?.type).toEqual("image/jpeg");
}); });
}); });

View file

@ -1,65 +1,65 @@
// FILEPATH: /home/jessew/Dev/lysand/packages/media-manager/media-converter.test.ts // FILEPATH: /home/jessew/Dev/lysand/packages/media-manager/media-converter.test.ts
import { describe, it, expect, beforeEach } from "bun:test"; import { beforeEach, describe, expect, it } from "bun:test";
import { MediaConverter, ConvertableMediaFormats } from "../media-converter"; import { ConvertableMediaFormats, MediaConverter } from "../media-converter";
describe("MediaConverter", () => { describe("MediaConverter", () => {
let mediaConverter: MediaConverter; let mediaConverter: MediaConverter;
beforeEach(() => { beforeEach(() => {
mediaConverter = new MediaConverter( mediaConverter = new MediaConverter(
ConvertableMediaFormats.JPG, ConvertableMediaFormats.JPG,
ConvertableMediaFormats.PNG ConvertableMediaFormats.PNG,
); );
}); });
it("should initialize with correct formats", () => { it("should initialize with correct formats", () => {
expect(mediaConverter.fromFormat).toEqual(ConvertableMediaFormats.JPG); expect(mediaConverter.fromFormat).toEqual(ConvertableMediaFormats.JPG);
expect(mediaConverter.toFormat).toEqual(ConvertableMediaFormats.PNG); expect(mediaConverter.toFormat).toEqual(ConvertableMediaFormats.PNG);
}); });
it("should check if media is convertable", () => { it("should check if media is convertable", () => {
expect(mediaConverter.isConvertable()).toBe(true); expect(mediaConverter.isConvertable()).toBe(true);
mediaConverter.toFormat = ConvertableMediaFormats.JPG; mediaConverter.toFormat = ConvertableMediaFormats.JPG;
expect(mediaConverter.isConvertable()).toBe(false); expect(mediaConverter.isConvertable()).toBe(false);
}); });
it("should replace file name extension", () => { it("should replace file name extension", () => {
const fileName = "test.jpg"; const fileName = "test.jpg";
const expectedFileName = "test.png"; const expectedFileName = "test.png";
// Written like this because it's a private function // Written like this because it's a private function
expect(mediaConverter["getReplacedFileName"](fileName)).toEqual( expect(mediaConverter.getReplacedFileName(fileName)).toEqual(
expectedFileName expectedFileName,
); );
}); });
describe("Filename extractor", () => { describe("Filename extractor", () => {
it("should extract filename from path", () => { it("should extract filename from path", () => {
const path = "path/to/test.jpg"; const path = "path/to/test.jpg";
const expectedFileName = "test.jpg"; const expectedFileName = "test.jpg";
expect(mediaConverter["extractFilenameFromPath"](path)).toEqual( expect(mediaConverter.extractFilenameFromPath(path)).toEqual(
expectedFileName expectedFileName,
); );
}); });
it("should handle escaped slashes", () => { it("should handle escaped slashes", () => {
const path = "path/to/test\\/test.jpg"; const path = "path/to/test\\/test.jpg";
const expectedFileName = "test\\/test.jpg"; const expectedFileName = "test\\/test.jpg";
expect(mediaConverter["extractFilenameFromPath"](path)).toEqual( expect(mediaConverter.extractFilenameFromPath(path)).toEqual(
expectedFileName expectedFileName,
); );
}); });
}); });
it("should convert media", async () => { it("should convert media", async () => {
const file = Bun.file(__dirname + "/megamind.jpg"); const file = Bun.file(`${__dirname}/megamind.jpg`);
const convertedFile = await mediaConverter.convert( const convertedFile = await mediaConverter.convert(
file as unknown as File file as unknown as File,
); );
expect(convertedFile.name).toEqual("megamind.png"); expect(convertedFile.name).toEqual("megamind.png");
expect(convertedFile.type).toEqual( expect(convertedFile.type).toEqual(
`image/${ConvertableMediaFormats.PNG}` `image/${ConvertableMediaFormats.PNG}`,
); );
}); });
}); });

View file

@ -2,7 +2,7 @@ import type { APActor, APNote } from "activitypub-types";
import { ActivityPubTranslator } from "./protocols/activitypub"; import { ActivityPubTranslator } from "./protocols/activitypub";
export enum SupportedProtocols { export enum SupportedProtocols {
ACTIVITYPUB = "activitypub", ACTIVITYPUB = "activitypub",
} }
/** /**
@ -12,37 +12,40 @@ export enum SupportedProtocols {
* This class is not meant to be instantiated directly, but rather for its children to be used. * This class is not meant to be instantiated directly, but rather for its children to be used.
*/ */
export class ProtocolTranslator { export class ProtocolTranslator {
static auto(object: any) { // biome-ignore lint/suspicious/noExplicitAny: <explanation>
const protocol = this.recognizeProtocol(object); static auto(object: any) {
switch (protocol) { const protocol = ProtocolTranslator.recognizeProtocol(object);
case SupportedProtocols.ACTIVITYPUB: switch (protocol) {
return new ActivityPubTranslator(); case SupportedProtocols.ACTIVITYPUB:
default: return new ActivityPubTranslator();
throw new Error("Unknown protocol"); default:
} throw new Error("Unknown protocol");
} }
}
/** /**
* Translates an ActivityPub actor to a Lysand user * Translates an ActivityPub actor to a Lysand user
* @param data Raw JSON-LD data from an ActivityPub actor * @param data Raw JSON-LD data from an ActivityPub actor
*/ */
user(data: APActor) { user(data: APActor) {
// //
} }
/** /**
* Translates an ActivityPub note to a Lysand status * Translates an ActivityPub note to a Lysand status
* @param data Raw JSON-LD data from an ActivityPub note * @param data Raw JSON-LD data from an ActivityPub note
*/ */
status(data: APNote) { status(data: APNote) {
// //
} }
/** /**
* Automatically recognizes the protocol of a given object * Automatically recognizes the protocol of a given object
*/ */
private static recognizeProtocol(object: any) {
// Temporary stub // biome-ignore lint/suspicious/noExplicitAny: <explanation>
return SupportedProtocols.ACTIVITYPUB; private static recognizeProtocol(object: any) {
} // Temporary stub
return SupportedProtocols.ACTIVITYPUB;
}
} }

View file

@ -1,9 +1,9 @@
{ {
"name": "protocol-translator", "name": "protocol-translator",
"version": "0.0.0", "version": "0.0.0",
"main": "index.ts", "main": "index.ts",
"dependencies": {}, "dependencies": {},
"devDependencies": { "devDependencies": {
"activitypub-types": "^1.1.0" "activitypub-types": "^1.1.0"
} }
} }

View file

@ -1,11 +1,5 @@
import { ProtocolTranslator } from ".."; import { ProtocolTranslator } from "..";
export class ActivityPubTranslator extends ProtocolTranslator { export class ActivityPubTranslator extends ProtocolTranslator {
constructor() { user() {}
super(); }
}
user() {
}
}

View file

@ -13,158 +13,158 @@
* @returns JavaScript object of type T * @returns JavaScript object of type T
*/ */
export class RequestParser { export class RequestParser {
constructor(public request: Request) {} constructor(public request: Request) {}
/** /**
* Parse request body into a JavaScript object * Parse request body into a JavaScript object
* @returns JavaScript object of type T * @returns JavaScript object of type T
* @throws Error if body is invalid * @throws Error if body is invalid
*/ */
async toObject<T>() { async toObject<T>() {
try { try {
switch (await this.determineContentType()) { switch (await this.determineContentType()) {
case "application/json": case "application/json":
return this.parseJson<T>(); return this.parseJson<T>();
case "application/x-www-form-urlencoded": case "application/x-www-form-urlencoded":
return this.parseFormUrlencoded<T>(); return this.parseFormUrlencoded<T>();
case "multipart/form-data": case "multipart/form-data":
return this.parseFormData<T>(); return this.parseFormData<T>();
default: default:
return this.parseQuery<T>(); return this.parseQuery<T>();
} }
} catch { } catch {
return {} as T; return {} as T;
} }
} }
/** /**
* Determine body content type * Determine body content type
* If there is no Content-Type header, automatically * If there is no Content-Type header, automatically
* guess content type. Cuts off after ";" character * guess content type. Cuts off after ";" character
* @returns Content-Type header value, or empty string if there is no body * @returns Content-Type header value, or empty string if there is no body
* @throws Error if body is invalid * @throws Error if body is invalid
* @private * @private
*/ */
private async determineContentType() { private async determineContentType() {
if (this.request.headers.get("Content-Type")) { if (this.request.headers.get("Content-Type")) {
return ( return (
this.request.headers.get("Content-Type")?.split(";")[0] ?? "" this.request.headers.get("Content-Type")?.split(";")[0] ?? ""
); );
} }
// Check if body is valid JSON // Check if body is valid JSON
try { try {
await this.request.json(); await this.request.json();
return "application/json"; return "application/json";
} catch { } catch {
// This is not JSON // This is not JSON
} }
// Check if body is valid FormData // Check if body is valid FormData
try { try {
await this.request.formData(); await this.request.formData();
return "multipart/form-data"; return "multipart/form-data";
} catch { } catch {
// This is not FormData // This is not FormData
} }
if (this.request.body) { if (this.request.body) {
throw new Error("Invalid body"); throw new Error("Invalid body");
} }
// If there is no body, return query parameters // If there is no body, return query parameters
return ""; return "";
} }
/** /**
* Parse FormData body into a JavaScript object * Parse FormData body into a JavaScript object
* @returns JavaScript object of type T * @returns JavaScript object of type T
* @private * @private
* @throws Error if body is invalid * @throws Error if body is invalid
*/ */
private async parseFormData<T>(): Promise<Partial<T>> { private async parseFormData<T>(): Promise<Partial<T>> {
const formData = await this.request.formData(); const formData = await this.request.formData();
const result: Partial<T> = {}; const result: Partial<T> = {};
for (const [key, value] of formData.entries()) { for (const [key, value] of formData.entries()) {
if (value instanceof File) { if (value instanceof File) {
result[key as keyof T] = value as any; result[key as keyof T] = value as T[keyof T];
} else if (key.endsWith("[]")) { } else if (key.endsWith("[]")) {
const arrayKey = key.slice(0, -2) as keyof T; const arrayKey = key.slice(0, -2) as keyof T;
if (!result[arrayKey]) { if (!result[arrayKey]) {
result[arrayKey] = [] as T[keyof T]; result[arrayKey] = [] as T[keyof T];
} }
(result[arrayKey] as any[]).push(value); (result[arrayKey] as FormDataEntryValue[]).push(value);
} else { } else {
result[key as keyof T] = value as any; result[key as keyof T] = value as T[keyof T];
} }
} }
return result; return result;
} }
/** /**
* Parse application/x-www-form-urlencoded body into a JavaScript object * Parse application/x-www-form-urlencoded body into a JavaScript object
* @returns JavaScript object of type T * @returns JavaScript object of type T
* @private * @private
* @throws Error if body is invalid * @throws Error if body is invalid
*/ */
private async parseFormUrlencoded<T>(): Promise<Partial<T>> { private async parseFormUrlencoded<T>(): Promise<Partial<T>> {
const formData = await this.request.formData(); const formData = await this.request.formData();
const result: Partial<T> = {}; const result: Partial<T> = {};
for (const [key, value] of formData.entries()) { for (const [key, value] of formData.entries()) {
if (key.endsWith("[]")) { if (key.endsWith("[]")) {
const arrayKey = key.slice(0, -2) as keyof T; const arrayKey = key.slice(0, -2) as keyof T;
if (!result[arrayKey]) { if (!result[arrayKey]) {
result[arrayKey] = [] as T[keyof T]; result[arrayKey] = [] as T[keyof T];
} }
(result[arrayKey] as any[]).push(value); (result[arrayKey] as FormDataEntryValue[]).push(value);
} else { } else {
result[key as keyof T] = value as any; result[key as keyof T] = value as T[keyof T];
} }
} }
return result; return result;
} }
/** /**
* Parse JSON body into a JavaScript object * Parse JSON body into a JavaScript object
* @returns JavaScript object of type T * @returns JavaScript object of type T
* @private * @private
* @throws Error if body is invalid * @throws Error if body is invalid
*/ */
private async parseJson<T>(): Promise<Partial<T>> { private async parseJson<T>(): Promise<Partial<T>> {
try { try {
return (await this.request.json()) as T; return (await this.request.json()) as T;
} catch { } catch {
return {}; return {};
} }
} }
/** /**
* Parse query parameters into a JavaScript object * Parse query parameters into a JavaScript object
* @private * @private
* @throws Error if body is invalid * @throws Error if body is invalid
* @returns JavaScript object of type T * @returns JavaScript object of type T
*/ */
private parseQuery<T>(): Partial<T> { private parseQuery<T>(): Partial<T> {
const result: Partial<T> = {}; const result: Partial<T> = {};
const url = new URL(this.request.url); const url = new URL(this.request.url);
for (const [key, value] of url.searchParams.entries()) { for (const [key, value] of url.searchParams.entries()) {
if (key.endsWith("[]")) { if (key.endsWith("[]")) {
const arrayKey = key.slice(0, -2) as keyof T; const arrayKey = key.slice(0, -2) as keyof T;
if (!result[arrayKey]) { if (!result[arrayKey]) {
result[arrayKey] = [] as T[keyof T]; result[arrayKey] = [] as T[keyof T];
} }
(result[arrayKey] as string[]).push(value); (result[arrayKey] as string[]).push(value);
} else { } else {
result[key as keyof T] = value as any; result[key as keyof T] = value as T[keyof T];
} }
} }
return result; return result;
} }
} }

View file

@ -3,4 +3,4 @@
"version": "0.0.0", "version": "0.0.0",
"main": "index.ts", "main": "index.ts",
"dependencies": {} "dependencies": {}
} }

View file

@ -1,158 +1,158 @@
import { describe, it, expect, test } from "bun:test"; import { describe, expect, it, test } from "bun:test";
import { RequestParser } from ".."; import { RequestParser } from "..";
describe("RequestParser", () => { describe("RequestParser", () => {
describe("Should parse query parameters correctly", () => { describe("Should parse query parameters correctly", () => {
test("With text parameters", async () => { test("With text parameters", async () => {
const request = new Request( const request = new Request(
"http://localhost?param1=value1&param2=value2" "http://localhost?param1=value1&param2=value2",
); );
const result = await new RequestParser(request).toObject<{ const result = await new RequestParser(request).toObject<{
param1: string; param1: string;
param2: string; param2: string;
}>(); }>();
expect(result).toEqual({ param1: "value1", param2: "value2" }); expect(result).toEqual({ param1: "value1", param2: "value2" });
}); });
test("With Array", async () => { test("With Array", async () => {
const request = new Request( const request = new Request(
"http://localhost?test[]=value1&test[]=value2" "http://localhost?test[]=value1&test[]=value2",
); );
const result = await new RequestParser(request).toObject<{ const result = await new RequestParser(request).toObject<{
test: string[]; test: string[];
}>(); }>();
expect(result.test).toEqual(["value1", "value2"]); expect(result.test).toEqual(["value1", "value2"]);
}); });
test("With both at once", async () => { test("With both at once", async () => {
const request = new Request( const request = new Request(
"http://localhost?param1=value1&param2=value2&test[]=value1&test[]=value2" "http://localhost?param1=value1&param2=value2&test[]=value1&test[]=value2",
); );
const result = await new RequestParser(request).toObject<{ const result = await new RequestParser(request).toObject<{
param1: string; param1: string;
param2: string; param2: string;
test: string[]; test: string[];
}>(); }>();
expect(result).toEqual({ expect(result).toEqual({
param1: "value1", param1: "value1",
param2: "value2", param2: "value2",
test: ["value1", "value2"], test: ["value1", "value2"],
}); });
}); });
}); });
it("should parse JSON body correctly", async () => { it("should parse JSON body correctly", async () => {
const request = new Request("http://localhost", { const request = new Request("http://localhost", {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ param1: "value1", param2: "value2" }), body: JSON.stringify({ param1: "value1", param2: "value2" }),
}); });
const result = await new RequestParser(request).toObject<{ const result = await new RequestParser(request).toObject<{
param1: string; param1: string;
param2: string; param2: string;
}>(); }>();
expect(result).toEqual({ param1: "value1", param2: "value2" }); expect(result).toEqual({ param1: "value1", param2: "value2" });
}); });
it("should handle invalid JSON body", async () => { it("should handle invalid JSON body", async () => {
const request = new Request("http://localhost", { const request = new Request("http://localhost", {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: "invalid json", body: "invalid json",
}); });
const result = await new RequestParser(request).toObject<{ const result = await new RequestParser(request).toObject<{
param1: string; param1: string;
param2: string; param2: string;
}>(); }>();
expect(result).toEqual({}); expect(result).toEqual({});
}); });
describe("should parse form data correctly", () => { describe("should parse form data correctly", () => {
test("With basic text parameters", async () => { test("With basic text parameters", async () => {
const formData = new FormData(); const formData = new FormData();
formData.append("param1", "value1"); formData.append("param1", "value1");
formData.append("param2", "value2"); formData.append("param2", "value2");
const request = new Request("http://localhost", { const request = new Request("http://localhost", {
method: "POST", method: "POST",
body: formData, body: formData,
}); });
const result = await new RequestParser(request).toObject<{ const result = await new RequestParser(request).toObject<{
param1: string; param1: string;
param2: string; param2: string;
}>(); }>();
expect(result).toEqual({ param1: "value1", param2: "value2" }); expect(result).toEqual({ param1: "value1", param2: "value2" });
}); });
test("With File object", async () => { test("With File object", async () => {
const file = new File(["content"], "filename.txt", { const file = new File(["content"], "filename.txt", {
type: "text/plain", type: "text/plain",
}); });
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
const request = new Request("http://localhost", { const request = new Request("http://localhost", {
method: "POST", method: "POST",
body: formData, body: formData,
}); });
const result = await new RequestParser(request).toObject<{ const result = await new RequestParser(request).toObject<{
file: File; file: File;
}>(); }>();
expect(result.file).toBeInstanceOf(File); expect(result.file).toBeInstanceOf(File);
expect(await result.file?.text()).toEqual("content"); expect(await result.file?.text()).toEqual("content");
}); });
test("With Array", async () => { test("With Array", async () => {
const formData = new FormData(); const formData = new FormData();
formData.append("test[]", "value1"); formData.append("test[]", "value1");
formData.append("test[]", "value2"); formData.append("test[]", "value2");
const request = new Request("http://localhost", { const request = new Request("http://localhost", {
method: "POST", method: "POST",
body: formData, body: formData,
}); });
const result = await new RequestParser(request).toObject<{ const result = await new RequestParser(request).toObject<{
test: string[]; test: string[];
}>(); }>();
expect(result.test).toEqual(["value1", "value2"]); expect(result.test).toEqual(["value1", "value2"]);
}); });
test("With all three at once", async () => { test("With all three at once", async () => {
const file = new File(["content"], "filename.txt", { const file = new File(["content"], "filename.txt", {
type: "text/plain", type: "text/plain",
}); });
const formData = new FormData(); const formData = new FormData();
formData.append("param1", "value1"); formData.append("param1", "value1");
formData.append("param2", "value2"); formData.append("param2", "value2");
formData.append("file", file); formData.append("file", file);
formData.append("test[]", "value1"); formData.append("test[]", "value1");
formData.append("test[]", "value2"); formData.append("test[]", "value2");
const request = new Request("http://localhost", { const request = new Request("http://localhost", {
method: "POST", method: "POST",
body: formData, body: formData,
}); });
const result = await new RequestParser(request).toObject<{ const result = await new RequestParser(request).toObject<{
param1: string; param1: string;
param2: string; param2: string;
file: File; file: File;
test: string[]; test: string[];
}>(); }>();
expect(result).toEqual({ expect(result).toEqual({
param1: "value1", param1: "value1",
param2: "value2", param2: "value2",
file: file, file: file,
test: ["value1", "value2"], test: ["value1", "value2"],
}); });
}); });
test("URL Encoded", async () => { test("URL Encoded", async () => {
const request = new Request("http://localhost", { const request = new Request("http://localhost", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
}, },
body: "param1=value1&param2=value2", body: "param1=value1&param2=value2",
}); });
const result = await new RequestParser(request).toObject<{ const result = await new RequestParser(request).toObject<{
param1: string; param1: string;
param2: string; param2: string;
}>(); }>();
expect(result).toEqual({ param1: "value1", param2: "value2" }); expect(result).toEqual({ param1: "value1", param2: "value2" });
}); });
}); });
}); });

View file

@ -1,6 +1,6 @@
<script setup> <script setup>
// Import Tailwind style reset // Import Tailwind style reset
import '@unocss/reset/tailwind-compat.css' import "@unocss/reset/tailwind-compat.css";
</script> </script>
<template> <template>

View file

@ -10,7 +10,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from "vue";
const props = defineProps<{ const props = defineProps<{
label: string; label: string;
@ -26,5 +26,5 @@ const checkValid = (e: Event) => {
} else { } else {
isInvalid.value = true; isInvalid.value = true;
} }
} };
</script> </script>

View file

@ -1,13 +1,13 @@
import { createApp } from "vue";
import "./style.css";
import "virtual:uno.css"; import "virtual:uno.css";
import { createApp } from "vue";
import { createRouter, createWebHistory } from "vue-router"; import { createRouter, createWebHistory } from "vue-router";
import App from "./App.vue"; import App from "./App.vue";
import routes from "./routes"; import routes from "./routes";
import "./style.css";
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: routes, routes: routes,
}); });
const app = createApp(App); const app = createApp(App);

View file

@ -37,7 +37,7 @@
</template> </template>
<script setup> <script setup>
import { ref } from 'vue'; import { ref } from "vue";
const location = window.location; const location = window.location;
const version = __VERSION__; const version = __VERSION__;

View file

@ -52,9 +52,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useRoute } from 'vue-router'; import { onMounted, ref } from "vue";
import LoginInput from "../../components/LoginInput.vue" import { useRoute } from "vue-router";
import { onMounted, ref } from 'vue'; import LoginInput from "../../components/LoginInput.vue";
const query = useRoute().query; const query = useRoute().query;
@ -64,18 +64,21 @@ const client_id = query.client_id;
const scope = query.scope; const scope = query.scope;
const error = decodeURIComponent(query.error as string); const error = decodeURIComponent(query.error as string);
const oauthProviders = ref<{ const oauthProviders = ref<
name: string; | {
icon: string; name: string;
id: string icon: string;
}[] | null>(null); id: string;
}[]
| null
>(null);
const getOauthProviders = async () => { const getOauthProviders = async () => {
const response = await fetch('/oauth/providers'); const response = await fetch("/oauth/providers");
return await response.json() as any; return await response.json();
} };
onMounted(async () => { onMounted(async () => {
oauthProviders.value = await getOauthProviders(); oauthProviders.value = await getOauthProviders();
}) });
</script> </script>

View file

@ -53,7 +53,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useRoute } from 'vue-router'; import { useRoute } from "vue-router";
const query = useRoute().query; const query = useRoute().query;
@ -61,7 +61,7 @@ const application = query.application;
const website = decodeURIComponent(query.website as string); const website = decodeURIComponent(query.website as string);
const redirect_uri = query.redirect_uri as string; const redirect_uri = query.redirect_uri as string;
const client_id = query.client_id; const client_id = query.client_id;
const scope = decodeURIComponent(query.scope as string || ""); const scope = decodeURIComponent((query.scope as string) || "");
const code = query.code; const code = query.code;
const oauthScopeText: Record<string, string> = { const oauthScopeText: Record<string, string> = {
@ -79,7 +79,7 @@ const oauthScopeText: Record<string, string> = {
"w:conversations": "Edit your conversations", "w:conversations": "Edit your conversations",
"w:media": "Upload media", "w:media": "Upload media",
"w:reports": "Report users", "w:reports": "Report users",
} };
const scopes = scope.split(" "); const scopes = scope.split(" ");
@ -89,30 +89,56 @@ const scopes = scope.split(" ");
// Return an array of strings to display // Return an array of strings to display
// "read write:accounts" returns all the fields with $VERB as read, plus the accounts field with $VERB as write // "read write:accounts" returns all the fields with $VERB as read, plus the accounts field with $VERB as write
const getScopeText = (fullScopes: string[]) => { const getScopeText = (fullScopes: string[]) => {
let scopeTexts = []; const scopeTexts = [];
const readScopes = fullScopes.filter(scope => scope.includes("read")); const readScopes = fullScopes.filter((scope) => scope.includes("read"));
const writeScopes = fullScopes.filter(scope => scope.includes("write")); const writeScopes = fullScopes.filter((scope) => scope.includes("write"));
for (const possibleScope of Object.keys(oauthScopeText)) { for (const possibleScope of Object.keys(oauthScopeText)) {
const [scopeAction, scopeName] = possibleScope.split(':'); const [scopeAction, scopeName] = possibleScope.split(":");
if (scopeAction.includes("rw") && (readScopes.includes(`read:${scopeName}`) || readScopes.find(scope => scope === "read")) && (writeScopes.includes(`write:${scopeName}`) || writeScopes.find(scope => scope === "write"))) { if (
if (oauthScopeText[possibleScope].includes("$VERB")) scopeTexts.push(["Read and write", oauthScopeText[possibleScope].replace("$VERB", "")]); scopeAction.includes("rw") &&
(readScopes.includes(`read:${scopeName}`) ||
readScopes.find((scope) => scope === "read")) &&
(writeScopes.includes(`write:${scopeName}`) ||
writeScopes.find((scope) => scope === "write"))
) {
if (oauthScopeText[possibleScope].includes("$VERB"))
scopeTexts.push([
"Read and write",
oauthScopeText[possibleScope].replace("$VERB", ""),
]);
else scopeTexts.push(["", oauthScopeText[possibleScope]]); else scopeTexts.push(["", oauthScopeText[possibleScope]]);
continue; continue;
} }
if (scopeAction.includes('r') && (readScopes.includes(`read:${scopeName}`) || readScopes.find(scope => scope === "read"))) { if (
if (oauthScopeText[possibleScope].includes("$VERB")) scopeTexts.push(["Read", oauthScopeText[possibleScope].replace("$VERB", "")]); scopeAction.includes("r") &&
(readScopes.includes(`read:${scopeName}`) ||
readScopes.find((scope) => scope === "read"))
) {
if (oauthScopeText[possibleScope].includes("$VERB"))
scopeTexts.push([
"Read",
oauthScopeText[possibleScope].replace("$VERB", ""),
]);
else scopeTexts.push(["", oauthScopeText[possibleScope]]); else scopeTexts.push(["", oauthScopeText[possibleScope]]);
} }
if (scopeAction.includes('w') && (writeScopes.includes(`write:${scopeName}`) || writeScopes.find(scope => scope === "write"))) { if (
if (oauthScopeText[possibleScope].includes("$VERB")) scopeTexts.push(["Write", oauthScopeText[possibleScope].replace("$VERB", "")]); scopeAction.includes("w") &&
(writeScopes.includes(`write:${scopeName}`) ||
writeScopes.find((scope) => scope === "write"))
) {
if (oauthScopeText[possibleScope].includes("$VERB"))
scopeTexts.push([
"Write",
oauthScopeText[possibleScope].replace("$VERB", ""),
]);
else scopeTexts.push(["", oauthScopeText[possibleScope]]); else scopeTexts.push(["", oauthScopeText[possibleScope]]);
} }
} }
return scopeTexts; return scopeTexts;
} };
</script> </script>

View file

@ -98,12 +98,14 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from "vue";
import type { APIInstance } from "~types/entities/instance"; import type { APIInstance } from "~types/entities/instance";
import LoginInput from "../../components/LoginInput.vue" import LoginInput from "../../components/LoginInput.vue";
import { computed, ref, watch } from 'vue';
const instanceInfo = await fetch("/api/v1/instance").then(res => res.json()) as APIInstance & { const instanceInfo = (await fetch("/api/v1/instance").then((res) =>
tos_url: string res.json(),
)) as APIInstance & {
tos_url: string;
}; };
const errors = ref<{ const errors = ref<{
@ -124,26 +126,40 @@ const registerUser = (e: Event) => {
e.preventDefault(); e.preventDefault();
const formData = new FormData(); const formData = new FormData();
formData.append("email", (e.target as any).email.value); const target = e.target as unknown as Record<string, HTMLInputElement>;
formData.append("password", (e.target as any).password.value);
formData.append("username", (e.target as any).username.value); formData.append("email", target.email.value);
formData.append("password", target.password.value);
formData.append("username", target.username.value);
formData.append("reason", reason.value); formData.append("reason", reason.value);
formData.append("locale", "en") formData.append("locale", "en");
formData.append("agreement", "true"); formData.append("agreement", "true");
// @ts-ignore // @ts-ignore
fetch("/api/v1/accounts", { fetch("/api/v1/accounts", {
method: "POST", method: "POST",
body: formData, body: formData,
}).then(async res => {
if (res.status === 422) {
errors.value = (await res.json() as any).details;
console.log(errors.value)
} else {
// @ts-ignore
window.location.href = "/register/success";
}
}).catch(async err => {
console.error(err);
}) })
} .then(async (res) => {
if (res.status === 422) {
errors.value = (
(await res.json()) as Record<
string,
{
[key: string]: {
error: string;
description: string;
}[];
}
>
).details;
console.log(errors.value);
} else {
// @ts-ignore
window.location.href = "/register/success";
}
})
.catch(async (err) => {
console.error(err);
});
};
</script> </script>

View file

@ -6,9 +6,9 @@ import registerIndexVue from "./pages/register/index.vue";
import successVue from "./pages/register/success.vue"; import successVue from "./pages/register/success.vue";
export default [ export default [
{ path: "/", component: indexVue }, { path: "/", component: indexVue },
{ path: "/oauth/authorize", component: authorizeVue }, { path: "/oauth/authorize", component: authorizeVue },
{ path: "/oauth/redirect", component: redirectVue }, { path: "/oauth/redirect", component: redirectVue },
{ path: "/register", component: registerIndexVue }, { path: "/register", component: registerIndexVue },
{ path: "/register/success", component: successVue }, { path: "/register/success", component: successVue },
] as RouteRecordRaw[]; ] as RouteRecordRaw[];

View file

@ -1,35 +1,35 @@
import { defineConfig } from "vite";
import UnoCSS from "unocss/vite";
import vue from "@vitejs/plugin-vue"; import vue from "@vitejs/plugin-vue";
import UnoCSS from "unocss/vite";
import { defineConfig } from "vite";
import pkg from "../package.json"; import pkg from "../package.json";
export default defineConfig({ export default defineConfig({
base: "/", base: "/",
build: { build: {
outDir: "./dist", outDir: "./dist",
}, },
// main.ts is in pages/ directory // main.ts is in pages/ directory
resolve: { resolve: {
alias: { alias: {
vue: "vue/dist/vue.esm-bundler", vue: "vue/dist/vue.esm-bundler",
}, },
}, },
server: { server: {
hmr: { hmr: {
clientPort: 5173, clientPort: 5173,
}, },
}, },
define: { define: {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
__VERSION__: JSON.stringify(pkg.version), __VERSION__: JSON.stringify(pkg.version),
}, },
ssr: { ssr: {
noExternal: ["@prisma/client"], noExternal: ["@prisma/client"],
}, },
plugins: [ plugins: [
UnoCSS({ UnoCSS({
mode: "global", mode: "global",
}), }),
vue(), vue(),
], ],
}); });

View file

@ -2,11 +2,11 @@ import type { Server } from "./types";
import { HookTypes } from "./types"; import { HookTypes } from "./types";
const registerPlugin = (server: Server) => { const registerPlugin = (server: Server) => {
server.on(HookTypes.OnPostCreate, (req, newPost, author) => { server.on(HookTypes.OnPostCreate, (req, newPost, author) => {
console.log("New post created!"); console.log("New post created!");
console.log(`Post details: ${newPost.content} (${newPost.id})`); console.log(`Post details: ${newPost.content} (${newPost.id})`);
console.log(`Made by ${author.username} (${author.id})`); console.log(`Made by ${author.username} (${author.id})`);
}); });
}; };
export default registerPlugin; export default registerPlugin;

View file

@ -4,152 +4,152 @@ import type { UserWithRelations } from "~database/entities/User";
import type { LysandObjectType } from "~types/lysand/Object"; import type { LysandObjectType } from "~types/lysand/Object";
export enum HookTypes { export enum HookTypes {
/** /**
* Called before the server starts listening * Called before the server starts listening
*/ */
PreServe = "preServe", PreServe = "preServe",
/** /**
* Called after the server stops listening * Called after the server stops listening
*/ */
PostServe = "postServe", PostServe = "postServe",
/** /**
* Called on every HTTP request (before anything else is done) * Called on every HTTP request (before anything else is done)
*/ */
OnRequestReceive = "onRequestReceive", OnRequestReceive = "onRequestReceive",
/** /**
* Called on every HTTP request (after it is processed) * Called on every HTTP request (after it is processed)
*/ */
OnRequestProcessed = "onRequestProcessed", OnRequestProcessed = "onRequestProcessed",
/** /**
* Called on every object received (before it is parsed and added to the database) * Called on every object received (before it is parsed and added to the database)
*/ */
OnObjectReceive = "onObjectReceive", OnObjectReceive = "onObjectReceive",
/** /**
* Called on every object processed (after it is parsed and added to the database) * Called on every object processed (after it is parsed and added to the database)
*/ */
OnObjectProcessed = "onObjectProcessed", OnObjectProcessed = "onObjectProcessed",
/** /**
* Called when signature verification fails on an object * Called when signature verification fails on an object
*/ */
OnCryptoFail = "onCryptoFail", OnCryptoFail = "onCryptoFail",
/** /**
* Called when signature verification succeeds on an object * Called when signature verification succeeds on an object
*/ */
OnCryptoSuccess = "onCryptoSuccess", OnCryptoSuccess = "onCryptoSuccess",
/** /**
* Called when a user is banned by another user * Called when a user is banned by another user
*/ */
OnBan = "onBan", OnBan = "onBan",
/** /**
* Called when a user is suspended by another user * Called when a user is suspended by another user
*/ */
OnSuspend = "onSuspend", OnSuspend = "onSuspend",
/** /**
* Called when a user is blocked by another user * Called when a user is blocked by another user
*/ */
OnUserBlock = "onUserBlock", OnUserBlock = "onUserBlock",
/** /**
* Called when a user is muted by another user * Called when a user is muted by another user
*/ */
OnUserMute = "onUserMute", OnUserMute = "onUserMute",
/** /**
* Called when a user is followed by another user * Called when a user is followed by another user
*/ */
OnUserFollow = "onUserFollow", OnUserFollow = "onUserFollow",
/** /**
* Called when a user registers (before completing email verification) * Called when a user registers (before completing email verification)
*/ */
OnRegister = "onRegister", OnRegister = "onRegister",
/** /**
* Called when a user finishes registering (after completing email verification) * Called when a user finishes registering (after completing email verification)
*/ */
OnRegisterFinish = "onRegisterFinish", OnRegisterFinish = "onRegisterFinish",
/** /**
* Called when a user deletes their account * Called when a user deletes their account
*/ */
OnDeleteAccount = "onDeleteAccount", OnDeleteAccount = "onDeleteAccount",
/** /**
* Called when a post is created * Called when a post is created
*/ */
OnPostCreate = "onPostCreate", OnPostCreate = "onPostCreate",
/** /**
* Called when a post is deleted * Called when a post is deleted
*/ */
OnPostDelete = "onPostDelete", OnPostDelete = "onPostDelete",
/** /**
* Called when a post is updated * Called when a post is updated
*/ */
OnPostUpdate = "onPostUpdate", OnPostUpdate = "onPostUpdate",
} }
export interface ServerStats { export interface ServerStats {
postCount: number; postCount: number;
} }
interface ServerEvents { interface ServerEvents {
[HookTypes.PreServe]: () => void; [HookTypes.PreServe]: () => void;
[HookTypes.PostServe]: (stats: ServerStats) => void; [HookTypes.PostServe]: (stats: ServerStats) => void;
[HookTypes.OnRequestReceive]: (req: Request) => void; [HookTypes.OnRequestReceive]: (req: Request) => void;
[HookTypes.OnRequestProcessed]: (req: Request) => void; [HookTypes.OnRequestProcessed]: (req: Request) => void;
[HookTypes.OnObjectReceive]: (obj: LysandObjectType) => void; [HookTypes.OnObjectReceive]: (obj: LysandObjectType) => void;
[HookTypes.OnObjectProcessed]: (obj: LysandObjectType) => void; [HookTypes.OnObjectProcessed]: (obj: LysandObjectType) => void;
[HookTypes.OnCryptoFail]: ( [HookTypes.OnCryptoFail]: (
req: Request, req: Request,
obj: LysandObjectType, obj: LysandObjectType,
author: UserWithRelations, author: UserWithRelations,
publicKey: string publicKey: string,
) => void; ) => void;
[HookTypes.OnCryptoSuccess]: ( [HookTypes.OnCryptoSuccess]: (
req: Request, req: Request,
obj: LysandObjectType, obj: LysandObjectType,
author: UserWithRelations, author: UserWithRelations,
publicKey: string publicKey: string,
) => void; ) => void;
[HookTypes.OnBan]: ( [HookTypes.OnBan]: (
req: Request, req: Request,
bannedUser: UserWithRelations, bannedUser: UserWithRelations,
banner: UserWithRelations banner: UserWithRelations,
) => void; ) => void;
[HookTypes.OnSuspend]: ( [HookTypes.OnSuspend]: (
req: Request, req: Request,
suspendedUser: UserWithRelations, suspendedUser: UserWithRelations,
suspender: UserWithRelations suspender: UserWithRelations,
) => void; ) => void;
[HookTypes.OnUserBlock]: ( [HookTypes.OnUserBlock]: (
req: Request, req: Request,
blockedUser: UserWithRelations, blockedUser: UserWithRelations,
blocker: UserWithRelations blocker: UserWithRelations,
) => void; ) => void;
[HookTypes.OnUserMute]: ( [HookTypes.OnUserMute]: (
req: Request, req: Request,
mutedUser: UserWithRelations, mutedUser: UserWithRelations,
muter: UserWithRelations muter: UserWithRelations,
) => void; ) => void;
[HookTypes.OnUserFollow]: ( [HookTypes.OnUserFollow]: (
req: Request, req: Request,
followedUser: UserWithRelations, followedUser: UserWithRelations,
follower: UserWithRelations follower: UserWithRelations,
) => void; ) => void;
[HookTypes.OnRegister]: (req: Request, newUser: UserWithRelations) => void; [HookTypes.OnRegister]: (req: Request, newUser: UserWithRelations) => void;
[HookTypes.OnDeleteAccount]: ( [HookTypes.OnDeleteAccount]: (
req: Request, req: Request,
deletedUser: UserWithRelations deletedUser: UserWithRelations,
) => void; ) => void;
[HookTypes.OnPostCreate]: ( [HookTypes.OnPostCreate]: (
req: Request, req: Request,
newPost: StatusWithRelations, newPost: StatusWithRelations,
author: UserWithRelations author: UserWithRelations,
) => void; ) => void;
[HookTypes.OnPostDelete]: ( [HookTypes.OnPostDelete]: (
req: Request, req: Request,
deletedPost: StatusWithRelations, deletedPost: StatusWithRelations,
deleter: UserWithRelations deleter: UserWithRelations,
) => void; ) => void;
[HookTypes.OnPostUpdate]: ( [HookTypes.OnPostUpdate]: (
req: Request, req: Request,
updatedPost: StatusWithRelations, updatedPost: StatusWithRelations,
updater: UserWithRelations updater: UserWithRelations,
) => void; ) => void;
} }
export class Server extends EventEmitter<ServerEvents> {} export class Server extends EventEmitter<ServerEvents> {}

View file

@ -3,7 +3,7 @@ import { config } from "config-manager";
// Proxies all `bunx prisma` commands with an environment variable // Proxies all `bunx prisma` commands with an environment variable
process.stdout.write( process.stdout.write(
`postgresql://${config.database.username}:${config.database.password}@${config.database.host}:${config.database.port}/${config.database.database}\n` `postgresql://${config.database.username}:${config.database.password}@${config.database.host}:${config.database.port}/${config.database.database}\n`,
); );
// Ends // Ends

188
routes.ts
View file

@ -5,106 +5,106 @@ import type { APIRouteMeta } from "./types/api";
// This is to allow for compilation of the routes, so that we can minify them and // This is to allow for compilation of the routes, so that we can minify them and
// node_modules in production // node_modules in production
export const rawRoutes = { export const rawRoutes = {
"/api/v1/accounts": "./server/api/api/v1/accounts", "/api/v1/accounts": "./server/api/api/v1/accounts",
"/api/v1/accounts/familiar_followers": "/api/v1/accounts/familiar_followers":
"+api/v1/accounts/familiar_followers/index", "+api/v1/accounts/familiar_followers/index",
"/api/v1/accounts/relationships": "/api/v1/accounts/relationships":
"./server/api/api/v1/accounts/relationships/index", "./server/api/api/v1/accounts/relationships/index",
"/api/v1/accounts/search": "./server/api/api/v1/accounts/search/index", "/api/v1/accounts/search": "./server/api/api/v1/accounts/search/index",
"/api/v1/accounts/update_credentials": "/api/v1/accounts/update_credentials":
"./server/api/api/v1/accounts/update_credentials/index", "./server/api/api/v1/accounts/update_credentials/index",
"/api/v1/accounts/verify_credentials": "/api/v1/accounts/verify_credentials":
"./server/api/api/v1/accounts/verify_credentials/index", "./server/api/api/v1/accounts/verify_credentials/index",
"/api/v1/apps": "./server/api/api/v1/apps/index", "/api/v1/apps": "./server/api/api/v1/apps/index",
"/api/v1/apps/verify_credentials": "/api/v1/apps/verify_credentials":
"./server/api/api/v1/apps/verify_credentials/index", "./server/api/api/v1/apps/verify_credentials/index",
"/api/v1/blocks": "./server/api/api/v1/blocks/index", "/api/v1/blocks": "./server/api/api/v1/blocks/index",
"/api/v1/custom_emojis": "./server/api/api/v1/custom_emojis/index", "/api/v1/custom_emojis": "./server/api/api/v1/custom_emojis/index",
"/api/v1/favourites": "./server/api/api/v1/favourites/index", "/api/v1/favourites": "./server/api/api/v1/favourites/index",
"/api/v1/follow_requests": "./server/api/api/v1/follow_requests/index", "/api/v1/follow_requests": "./server/api/api/v1/follow_requests/index",
"/api/v1/instance": "./server/api/api/v1/instance/index", "/api/v1/instance": "./server/api/api/v1/instance/index",
"/api/v1/media": "./server/api/api/v1/media/index", "/api/v1/media": "./server/api/api/v1/media/index",
"/api/v1/mutes": "./server/api/api/v1/mutes/index", "/api/v1/mutes": "./server/api/api/v1/mutes/index",
"/api/v1/notifications": "./server/api/api/v1/notifications/index", "/api/v1/notifications": "./server/api/api/v1/notifications/index",
"/api/v1/profile/avatar": "./server/api/api/v1/profile/avatar", "/api/v1/profile/avatar": "./server/api/api/v1/profile/avatar",
"/api/v1/profile/header": "./server/api/api/v1/profile/header", "/api/v1/profile/header": "./server/api/api/v1/profile/header",
"/api/v1/statuses": "./server/api/api/v1/statuses/index", "/api/v1/statuses": "./server/api/api/v1/statuses/index",
"/api/v1/timelines/home": "./server/api/api/v1/timelines/home", "/api/v1/timelines/home": "./server/api/api/v1/timelines/home",
"/api/v1/timelines/public": "./server/api/api/v1/timelines/public", "/api/v1/timelines/public": "./server/api/api/v1/timelines/public",
"/api/v2/media": "./server/api/api/v2/media/index", "/api/v2/media": "./server/api/api/v2/media/index",
"/api/v2/search": "./server/api/api/v2/search/index", "/api/v2/search": "./server/api/api/v2/search/index",
"/auth/login": "./server/api/auth/login/index", "/auth/login": "./server/api/auth/login/index",
"/auth/redirect": "./server/api/auth/redirect/index", "/auth/redirect": "./server/api/auth/redirect/index",
"/nodeinfo/2.0": "./server/api/nodeinfo/2.0/index", "/nodeinfo/2.0": "./server/api/nodeinfo/2.0/index",
"/oauth/authorize-external": "./server/api/oauth/authorize-external/index", "/oauth/authorize-external": "./server/api/oauth/authorize-external/index",
"/oauth/providers": "./server/api/oauth/providers/index", "/oauth/providers": "./server/api/oauth/providers/index",
"/oauth/token": "./server/api/oauth/token/index", "/oauth/token": "./server/api/oauth/token/index",
"/api/v1/accounts/[id]": "./server/api/api/v1/accounts/[id]/index", "/api/v1/accounts/[id]": "./server/api/api/v1/accounts/[id]/index",
"/api/v1/accounts/[id]/block": "./server/api/api/v1/accounts/[id]/block", "/api/v1/accounts/[id]/block": "./server/api/api/v1/accounts/[id]/block",
"/api/v1/accounts/[id]/follow": "./server/api/api/v1/accounts/[id]/follow", "/api/v1/accounts/[id]/follow": "./server/api/api/v1/accounts/[id]/follow",
"/api/v1/accounts/[id]/followers": "/api/v1/accounts/[id]/followers":
"./server/api/api/v1/accounts/[id]/followers", "./server/api/api/v1/accounts/[id]/followers",
"/api/v1/accounts/[id]/following": "/api/v1/accounts/[id]/following":
"./server/api/api/v1/accounts/[id]/following", "./server/api/api/v1/accounts/[id]/following",
"/api/v1/accounts/[id]/mute": "./server/api/api/v1/accounts/[id]/mute", "/api/v1/accounts/[id]/mute": "./server/api/api/v1/accounts/[id]/mute",
"/api/v1/accounts/[id]/note": "./server/api/api/v1/accounts/[id]/note", "/api/v1/accounts/[id]/note": "./server/api/api/v1/accounts/[id]/note",
"/api/v1/accounts/[id]/pin": "./server/api/api/v1/accounts/[id]/pin", "/api/v1/accounts/[id]/pin": "./server/api/api/v1/accounts/[id]/pin",
"/api/v1/accounts/[id]/remove_from_followers": "/api/v1/accounts/[id]/remove_from_followers":
"./server/api/api/v1/accounts/[id]/remove_from_followers", "./server/api/api/v1/accounts/[id]/remove_from_followers",
"/api/v1/accounts/[id]/statuses": "/api/v1/accounts/[id]/statuses":
"./server/api/api/v1/accounts/[id]/statuses", "./server/api/api/v1/accounts/[id]/statuses",
"/api/v1/accounts/[id]/unblock": "/api/v1/accounts/[id]/unblock":
"./server/api/api/v1/accounts/[id]/unblock", "./server/api/api/v1/accounts/[id]/unblock",
"/api/v1/accounts/[id]/unfollow": "/api/v1/accounts/[id]/unfollow":
"./server/api/api/v1/accounts/[id]/unfollow", "./server/api/api/v1/accounts/[id]/unfollow",
"/api/v1/accounts/[id]/unmute": "./server/api/api/v1/accounts/[id]/unmute", "/api/v1/accounts/[id]/unmute": "./server/api/api/v1/accounts/[id]/unmute",
"/api/v1/accounts/[id]/unpin": "./server/api/api/v1/accounts/[id]/unpin", "/api/v1/accounts/[id]/unpin": "./server/api/api/v1/accounts/[id]/unpin",
"/api/v1/follow_requests/[account_id]/authorize": "/api/v1/follow_requests/[account_id]/authorize":
"./server/api/api/v1/follow_requests/[account_id]/authorize", "./server/api/api/v1/follow_requests/[account_id]/authorize",
"/api/v1/follow_requests/[account_id]/reject": "/api/v1/follow_requests/[account_id]/reject":
"./server/api/api/v1/follow_requests/[account_id]/reject", "./server/api/api/v1/follow_requests/[account_id]/reject",
"/api/v1/media/[id]": "./server/api/api/v1/media/[id]/index", "/api/v1/media/[id]": "./server/api/api/v1/media/[id]/index",
"/api/v1/statuses/[id]": "./server/api/api/v1/statuses/[id]/index", "/api/v1/statuses/[id]": "./server/api/api/v1/statuses/[id]/index",
"/api/v1/statuses/[id]/context": "/api/v1/statuses/[id]/context":
"./server/api/api/v1/statuses/[id]/context", "./server/api/api/v1/statuses/[id]/context",
"/api/v1/statuses/[id]/favourite": "/api/v1/statuses/[id]/favourite":
"./server/api/api/v1/statuses/[id]/favourite", "./server/api/api/v1/statuses/[id]/favourite",
"/api/v1/statuses/[id]/favourited_by": "/api/v1/statuses/[id]/favourited_by":
"./server/api/api/v1/statuses/[id]/favourited_by", "./server/api/api/v1/statuses/[id]/favourited_by",
"/api/v1/statuses/[id]/pin": "./server/api/api/v1/statuses/[id]/pin", "/api/v1/statuses/[id]/pin": "./server/api/api/v1/statuses/[id]/pin",
"/api/v1/statuses/[id]/reblog": "./server/api/api/v1/statuses/[id]/reblog", "/api/v1/statuses/[id]/reblog": "./server/api/api/v1/statuses/[id]/reblog",
"/api/v1/statuses/[id]/reblogged_by": "/api/v1/statuses/[id]/reblogged_by":
"./server/api/api/v1/statuses/[id]/reblogged_by", "./server/api/api/v1/statuses/[id]/reblogged_by",
"/api/v1/statuses/[id]/source": "./server/api/api/v1/statuses/[id]/source", "/api/v1/statuses/[id]/source": "./server/api/api/v1/statuses/[id]/source",
"/api/v1/statuses/[id]/unfavourite": "/api/v1/statuses/[id]/unfavourite":
"./server/api/api/v1/statuses/[id]/unfavourite", "./server/api/api/v1/statuses/[id]/unfavourite",
"/api/v1/statuses/[id]/unpin": "./server/api/api/v1/statuses/[id]/unpin", "/api/v1/statuses/[id]/unpin": "./server/api/api/v1/statuses/[id]/unpin",
"/api/v1/statuses/[id]/unreblog": "/api/v1/statuses/[id]/unreblog":
"./server/api/api/v1/statuses/[id]/unreblog", "./server/api/api/v1/statuses/[id]/unreblog",
"/media/[id]": "./server/api/media/[id]/index", "/media/[id]": "./server/api/media/[id]/index",
"/oauth/callback/[issuer]": "./server/api/oauth/callback/[issuer]/index", "/oauth/callback/[issuer]": "./server/api/oauth/callback/[issuer]/index",
"/object/[uuid]": "./server/api/object/[uuid]/index", "/object/[uuid]": "./server/api/object/[uuid]/index",
"/users/[uuid]": "./server/api/users/[uuid]/index", "/users/[uuid]": "./server/api/users/[uuid]/index",
"/users/[uuid]/inbox": "./server/api/users/[uuid]/inbox/index", "/users/[uuid]/inbox": "./server/api/users/[uuid]/inbox/index",
"/users/[uuid]/outbox": "./server/api/users/[uuid]/outbox/index", "/users/[uuid]/outbox": "./server/api/users/[uuid]/outbox/index",
"/[...404]": "./server/api/[...404]", "/[...404]": "./server/api/[...404]",
} as Record<string, string>; } as Record<string, string>;
// Returns the route filesystem path when given a URL // Returns the route filesystem path when given a URL
export const routeMatcher = new Bun.FileSystemRouter({ export const routeMatcher = new Bun.FileSystemRouter({
style: "nextjs", style: "nextjs",
dir: process.cwd() + "/server/api", dir: `${process.cwd()}/server/api`,
}); });
export const matchRoute = async <T = Record<string, never>>(url: string) => { export const matchRoute = async <T = Record<string, never>>(url: string) => {
const route = routeMatcher.match(url); const route = routeMatcher.match(url);
if (!route) return { file: null, matchedRoute: null }; if (!route) return { file: null, matchedRoute: null };
return { return {
file: (await import(rawRoutes[route.name])) as { file: (await import(rawRoutes[route.name])) as {
default: RouteHandler<T>; default: RouteHandler<T>;
meta: APIRouteMeta; meta: APIRouteMeta;
}, },
matchedRoute: route, matchedRoute: route,
}; };
}; };

427
server.ts
View file

@ -1,251 +1,246 @@
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import type { Config } from "config-manager";
import { matches } from "ip-matching"; import { matches } from "ip-matching";
import { getFromRequest } from "~database/entities/User";
import { type Config } from "config-manager";
import type { LogManager, MultiLogManager } from "log-manager"; import type { LogManager, MultiLogManager } from "log-manager";
import { LogLevel } from "log-manager"; import { LogLevel } from "log-manager";
import { RequestParser } from "request-parser"; import { RequestParser } from "request-parser";
import { getFromRequest } from "~database/entities/User";
import { matchRoute } from "~routes"; import { matchRoute } from "~routes";
export const createServer = ( export const createServer = (
config: Config, config: Config,
logger: LogManager | MultiLogManager, logger: LogManager | MultiLogManager,
isProd: boolean isProd: boolean,
) => ) =>
Bun.serve({ Bun.serve({
port: config.http.bind_port, port: config.http.bind_port,
hostname: config.http.bind || "0.0.0.0", // defaults to "0.0.0.0" hostname: config.http.bind || "0.0.0.0", // defaults to "0.0.0.0"
async fetch(req) { async fetch(req) {
// Check for banned IPs // Check for banned IPs
const request_ip = this.requestIP(req)?.address ?? ""; const request_ip = this.requestIP(req)?.address ?? "";
for (const ip of config.http.banned_ips) { for (const ip of config.http.banned_ips) {
try { try {
if (matches(ip, request_ip)) { if (matches(ip, request_ip)) {
return new Response(undefined, { return new Response(undefined, {
status: 403, status: 403,
statusText: "Forbidden", statusText: "Forbidden",
}); });
} }
} catch (e) { } catch (e) {
console.error(`[-] Error while parsing banned IP "${ip}" `); console.error(`[-] Error while parsing banned IP "${ip}" `);
throw e; throw e;
} }
} }
// Check for banned user agents (regex) // Check for banned user agents (regex)
const ua = req.headers.get("User-Agent") ?? ""; const ua = req.headers.get("User-Agent") ?? "";
for (const agent of config.http.banned_user_agents) { for (const agent of config.http.banned_user_agents) {
if (new RegExp(agent).test(ua)) { if (new RegExp(agent).test(ua)) {
return new Response(undefined, { return new Response(undefined, {
status: 403, status: 403,
statusText: "Forbidden", statusText: "Forbidden",
}); });
} }
} }
if (config.http.bait.enabled) { if (config.http.bait.enabled) {
// Check for bait IPs // Check for bait IPs
for (const ip of config.http.bait.bait_ips) { for (const ip of config.http.bait.bait_ips) {
try { try {
if (matches(ip, request_ip)) { if (matches(ip, request_ip)) {
const file = Bun.file( const file = Bun.file(
config.http.bait.send_file || config.http.bait.send_file ||
"./pages/beemovie.txt" "./pages/beemovie.txt",
); );
if (await file.exists()) { if (await file.exists()) {
return new Response(file); return new Response(file);
} else { }
await logger.log( await logger.log(
LogLevel.ERROR, LogLevel.ERROR,
"Server.Bait", "Server.Bait",
`Bait file not found: ${config.http.bait.send_file}` `Bait file not found: ${config.http.bait.send_file}`,
); );
} }
} } catch (e) {
} catch (e) { console.error(
console.error( `[-] Error while parsing bait IP "${ip}" `,
`[-] Error while parsing bait IP "${ip}" ` );
); throw e;
throw e; }
} }
}
// Check for bait user agents (regex) // Check for bait user agents (regex)
for (const agent of config.http.bait.bait_user_agents) { for (const agent of config.http.bait.bait_user_agents) {
console.log(agent); console.log(agent);
if (new RegExp(agent).test(ua)) { if (new RegExp(agent).test(ua)) {
const file = Bun.file( const file = Bun.file(
config.http.bait.send_file || "./pages/beemovie.txt" config.http.bait.send_file ||
); "./pages/beemovie.txt",
);
if (await file.exists()) { if (await file.exists()) {
return new Response(file); return new Response(file);
} else { }
await logger.log( await logger.log(
LogLevel.ERROR, LogLevel.ERROR,
"Server.Bait", "Server.Bait",
`Bait file not found: ${config.http.bait.send_file}` `Bait file not found: ${config.http.bait.send_file}`,
); );
} }
} }
} }
}
if (config.logging.log_requests) { if (config.logging.log_requests) {
await logger.logRequest( await logger.logRequest(
req, req,
config.logging.log_ip ? request_ip : undefined, config.logging.log_ip ? request_ip : undefined,
config.logging.log_requests_verbose config.logging.log_requests_verbose,
); );
} }
if (req.method === "OPTIONS") { if (req.method === "OPTIONS") {
return jsonResponse({}); return jsonResponse({});
} }
const { file: filePromise, matchedRoute } = await matchRoute( const { file: filePromise, matchedRoute } = await matchRoute(
req.url req.url,
); );
const file = filePromise; const file = filePromise;
if (matchedRoute && file == undefined) { if (matchedRoute && file === undefined) {
await logger.log( await logger.log(
LogLevel.ERROR, LogLevel.ERROR,
"Server", "Server",
`Route file ${matchedRoute.filePath} not found or not registered in the routes file` `Route file ${matchedRoute.filePath} not found or not registered in the routes file`,
); );
return errorResponse("Route not found", 500); return errorResponse("Route not found", 500);
} }
if ( if (matchedRoute && matchedRoute.name !== "/[...404]" && file) {
matchedRoute && const meta = file.meta;
matchedRoute.name !== "/[...404]" &&
file != undefined
) {
const meta = file.meta;
// Check for allowed requests // Check for allowed requests
if (!meta.allowedMethods.includes(req.method as any)) { // @ts-expect-error Stupid error
return new Response(undefined, { if (!meta.allowedMethods.includes(req.method as string)) {
status: 405, return new Response(undefined, {
statusText: `Method not allowed: allowed methods are: ${meta.allowedMethods.join( status: 405,
", " statusText: `Method not allowed: allowed methods are: ${meta.allowedMethods.join(
)}`, ", ",
}); )}`,
} });
}
// TODO: Check for ratelimits // TODO: Check for ratelimits
const auth = await getFromRequest(req); const auth = await getFromRequest(req);
// Check for authentication if required // Check for authentication if required
if (meta.auth.required) { if (meta.auth.required) {
if (!auth.user) { if (!auth.user) {
return new Response(undefined, { return new Response(undefined, {
status: 401, status: 401,
statusText: "Unauthorized", statusText: "Unauthorized",
}); });
} }
} else if ( } else if (
(meta.auth.requiredOnMethods ?? []).includes( // @ts-expect-error Stupid error
req.method as any (meta.auth.requiredOnMethods ?? []).includes(req.method)
) ) {
) { if (!auth.user) {
if (!auth.user) { return new Response(undefined, {
return new Response(undefined, { status: 401,
status: 401, statusText: "Unauthorized",
statusText: "Unauthorized", });
}); }
} }
}
let parsedRequest = {}; let parsedRequest = {};
try { try {
parsedRequest = await new RequestParser(req).toObject(); parsedRequest = await new RequestParser(req).toObject();
} catch (e) { } catch (e) {
await logger.logError( await logger.logError(
LogLevel.ERROR, LogLevel.ERROR,
"Server.RouteRequestParser", "Server.RouteRequestParser",
e as Error e as Error,
); );
return new Response(undefined, { return new Response(undefined, {
status: 400, status: 400,
statusText: "Bad request", statusText: "Bad request",
}); });
} }
return await file.default(req.clone(), matchedRoute, { return await file.default(req.clone(), matchedRoute, {
auth, auth,
parsedRequest, parsedRequest,
// To avoid having to rewrite each route // To avoid having to rewrite each route
configManager: { configManager: {
getConfig: () => Promise.resolve(config), getConfig: () => Promise.resolve(config),
}, },
}); });
} else if (matchedRoute?.name === "/[...404]" || !matchedRoute) { }
if (new URL(req.url).pathname.startsWith("/api")) { if (matchedRoute?.name === "/[...404]" || !matchedRoute) {
return errorResponse("Route not found", 404); if (new URL(req.url).pathname.startsWith("/api")) {
} return errorResponse("Route not found", 404);
}
// Proxy response from Vite at localhost:5173 if in development mode // Proxy response from Vite at localhost:5173 if in development mode
if (isProd) { if (isProd) {
if (new URL(req.url).pathname.startsWith("/assets")) { if (new URL(req.url).pathname.startsWith("/assets")) {
const file = Bun.file( const file = Bun.file(
`./pages/dist${new URL(req.url).pathname}` `./pages/dist${new URL(req.url).pathname}`,
); );
// Serve from pages/dist/assets // Serve from pages/dist/assets
if (await file.exists()) { if (await file.exists()) {
return new Response(file); return new Response(file);
} else return errorResponse("Asset not found", 404); }
} return errorResponse("Asset not found", 404);
if (new URL(req.url).pathname.startsWith("/api")) { }
return errorResponse("Route not found", 404); if (new URL(req.url).pathname.startsWith("/api")) {
} return errorResponse("Route not found", 404);
}
const file = Bun.file(`./pages/dist/index.html`); const file = Bun.file("./pages/dist/index.html");
// Serve from pages/dist // Serve from pages/dist
return new Response(file); return new Response(file);
} else { }
const proxy = await fetch( const proxy = await fetch(
req.url.replace( req.url.replace(
config.http.base_url, config.http.base_url,
"http://localhost:5173" "http://localhost:5173",
) ),
).catch(async e => { ).catch(async (e) => {
await logger.logError( await logger.logError(
LogLevel.ERROR, LogLevel.ERROR,
"Server.Proxy", "Server.Proxy",
e as Error e as Error,
); );
await logger.log( await logger.log(
LogLevel.ERROR, LogLevel.ERROR,
"Server.Proxy", "Server.Proxy",
`The development Vite server is not running or the route is not found: ${req.url.replace( `The development Vite server is not running or the route is not found: ${req.url.replace(
config.http.base_url, config.http.base_url,
"http://localhost:5173" "http://localhost:5173",
)}` )}`,
); );
return errorResponse("Route not found", 404); return errorResponse("Route not found", 404);
}); });
if ( if (
proxy.status !== 404 && proxy.status !== 404 &&
!(await proxy.clone().text()).includes("404 Not Found") !(await proxy.clone().text()).includes("404 Not Found")
) { ) {
return proxy; return proxy;
} }
return errorResponse("Route not found", 404); return errorResponse("Route not found", 404);
} }
} else { return errorResponse("Route not found", 404);
return errorResponse("Route not found", 404); },
} });
},
});

View file

@ -1,23 +1,22 @@
import { xmlResponse } from "@response";
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { xmlResponse } from "@response";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
auth: { auth: {
required: false, required: false,
}, },
ratelimits: { ratelimits: {
duration: 60, duration: 60,
max: 60, max: 60,
}, },
route: "/.well-known/host-meta", route: "/.well-known/host-meta",
}); });
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const config = await extraData.configManager.getConfig(); const config = await extraData.configManager.getConfig();
return xmlResponse(` return xmlResponse(`
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0"> <XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
<Link rel="lrdd" template="${config.http.base_url}/.well-known/webfinger?resource={uri}"/> <Link rel="lrdd" template="${config.http.base_url}/.well-known/webfinger?resource={uri}"/>

View file

@ -1,43 +1,49 @@
import { jsonResponse } from "@response";
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { jsonResponse } from "@response";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
auth: { auth: {
required: false, required: false,
}, },
ratelimits: { ratelimits: {
duration: 60, duration: 60,
max: 60, max: 60,
}, },
route: "/.well-known/lysand", route: "/.well-known/lysand",
}); });
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const config = await extraData.configManager.getConfig(); const config = await extraData.configManager.getConfig();
// In the format acct:name@example.com // In the format acct:name@example.com
return jsonResponse({ return jsonResponse({
type: "ServerMetadata", type: "ServerMetadata",
name: config.instance.name, name: config.instance.name,
version: "0.0.1", version: "0.0.1",
description: config.instance.description, description: config.instance.description,
logo: config.instance.logo ? [ logo: config.instance.logo
{ ? [
content: config.instance.logo, {
content_type: `image/${config.instance.logo.split(".")[1]}`, content: config.instance.logo,
} content_type: `image/${
] : undefined, config.instance.logo.split(".")[1]
banner: config.instance.banner ? [ }`,
{ },
content: config.instance.banner, ]
content_type: `image/${config.instance.banner.split(".")[1]}`, : undefined,
} banner: config.instance.banner
] : undefined, ? [
supported_extensions: [ {
"org.lysand:custom_emojis" content: config.instance.banner,
], content_type: `image/${
config.instance.banner.split(".")[1]
}`,
},
]
: undefined,
supported_extensions: ["org.lysand:custom_emojis"],
website: "https://lysand.org", website: "https://lysand.org",
// TODO: Add admins, moderators field // TODO: Add admins, moderators field
}) });
}) });

View file

@ -1,25 +1,24 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
auth: { auth: {
required: false, required: false,
}, },
ratelimits: { ratelimits: {
duration: 60, duration: 60,
max: 60, max: 60,
}, },
route: "/.well-known/nodeinfo", route: "/.well-known/nodeinfo",
}); });
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const config = await extraData.configManager.getConfig(); const config = await extraData.configManager.getConfig();
return new Response("", { return new Response("", {
status: 301, status: 301,
headers: { headers: {
Location: `${config.http.base_url}/.well-known/nodeinfo/2.0`, Location: `${config.http.base_url}/.well-known/nodeinfo/2.0`,
}, },
}); });
}); });

View file

@ -1,59 +1,59 @@
import { errorResponse, jsonResponse } from "@response";
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
auth: { auth: {
required: false, required: false,
}, },
ratelimits: { ratelimits: {
duration: 60, duration: 60,
max: 60, max: 60,
}, },
route: "/.well-known/webfinger", route: "/.well-known/webfinger",
}); });
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
// In the format acct:name@example.com // In the format acct:name@example.com
const resource = matchedRoute.query.resource; const resource = matchedRoute.query.resource;
const requestedUser = resource.split("acct:")[1]; const requestedUser = resource.split("acct:")[1];
const config = await extraData.configManager.getConfig(); const config = await extraData.configManager.getConfig();
const host = new URL(config.http.base_url).hostname; const host = new URL(config.http.base_url).hostname;
// Check if user is a local user // Check if user is a local user
if (requestedUser.split("@")[1] !== host) { if (requestedUser.split("@")[1] !== host) {
return errorResponse("User is a remote user", 404); return errorResponse("User is a remote user", 404);
} }
const user = await client.user.findUnique({ const user = await client.user.findUnique({
where: { username: requestedUser.split("@")[0] }, where: { username: requestedUser.split("@")[0] },
}); });
if (!user) { if (!user) {
return errorResponse("User not found", 404); return errorResponse("User not found", 404);
} }
return jsonResponse({ return jsonResponse({
subject: `acct:${user.username}@${host}`, subject: `acct:${user.username}@${host}`,
links: [ links: [
{ {
rel: "self", rel: "self",
type: "application/activity+json", type: "application/activity+json",
href: `${config.http.base_url}/users/${user.username}/actor` href: `${config.http.base_url}/users/${user.username}/actor`,
}, },
{ {
rel: "https://webfinger.net/rel/profile-page", rel: "https://webfinger.net/rel/profile-page",
type: "text/html", type: "text/html",
href: `${config.http.base_url}/users/${user.username}` href: `${config.http.base_url}/users/${user.username}`,
}, },
{ {
rel: "self", rel: "self",
type: "application/activity+json; profile=\"https://www.w3.org/ns/activitystreams\"", type: 'application/activity+json; profile="https://www.w3.org/ns/activitystreams"',
href: `${config.http.base_url}/users/${user.username}/actor` href: `${config.http.base_url}/users/${user.username}/actor`,
} },
] ],
}) });
}); });

View file

@ -2,20 +2,20 @@ import { apiRoute, applyConfig } from "@api";
import { errorResponse } from "@response"; import { errorResponse } from "@response";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST", "GET", "PUT", "PATCH", "DELETE"], allowedMethods: ["POST", "GET", "PUT", "PATCH", "DELETE"],
auth: { auth: {
required: false, required: false,
}, },
ratelimits: { ratelimits: {
duration: 60, duration: 60,
max: 100, max: 100,
}, },
route: "/[...404]", route: "/[...404]",
}); });
/** /**
* Default catch-all route, returns a 404 error. * Default catch-all route, returns a 404 error.
*/ */
export default apiRoute(() => { export default apiRoute(() => {
return errorResponse("This API route does not exist", 404); return errorResponse("This API route does not exist", 404);
}); });

View file

@ -1,81 +1,81 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import { import {
createNewRelationship, createNewRelationship,
relationshipToAPI, relationshipToAPI,
} from "~database/entities/Relationship"; } from "~database/entities/Relationship";
import { getRelationshipToOtherUser } from "~database/entities/User"; import { getRelationshipToOtherUser } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
ratelimits: { ratelimits: {
max: 30, max: 30,
duration: 60, duration: 60,
}, },
route: "/accounts/:id/block", route: "/accounts/:id/block",
auth: { auth: {
required: true, required: true,
oauthPermissions: ["write:blocks"], oauthPermissions: ["write:blocks"],
}, },
}); });
/** /**
* Blocks a user * Blocks a user
*/ */
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
const { user: self } = extraData.auth; const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
const user = await client.user.findUnique({ const user = await client.user.findUnique({
where: { id }, where: { id },
include: { include: {
relationships: { relationships: {
include: { include: {
owner: true, owner: true,
subject: true, subject: true,
}, },
}, },
}, },
}); });
if (!user) return errorResponse("User not found", 404); if (!user) return errorResponse("User not found", 404);
// Check if already following // Check if already following
let relationship = await getRelationshipToOtherUser(self, user); let relationship = await getRelationshipToOtherUser(self, user);
if (!relationship) { if (!relationship) {
// Create new relationship // Create new relationship
const newRelationship = await createNewRelationship(self, user); const newRelationship = await createNewRelationship(self, user);
await client.user.update({ await client.user.update({
where: { id: self.id }, where: { id: self.id },
data: { data: {
relationships: { relationships: {
connect: { connect: {
id: newRelationship.id, id: newRelationship.id,
}, },
}, },
}, },
}); });
relationship = newRelationship; relationship = newRelationship;
} }
if (!relationship.blocking) { if (!relationship.blocking) {
relationship.blocking = true; relationship.blocking = true;
} }
await client.relationship.update({ await client.relationship.update({
where: { id: relationship.id }, where: { id: relationship.id },
data: { data: {
blocking: true, blocking: true,
}, },
}); });
return jsonResponse(relationshipToAPI(relationship)); return jsonResponse(relationshipToAPI(relationship));
}); });

View file

@ -1,99 +1,99 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import { import {
createNewRelationship, createNewRelationship,
relationshipToAPI, relationshipToAPI,
} from "~database/entities/Relationship"; } from "~database/entities/Relationship";
import { getRelationshipToOtherUser } from "~database/entities/User"; import { getRelationshipToOtherUser } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
ratelimits: { ratelimits: {
max: 30, max: 30,
duration: 60, duration: 60,
}, },
route: "/accounts/:id/follow", route: "/accounts/:id/follow",
auth: { auth: {
required: true, required: true,
oauthPermissions: ["write:follows"], oauthPermissions: ["write:follows"],
}, },
}); });
/** /**
* Follow a user * Follow a user
*/ */
export default apiRoute<{ export default apiRoute<{
reblogs?: boolean; reblogs?: boolean;
notify?: boolean; notify?: boolean;
languages?: string[]; languages?: string[];
}>(async (req, matchedRoute, extraData) => { }>(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
const { user: self } = extraData.auth; const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
const { languages, notify, reblogs } = extraData.parsedRequest; const { languages, notify, reblogs } = extraData.parsedRequest;
const user = await client.user.findUnique({ const user = await client.user.findUnique({
where: { id }, where: { id },
include: { include: {
relationships: { relationships: {
include: { include: {
owner: true, owner: true,
subject: true, subject: true,
}, },
}, },
}, },
}); });
if (!user) return errorResponse("User not found", 404); if (!user) return errorResponse("User not found", 404);
// Check if already following // Check if already following
let relationship = await getRelationshipToOtherUser(self, user); let relationship = await getRelationshipToOtherUser(self, user);
if (!relationship) { if (!relationship) {
// Create new relationship // Create new relationship
const newRelationship = await createNewRelationship(self, user); const newRelationship = await createNewRelationship(self, user);
await client.user.update({ await client.user.update({
where: { id: self.id }, where: { id: self.id },
data: { data: {
relationships: { relationships: {
connect: { connect: {
id: newRelationship.id, id: newRelationship.id,
}, },
}, },
}, },
}); });
relationship = newRelationship; relationship = newRelationship;
} }
if (!relationship.following) { if (!relationship.following) {
relationship.following = true; relationship.following = true;
} }
if (reblogs) { if (reblogs) {
relationship.showingReblogs = true; relationship.showingReblogs = true;
} }
if (notify) { if (notify) {
relationship.notifying = true; relationship.notifying = true;
} }
if (languages) { if (languages) {
relationship.languages = languages; relationship.languages = languages;
} }
await client.relationship.update({ await client.relationship.update({
where: { id: relationship.id }, where: { id: relationship.id },
data: { data: {
following: true, following: true,
showingReblogs: reblogs ?? false, showingReblogs: reblogs ?? false,
notifying: notify ?? false, notifying: notify ?? false,
languages: languages ?? [], languages: languages ?? [],
}, },
}); });
return jsonResponse(relationshipToAPI(relationship)); return jsonResponse(relationshipToAPI(relationship));
}); });

View file

@ -1,82 +1,82 @@
import { apiRoute, applyConfig } from "@api";
/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { userToAPI } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { userToAPI } from "~database/entities/User";
import { userRelations } from "~database/entities/relations"; import { userRelations } from "~database/entities/relations";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
ratelimits: { ratelimits: {
max: 60, max: 60,
duration: 60, duration: 60,
}, },
route: "/accounts/:id/followers", route: "/accounts/:id/followers",
auth: { auth: {
required: false, required: false,
oauthPermissions: [], oauthPermissions: [],
}, },
}); });
/** /**
* Fetch all statuses for a user * Fetch all statuses for a user
*/ */
export default apiRoute<{ export default apiRoute<{
max_id?: string; max_id?: string;
since_id?: string; since_id?: string;
min_id?: string; min_id?: string;
limit?: number; limit?: number;
}>(async (req, matchedRoute, extraData) => { }>(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
// TODO: Add pinned // TODO: Add pinned
const { max_id, min_id, since_id, limit = 20 } = extraData.parsedRequest; const { max_id, min_id, since_id, limit = 20 } = extraData.parsedRequest;
const user = await client.user.findUnique({ const user = await client.user.findUnique({
where: { id }, where: { id },
include: userRelations, include: userRelations,
}); });
if (limit < 1 || limit > 40) return errorResponse("Invalid limit", 400); if (limit < 1 || limit > 40) return errorResponse("Invalid limit", 400);
if (!user) return errorResponse("User not found", 404); if (!user) return errorResponse("User not found", 404);
const objects = await client.user.findMany({ const objects = await client.user.findMany({
where: { where: {
relationships: { relationships: {
some: { some: {
subjectId: user.id, subjectId: user.id,
following: true, following: true,
}, },
}, },
id: { id: {
lt: max_id, lt: max_id,
gt: min_id, gt: min_id,
gte: since_id, gte: since_id,
}, },
}, },
include: userRelations, include: userRelations,
take: Number(limit), take: Number(limit),
orderBy: { orderBy: {
id: "desc", id: "desc",
}, },
}); });
// Constuct HTTP Link header (next and prev) // Constuct HTTP Link header (next and prev)
const linkHeader = []; const linkHeader = [];
if (objects.length > 0) { if (objects.length > 0) {
const urlWithoutQuery = req.url.split("?")[0]; const urlWithoutQuery = req.url.split("?")[0];
linkHeader.push( linkHeader.push(
`<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`, `<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`,
`<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"` `<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`,
); );
} }
return jsonResponse( return jsonResponse(
await Promise.all(objects.map(object => userToAPI(object))), await Promise.all(objects.map((object) => userToAPI(object))),
200, 200,
{ {
Link: linkHeader.join(", "), Link: linkHeader.join(", "),
} },
); );
}); });

View file

@ -1,82 +1,82 @@
import { apiRoute, applyConfig } from "@api";
/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { userToAPI } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { userToAPI } from "~database/entities/User";
import { userRelations } from "~database/entities/relations"; import { userRelations } from "~database/entities/relations";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
ratelimits: { ratelimits: {
max: 60, max: 60,
duration: 60, duration: 60,
}, },
route: "/accounts/:id/following", route: "/accounts/:id/following",
auth: { auth: {
required: false, required: false,
oauthPermissions: [], oauthPermissions: [],
}, },
}); });
/** /**
* Fetch all statuses for a user * Fetch all statuses for a user
*/ */
export default apiRoute<{ export default apiRoute<{
max_id?: string; max_id?: string;
since_id?: string; since_id?: string;
min_id?: string; min_id?: string;
limit?: number; limit?: number;
}>(async (req, matchedRoute, extraData) => { }>(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
// TODO: Add pinned // TODO: Add pinned
const { max_id, min_id, since_id, limit = 20 } = extraData.parsedRequest; const { max_id, min_id, since_id, limit = 20 } = extraData.parsedRequest;
const user = await client.user.findUnique({ const user = await client.user.findUnique({
where: { id }, where: { id },
include: userRelations, include: userRelations,
}); });
if (limit < 1 || limit > 40) return errorResponse("Invalid limit", 400); if (limit < 1 || limit > 40) return errorResponse("Invalid limit", 400);
if (!user) return errorResponse("User not found", 404); if (!user) return errorResponse("User not found", 404);
const objects = await client.user.findMany({ const objects = await client.user.findMany({
where: { where: {
relationshipSubjects: { relationshipSubjects: {
some: { some: {
ownerId: user.id, ownerId: user.id,
following: true, following: true,
}, },
}, },
id: { id: {
lt: max_id, lt: max_id,
gt: min_id, gt: min_id,
gte: since_id, gte: since_id,
}, },
}, },
include: userRelations, include: userRelations,
take: Number(limit), take: Number(limit),
orderBy: { orderBy: {
id: "desc", id: "desc",
}, },
}); });
// Constuct HTTP Link header (next and prev) // Constuct HTTP Link header (next and prev)
const linkHeader = []; const linkHeader = [];
if (objects.length > 0) { if (objects.length > 0) {
const urlWithoutQuery = req.url.split("?")[0]; const urlWithoutQuery = req.url.split("?")[0];
linkHeader.push( linkHeader.push(
`<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`, `<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`,
`<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"` `<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`,
); );
} }
return jsonResponse( return jsonResponse(
await Promise.all(objects.map(object => userToAPI(object))), await Promise.all(objects.map((object) => userToAPI(object))),
200, 200,
{ {
Link: linkHeader.join(", "), Link: linkHeader.join(", "),
} },
); );
}); });

View file

@ -1,46 +1,46 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import type { UserWithRelations } from "~database/entities/User"; import type { UserWithRelations } from "~database/entities/User";
import { userToAPI } from "~database/entities/User"; import { userToAPI } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
import { userRelations } from "~database/entities/relations"; import { userRelations } from "~database/entities/relations";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
ratelimits: { ratelimits: {
max: 30, max: 30,
duration: 60, duration: 60,
}, },
route: "/accounts/:id", route: "/accounts/:id",
auth: { auth: {
required: true, required: true,
oauthPermissions: [], oauthPermissions: [],
}, },
}); });
/** /**
* Fetch a user * Fetch a user
*/ */
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
// Check if ID is valid UUID // Check if ID is valid UUID
if (!id.match(/^[0-9a-fA-F]{24}$/)) { if (!id.match(/^[0-9a-fA-F]{24}$/)) {
return errorResponse("Invalid ID", 404); return errorResponse("Invalid ID", 404);
} }
const { user } = extraData.auth; const { user } = extraData.auth;
let foundUser: UserWithRelations | null; let foundUser: UserWithRelations | null;
try { try {
foundUser = await client.user.findUnique({ foundUser = await client.user.findUnique({
where: { id }, where: { id },
include: userRelations, include: userRelations,
}); });
} catch (e) { } catch (e) {
return errorResponse("Invalid ID", 404); return errorResponse("Invalid ID", 404);
} }
if (!foundUser) return errorResponse("User not found", 404); if (!foundUser) return errorResponse("User not found", 404);
return jsonResponse(userToAPI(foundUser, user?.id === foundUser.id)); return jsonResponse(userToAPI(foundUser, user?.id === foundUser.id));
}); });

View file

@ -1,93 +1,93 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import { import {
createNewRelationship, createNewRelationship,
relationshipToAPI, relationshipToAPI,
} from "~database/entities/Relationship"; } from "~database/entities/Relationship";
import { getRelationshipToOtherUser } from "~database/entities/User"; import { getRelationshipToOtherUser } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
ratelimits: { ratelimits: {
max: 30, max: 30,
duration: 60, duration: 60,
}, },
route: "/accounts/:id/mute", route: "/accounts/:id/mute",
auth: { auth: {
required: true, required: true,
oauthPermissions: ["write:mutes"], oauthPermissions: ["write:mutes"],
}, },
}); });
/** /**
* Mute a user * Mute a user
*/ */
export default apiRoute<{ export default apiRoute<{
notifications: boolean; notifications: boolean;
duration: number; duration: number;
}>(async (req, matchedRoute, extraData) => { }>(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
const { user: self } = extraData.auth; const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { notifications, duration } = extraData.parsedRequest; const { notifications, duration } = extraData.parsedRequest;
const user = await client.user.findUnique({ const user = await client.user.findUnique({
where: { id }, where: { id },
include: { include: {
relationships: { relationships: {
include: { include: {
owner: true, owner: true,
subject: true, subject: true,
}, },
}, },
}, },
}); });
if (!user) return errorResponse("User not found", 404); if (!user) return errorResponse("User not found", 404);
// Check if already following // Check if already following
let relationship = await getRelationshipToOtherUser(self, user); let relationship = await getRelationshipToOtherUser(self, user);
if (!relationship) { if (!relationship) {
// Create new relationship // Create new relationship
const newRelationship = await createNewRelationship(self, user); const newRelationship = await createNewRelationship(self, user);
await client.user.update({ await client.user.update({
where: { id: self.id }, where: { id: self.id },
data: { data: {
relationships: { relationships: {
connect: { connect: {
id: newRelationship.id, id: newRelationship.id,
}, },
}, },
}, },
}); });
relationship = newRelationship; relationship = newRelationship;
} }
if (!relationship.muting) { if (!relationship.muting) {
relationship.muting = true; relationship.muting = true;
} }
if (notifications ?? true) { if (notifications ?? true) {
relationship.mutingNotifications = true; relationship.mutingNotifications = true;
} }
await client.relationship.update({ await client.relationship.update({
where: { id: relationship.id }, where: { id: relationship.id },
data: { data: {
muting: true, muting: true,
mutingNotifications: notifications ?? true, mutingNotifications: notifications ?? true,
}, },
}); });
// TODO: Implement duration // TODO: Implement duration
return jsonResponse(relationshipToAPI(relationship)); return jsonResponse(relationshipToAPI(relationship));
}); });

View file

@ -1,83 +1,83 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import { import {
createNewRelationship, createNewRelationship,
relationshipToAPI, relationshipToAPI,
} from "~database/entities/Relationship"; } from "~database/entities/Relationship";
import { getRelationshipToOtherUser } from "~database/entities/User"; import { getRelationshipToOtherUser } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
ratelimits: { ratelimits: {
max: 30, max: 30,
duration: 60, duration: 60,
}, },
route: "/accounts/:id/note", route: "/accounts/:id/note",
auth: { auth: {
required: true, required: true,
oauthPermissions: ["write:accounts"], oauthPermissions: ["write:accounts"],
}, },
}); });
/** /**
* Sets a user note * Sets a user note
*/ */
export default apiRoute<{ export default apiRoute<{
comment: string; comment: string;
}>(async (req, matchedRoute, extraData) => { }>(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
const { user: self } = extraData.auth; const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
const { comment } = extraData.parsedRequest; const { comment } = extraData.parsedRequest;
const user = await client.user.findUnique({ const user = await client.user.findUnique({
where: { id }, where: { id },
include: { include: {
relationships: { relationships: {
include: { include: {
owner: true, owner: true,
subject: true, subject: true,
}, },
}, },
}, },
}); });
if (!user) return errorResponse("User not found", 404); if (!user) return errorResponse("User not found", 404);
// Check if already following // Check if already following
let relationship = await getRelationshipToOtherUser(self, user); let relationship = await getRelationshipToOtherUser(self, user);
if (!relationship) { if (!relationship) {
// Create new relationship // Create new relationship
const newRelationship = await createNewRelationship(self, user); const newRelationship = await createNewRelationship(self, user);
await client.user.update({ await client.user.update({
where: { id: self.id }, where: { id: self.id },
data: { data: {
relationships: { relationships: {
connect: { connect: {
id: newRelationship.id, id: newRelationship.id,
}, },
}, },
}, },
}); });
relationship = newRelationship; relationship = newRelationship;
} }
relationship.note = comment ?? ""; relationship.note = comment ?? "";
await client.relationship.update({ await client.relationship.update({
where: { id: relationship.id }, where: { id: relationship.id },
data: { data: {
note: relationship.note, note: relationship.note,
}, },
}); });
return jsonResponse(relationshipToAPI(relationship)); return jsonResponse(relationshipToAPI(relationship));
}); });

View file

@ -1,81 +1,81 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import { import {
createNewRelationship, createNewRelationship,
relationshipToAPI, relationshipToAPI,
} from "~database/entities/Relationship"; } from "~database/entities/Relationship";
import { getRelationshipToOtherUser } from "~database/entities/User"; import { getRelationshipToOtherUser } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
ratelimits: { ratelimits: {
max: 30, max: 30,
duration: 60, duration: 60,
}, },
route: "/accounts/:id/pin", route: "/accounts/:id/pin",
auth: { auth: {
required: true, required: true,
oauthPermissions: ["write:accounts"], oauthPermissions: ["write:accounts"],
}, },
}); });
/** /**
* Pin a user * Pin a user
*/ */
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
const { user: self } = extraData.auth; const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
const user = await client.user.findUnique({ const user = await client.user.findUnique({
where: { id }, where: { id },
include: { include: {
relationships: { relationships: {
include: { include: {
owner: true, owner: true,
subject: true, subject: true,
}, },
}, },
}, },
}); });
if (!user) return errorResponse("User not found", 404); if (!user) return errorResponse("User not found", 404);
// Check if already following // Check if already following
let relationship = await getRelationshipToOtherUser(self, user); let relationship = await getRelationshipToOtherUser(self, user);
if (!relationship) { if (!relationship) {
// Create new relationship // Create new relationship
const newRelationship = await createNewRelationship(self, user); const newRelationship = await createNewRelationship(self, user);
await client.user.update({ await client.user.update({
where: { id: self.id }, where: { id: self.id },
data: { data: {
relationships: { relationships: {
connect: { connect: {
id: newRelationship.id, id: newRelationship.id,
}, },
}, },
}, },
}); });
relationship = newRelationship; relationship = newRelationship;
} }
if (!relationship.endorsed) { if (!relationship.endorsed) {
relationship.endorsed = true; relationship.endorsed = true;
} }
await client.relationship.update({ await client.relationship.update({
where: { id: relationship.id }, where: { id: relationship.id },
data: { data: {
endorsed: true, endorsed: true,
}, },
}); });
return jsonResponse(relationshipToAPI(relationship)); return jsonResponse(relationshipToAPI(relationship));
}); });

View file

@ -1,95 +1,95 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import { import {
createNewRelationship, createNewRelationship,
relationshipToAPI, relationshipToAPI,
} from "~database/entities/Relationship"; } from "~database/entities/Relationship";
import { getRelationshipToOtherUser } from "~database/entities/User"; import { getRelationshipToOtherUser } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
ratelimits: { ratelimits: {
max: 30, max: 30,
duration: 60, duration: 60,
}, },
route: "/accounts/:id/remove_from_followers", route: "/accounts/:id/remove_from_followers",
auth: { auth: {
required: true, required: true,
oauthPermissions: ["write:follows"], oauthPermissions: ["write:follows"],
}, },
}); });
/** /**
* Removes an account from your followers list * Removes an account from your followers list
*/ */
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
const { user: self } = extraData.auth; const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
const user = await client.user.findUnique({ const user = await client.user.findUnique({
where: { id }, where: { id },
include: { include: {
relationships: { relationships: {
include: { include: {
owner: true, owner: true,
subject: true, subject: true,
}, },
}, },
}, },
}); });
if (!user) return errorResponse("User not found", 404); if (!user) return errorResponse("User not found", 404);
// Check if already following // Check if already following
let relationship = await getRelationshipToOtherUser(self, user); let relationship = await getRelationshipToOtherUser(self, user);
if (!relationship) { if (!relationship) {
// Create new relationship // Create new relationship
const newRelationship = await createNewRelationship(self, user); const newRelationship = await createNewRelationship(self, user);
await client.user.update({ await client.user.update({
where: { id: self.id }, where: { id: self.id },
data: { data: {
relationships: { relationships: {
connect: { connect: {
id: newRelationship.id, id: newRelationship.id,
}, },
}, },
}, },
}); });
relationship = newRelationship; relationship = newRelationship;
} }
if (relationship.followedBy) { if (relationship.followedBy) {
relationship.followedBy = false; relationship.followedBy = false;
} }
await client.relationship.update({ await client.relationship.update({
where: { id: relationship.id }, where: { id: relationship.id },
data: { data: {
followedBy: false, followedBy: false,
}, },
}); });
if (user.instanceId === null) { if (user.instanceId === null) {
// Also remove from followers list // Also remove from followers list
await client.relationship.updateMany({ await client.relationship.updateMany({
where: { where: {
ownerId: user.id, ownerId: user.id,
subjectId: self.id, subjectId: self.id,
following: true, following: true,
}, },
data: { data: {
following: false, following: false,
}, },
}); });
} }
return jsonResponse(relationshipToAPI(relationship)); return jsonResponse(relationshipToAPI(relationship));
}); });

View file

@ -1,134 +1,136 @@
import { apiRoute, applyConfig } from "@api";
/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { statusToAPI } from "~database/entities/Status";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { statusToAPI } from "~database/entities/Status";
import { import {
userRelations, statusAndUserRelations,
statusAndUserRelations, userRelations,
} from "~database/entities/relations"; } from "~database/entities/relations";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
ratelimits: { ratelimits: {
max: 30, max: 30,
duration: 60, duration: 60,
}, },
route: "/accounts/:id/statuses", route: "/accounts/:id/statuses",
auth: { auth: {
required: false, required: false,
oauthPermissions: ["read:statuses"], oauthPermissions: ["read:statuses"],
}, },
}); });
/** /**
* Fetch all statuses for a user * Fetch all statuses for a user
*/ */
export default apiRoute<{ export default apiRoute<{
max_id?: string; max_id?: string;
since_id?: string; since_id?: string;
min_id?: string; min_id?: string;
limit?: string; limit?: string;
only_media?: boolean; only_media?: boolean;
exclude_replies?: boolean; exclude_replies?: boolean;
exclude_reblogs?: boolean; exclude_reblogs?: boolean;
// TODO: Add with_muted // TODO: Add with_muted
pinned?: boolean; pinned?: boolean;
tagged?: string; tagged?: string;
}>(async (req, matchedRoute, extraData) => { }>(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
// TODO: Add pinned // TODO: Add pinned
const { const {
max_id, max_id,
min_id, min_id,
since_id, since_id,
limit = "20", limit = "20",
exclude_reblogs, exclude_reblogs,
pinned, pinned,
} = extraData.parsedRequest; } = extraData.parsedRequest;
const user = await client.user.findUnique({ const user = await client.user.findUnique({
where: { id }, where: { id },
include: userRelations, include: userRelations,
}); });
if (!user) return errorResponse("User not found", 404); if (!user) return errorResponse("User not found", 404);
if (pinned) { if (pinned) {
const objects = await client.status.findMany({ const objects = await client.status.findMany({
where: { where: {
authorId: id, authorId: id,
isReblog: false, isReblog: false,
pinnedBy: { pinnedBy: {
some: { some: {
id: user.id, id: user.id,
}, },
}, },
id: { id: {
lt: max_id, lt: max_id,
gt: min_id, gt: min_id,
gte: since_id, gte: since_id,
}, },
}, },
include: statusAndUserRelations, include: statusAndUserRelations,
take: Number(limit), take: Number(limit),
orderBy: { orderBy: {
id: "desc", id: "desc",
}, },
}); });
// Constuct HTTP Link header (next and prev) // Constuct HTTP Link header (next and prev)
const linkHeader = []; const linkHeader = [];
if (objects.length > 0) { if (objects.length > 0) {
const urlWithoutQuery = req.url.split("?")[0]; const urlWithoutQuery = req.url.split("?")[0];
linkHeader.push( linkHeader.push(
`<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`, `<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`,
`<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"` `<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`,
); );
} }
return jsonResponse( return jsonResponse(
await Promise.all(objects.map(status => statusToAPI(status, user))), await Promise.all(
200, objects.map((status) => statusToAPI(status, user)),
{ ),
Link: linkHeader.join(", "), 200,
} {
); Link: linkHeader.join(", "),
} },
);
}
const objects = await client.status.findMany({ const objects = await client.status.findMany({
where: { where: {
authorId: id, authorId: id,
isReblog: exclude_reblogs ? true : undefined, isReblog: exclude_reblogs ? true : undefined,
id: { id: {
lt: max_id, lt: max_id,
gt: min_id, gt: min_id,
gte: since_id, gte: since_id,
}, },
}, },
include: statusAndUserRelations, include: statusAndUserRelations,
take: Number(limit), take: Number(limit),
orderBy: { orderBy: {
id: "desc", id: "desc",
}, },
}); });
// Constuct HTTP Link header (next and prev) // Constuct HTTP Link header (next and prev)
const linkHeader = []; const linkHeader = [];
if (objects.length > 0) { if (objects.length > 0) {
const urlWithoutQuery = req.url.split("?")[0]; const urlWithoutQuery = req.url.split("?")[0];
linkHeader.push( linkHeader.push(
`<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`, `<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`,
`<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"` `<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`,
); );
} }
return jsonResponse( return jsonResponse(
await Promise.all(objects.map(status => statusToAPI(status, user))), await Promise.all(objects.map((status) => statusToAPI(status, user))),
200, 200,
{ {
Link: linkHeader.join(", "), Link: linkHeader.join(", "),
} },
); );
}); });

View file

@ -1,81 +1,81 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import { import {
createNewRelationship, createNewRelationship,
relationshipToAPI, relationshipToAPI,
} from "~database/entities/Relationship"; } from "~database/entities/Relationship";
import { getRelationshipToOtherUser } from "~database/entities/User"; import { getRelationshipToOtherUser } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
ratelimits: { ratelimits: {
max: 30, max: 30,
duration: 60, duration: 60,
}, },
route: "/accounts/:id/unblock", route: "/accounts/:id/unblock",
auth: { auth: {
required: true, required: true,
oauthPermissions: ["write:blocks"], oauthPermissions: ["write:blocks"],
}, },
}); });
/** /**
* Blocks a user * Blocks a user
*/ */
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
const { user: self } = extraData.auth; const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
const user = await client.user.findUnique({ const user = await client.user.findUnique({
where: { id }, where: { id },
include: { include: {
relationships: { relationships: {
include: { include: {
owner: true, owner: true,
subject: true, subject: true,
}, },
}, },
}, },
}); });
if (!user) return errorResponse("User not found", 404); if (!user) return errorResponse("User not found", 404);
// Check if already following // Check if already following
let relationship = await getRelationshipToOtherUser(self, user); let relationship = await getRelationshipToOtherUser(self, user);
if (!relationship) { if (!relationship) {
// Create new relationship // Create new relationship
const newRelationship = await createNewRelationship(self, user); const newRelationship = await createNewRelationship(self, user);
await client.user.update({ await client.user.update({
where: { id: self.id }, where: { id: self.id },
data: { data: {
relationships: { relationships: {
connect: { connect: {
id: newRelationship.id, id: newRelationship.id,
}, },
}, },
}, },
}); });
relationship = newRelationship; relationship = newRelationship;
} }
if (relationship.blocking) { if (relationship.blocking) {
relationship.blocking = false; relationship.blocking = false;
} }
await client.relationship.update({ await client.relationship.update({
where: { id: relationship.id }, where: { id: relationship.id },
data: { data: {
blocking: false, blocking: false,
}, },
}); });
return jsonResponse(relationshipToAPI(relationship)); return jsonResponse(relationshipToAPI(relationship));
}); });

View file

@ -1,81 +1,81 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import { import {
createNewRelationship, createNewRelationship,
relationshipToAPI, relationshipToAPI,
} from "~database/entities/Relationship"; } from "~database/entities/Relationship";
import { getRelationshipToOtherUser } from "~database/entities/User"; import { getRelationshipToOtherUser } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
ratelimits: { ratelimits: {
max: 30, max: 30,
duration: 60, duration: 60,
}, },
route: "/accounts/:id/unfollow", route: "/accounts/:id/unfollow",
auth: { auth: {
required: true, required: true,
oauthPermissions: ["write:follows"], oauthPermissions: ["write:follows"],
}, },
}); });
/** /**
* Unfollows a user * Unfollows a user
*/ */
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
const { user: self } = extraData.auth; const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
const user = await client.user.findUnique({ const user = await client.user.findUnique({
where: { id }, where: { id },
include: { include: {
relationships: { relationships: {
include: { include: {
owner: true, owner: true,
subject: true, subject: true,
}, },
}, },
}, },
}); });
if (!user) return errorResponse("User not found", 404); if (!user) return errorResponse("User not found", 404);
// Check if already following // Check if already following
let relationship = await getRelationshipToOtherUser(self, user); let relationship = await getRelationshipToOtherUser(self, user);
if (!relationship) { if (!relationship) {
// Create new relationship // Create new relationship
const newRelationship = await createNewRelationship(self, user); const newRelationship = await createNewRelationship(self, user);
await client.user.update({ await client.user.update({
where: { id: self.id }, where: { id: self.id },
data: { data: {
relationships: { relationships: {
connect: { connect: {
id: newRelationship.id, id: newRelationship.id,
}, },
}, },
}, },
}); });
relationship = newRelationship; relationship = newRelationship;
} }
if (relationship.following) { if (relationship.following) {
relationship.following = false; relationship.following = false;
} }
await client.relationship.update({ await client.relationship.update({
where: { id: relationship.id }, where: { id: relationship.id },
data: { data: {
following: false, following: false,
}, },
}); });
return jsonResponse(relationshipToAPI(relationship)); return jsonResponse(relationshipToAPI(relationship));
}); });

View file

@ -1,83 +1,83 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import { import {
createNewRelationship, createNewRelationship,
relationshipToAPI, relationshipToAPI,
} from "~database/entities/Relationship"; } from "~database/entities/Relationship";
import { getRelationshipToOtherUser } from "~database/entities/User"; import { getRelationshipToOtherUser } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
ratelimits: { ratelimits: {
max: 30, max: 30,
duration: 60, duration: 60,
}, },
route: "/accounts/:id/unmute", route: "/accounts/:id/unmute",
auth: { auth: {
required: true, required: true,
oauthPermissions: ["write:mutes"], oauthPermissions: ["write:mutes"],
}, },
}); });
/** /**
* Unmute a user * Unmute a user
*/ */
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
const { user: self } = extraData.auth; const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
const user = await client.user.findUnique({ const user = await client.user.findUnique({
where: { id }, where: { id },
include: { include: {
relationships: { relationships: {
include: { include: {
owner: true, owner: true,
subject: true, subject: true,
}, },
}, },
}, },
}); });
if (!user) return errorResponse("User not found", 404); if (!user) return errorResponse("User not found", 404);
// Check if already following // Check if already following
let relationship = await getRelationshipToOtherUser(self, user); let relationship = await getRelationshipToOtherUser(self, user);
if (!relationship) { if (!relationship) {
// Create new relationship // Create new relationship
const newRelationship = await createNewRelationship(self, user); const newRelationship = await createNewRelationship(self, user);
await client.user.update({ await client.user.update({
where: { id: self.id }, where: { id: self.id },
data: { data: {
relationships: { relationships: {
connect: { connect: {
id: newRelationship.id, id: newRelationship.id,
}, },
}, },
}, },
}); });
relationship = newRelationship; relationship = newRelationship;
} }
if (relationship.muting) { if (relationship.muting) {
relationship.muting = false; relationship.muting = false;
} }
// TODO: Implement duration // TODO: Implement duration
await client.relationship.update({ await client.relationship.update({
where: { id: relationship.id }, where: { id: relationship.id },
data: { data: {
muting: false, muting: false,
}, },
}); });
return jsonResponse(relationshipToAPI(relationship)); return jsonResponse(relationshipToAPI(relationship));
}); });

View file

@ -1,81 +1,81 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import { import {
createNewRelationship, createNewRelationship,
relationshipToAPI, relationshipToAPI,
} from "~database/entities/Relationship"; } from "~database/entities/Relationship";
import { getRelationshipToOtherUser } from "~database/entities/User"; import { getRelationshipToOtherUser } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
ratelimits: { ratelimits: {
max: 30, max: 30,
duration: 60, duration: 60,
}, },
route: "/accounts/:id/unpin", route: "/accounts/:id/unpin",
auth: { auth: {
required: true, required: true,
oauthPermissions: ["write:accounts"], oauthPermissions: ["write:accounts"],
}, },
}); });
/** /**
* Unpin a user * Unpin a user
*/ */
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
const { user: self } = extraData.auth; const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
const user = await client.user.findUnique({ const user = await client.user.findUnique({
where: { id }, where: { id },
include: { include: {
relationships: { relationships: {
include: { include: {
owner: true, owner: true,
subject: true, subject: true,
}, },
}, },
}, },
}); });
if (!user) return errorResponse("User not found", 404); if (!user) return errorResponse("User not found", 404);
// Check if already following // Check if already following
let relationship = await getRelationshipToOtherUser(self, user); let relationship = await getRelationshipToOtherUser(self, user);
if (!relationship) { if (!relationship) {
// Create new relationship // Create new relationship
const newRelationship = await createNewRelationship(self, user); const newRelationship = await createNewRelationship(self, user);
await client.user.update({ await client.user.update({
where: { id: self.id }, where: { id: self.id },
data: { data: {
relationships: { relationships: {
connect: { connect: {
id: newRelationship.id, id: newRelationship.id,
}, },
}, },
}, },
}); });
relationship = newRelationship; relationship = newRelationship;
} }
if (relationship.endorsed) { if (relationship.endorsed) {
relationship.endorsed = false; relationship.endorsed = false;
} }
await client.relationship.update({ await client.relationship.update({
where: { id: relationship.id }, where: { id: relationship.id },
data: { data: {
endorsed: false, endorsed: false,
}, },
}); });
return jsonResponse(relationshipToAPI(relationship)); return jsonResponse(relationshipToAPI(relationship));
}); });

View file

@ -1,67 +1,67 @@
import { errorResponse, jsonResponse } from "@response";
import { userToAPI } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { userToAPI } from "~database/entities/User";
import { userRelations } from "~database/entities/relations"; import { userRelations } from "~database/entities/relations";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
route: "/api/v1/accounts/familiar_followers", route: "/api/v1/accounts/familiar_followers",
ratelimits: { ratelimits: {
max: 5, max: 5,
duration: 60, duration: 60,
}, },
auth: { auth: {
required: true, required: true,
oauthPermissions: ["read:follows"], oauthPermissions: ["read:follows"],
}, },
}); });
/** /**
* Find familiar followers (followers of a user that you also follow) * Find familiar followers (followers of a user that you also follow)
*/ */
export default apiRoute<{ export default apiRoute<{
id: string[]; id: string[];
}>(async (req, matchedRoute, extraData) => { }>(async (req, matchedRoute, extraData) => {
const { user: self } = extraData.auth; const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
const { id: ids } = extraData.parsedRequest; const { id: ids } = extraData.parsedRequest;
// Minimum id count 1, maximum 10 // Minimum id count 1, maximum 10
if (!ids || ids.length < 1 || ids.length > 10) { if (!ids || ids.length < 1 || ids.length > 10) {
return errorResponse("Number of ids must be between 1 and 10", 422); return errorResponse("Number of ids must be between 1 and 10", 422);
} }
const followersOfIds = await client.user.findMany({ const followersOfIds = await client.user.findMany({
where: { where: {
relationships: { relationships: {
some: { some: {
subjectId: { subjectId: {
in: ids, in: ids,
}, },
following: true, following: true,
}, },
}, },
}, },
}); });
// Find users that you follow in followersOfIds // Find users that you follow in followersOfIds
const output = await client.user.findMany({ const output = await client.user.findMany({
where: { where: {
relationships: { relationships: {
some: { some: {
ownerId: self.id, ownerId: self.id,
subjectId: { subjectId: {
in: followersOfIds.map(f => f.id), in: followersOfIds.map((f) => f.id),
}, },
following: true, following: true,
}, },
}, },
}, },
include: userRelations, include: userRelations,
}); });
return jsonResponse(output.map(o => userToAPI(o))); return jsonResponse(output.map((o) => userToAPI(o)));
}); });

View file

@ -1,202 +1,206 @@
import { apiRoute, applyConfig } from "@api";
import { jsonResponse } from "@response"; import { jsonResponse } from "@response";
import { tempmailDomains } from "@tempmail"; import { tempmailDomains } from "@tempmail";
import { apiRoute, applyConfig } from "@api"; import ISO6391 from "iso-639-1";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { createNewLocalUser } from "~database/entities/User"; import { createNewLocalUser } from "~database/entities/User";
import ISO6391 from "iso-639-1";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
route: "/api/v1/accounts", route: "/api/v1/accounts",
ratelimits: { ratelimits: {
max: 2, max: 2,
duration: 60, duration: 60,
}, },
auth: { auth: {
required: false, required: false,
oauthPermissions: ["write:accounts"], oauthPermissions: ["write:accounts"],
}, },
}); });
export default apiRoute<{ export default apiRoute<{
username: string; username: string;
email: string; email: string;
password: string; password: string;
agreement: boolean; agreement: boolean;
locale: string; locale: string;
reason: string; reason: string;
}>(async (req, matchedRoute, extraData) => { }>(async (req, matchedRoute, extraData) => {
// TODO: Add Authorization check // TODO: Add Authorization check
const body = extraData.parsedRequest; const body = extraData.parsedRequest;
const config = await extraData.configManager.getConfig(); const config = await extraData.configManager.getConfig();
if (!config.signups.registration) { if (!config.signups.registration) {
return jsonResponse( return jsonResponse(
{ {
error: "Registration is disabled", error: "Registration is disabled",
}, },
422 422,
); );
} }
const errors: { const errors: {
details: Record< details: Record<
string, string,
{ {
error: error:
| "ERR_BLANK" | "ERR_BLANK"
| "ERR_INVALID" | "ERR_INVALID"
| "ERR_TOO_LONG" | "ERR_TOO_LONG"
| "ERR_TOO_SHORT" | "ERR_TOO_SHORT"
| "ERR_BLOCKED" | "ERR_BLOCKED"
| "ERR_TAKEN" | "ERR_TAKEN"
| "ERR_RESERVED" | "ERR_RESERVED"
| "ERR_ACCEPTED" | "ERR_ACCEPTED"
| "ERR_INCLUSION"; | "ERR_INCLUSION";
description: string; description: string;
}[] }[]
>; >;
} = { } = {
details: { details: {
password: [], password: [],
username: [], username: [],
email: [], email: [],
agreement: [], agreement: [],
locale: [], locale: [],
reason: [], reason: [],
}, },
}; };
// Check if fields are blank // Check if fields are blank
["username", "email", "password", "agreement", "locale", "reason"].forEach( for (const value of [
value => { "username",
// @ts-expect-error Value is always valid "email",
if (!body[value]) "password",
errors.details[value].push({ "agreement",
error: "ERR_BLANK", "locale",
description: `can't be blank`, "reason",
}); ]) {
} // @ts-expect-error We don't care about typing here
); if (!body[value]) {
errors.details[value].push({
error: "ERR_BLANK",
description: `can't be blank`,
});
}
}
// Check if username is valid // Check if username is valid
if (!body.username?.match(/^[a-zA-Z0-9_]+$/)) if (!body.username?.match(/^[a-zA-Z0-9_]+$/))
errors.details.username.push({ errors.details.username.push({
error: "ERR_INVALID", error: "ERR_INVALID",
description: `must only contain letters, numbers, and underscores`, description: "must only contain letters, numbers, and underscores",
}); });
// Check if username doesnt match filters // Check if username doesnt match filters
if ( if (
config.filters.username_filters.some(filter => config.filters.username.some((filter) => body.username?.match(filter))
body.username?.match(filter) ) {
) errors.details.username.push({
) { error: "ERR_INVALID",
errors.details.username.push({ description: "contains blocked words",
error: "ERR_INVALID", });
description: `contains blocked words`, }
});
}
// Check if username is too long // Check if username is too long
if ((body.username?.length ?? 0) > config.validation.max_username_size) if ((body.username?.length ?? 0) > config.validation.max_username_size)
errors.details.username.push({ errors.details.username.push({
error: "ERR_TOO_LONG", error: "ERR_TOO_LONG",
description: `is too long (maximum is ${config.validation.max_username_size} characters)`, description: `is too long (maximum is ${config.validation.max_username_size} characters)`,
}); });
// Check if username is too short // Check if username is too short
if ((body.username?.length ?? 0) < 3) if ((body.username?.length ?? 0) < 3)
errors.details.username.push({ errors.details.username.push({
error: "ERR_TOO_SHORT", error: "ERR_TOO_SHORT",
description: `is too short (minimum is 3 characters)`, description: "is too short (minimum is 3 characters)",
}); });
// Check if username is reserved // Check if username is reserved
if (config.validation.username_blacklist.includes(body.username ?? "")) if (config.validation.username_blacklist.includes(body.username ?? ""))
errors.details.username.push({ errors.details.username.push({
error: "ERR_RESERVED", error: "ERR_RESERVED",
description: `is reserved`, description: "is reserved",
}); });
// Check if username is taken // Check if username is taken
if (await client.user.findFirst({ where: { username: body.username } })) if (await client.user.findFirst({ where: { username: body.username } }))
errors.details.username.push({ errors.details.username.push({
error: "ERR_TAKEN", error: "ERR_TAKEN",
description: `is already taken`, description: "is already taken",
}); });
// Check if email is valid // Check if email is valid
if ( if (
!body.email?.match( !body.email?.match(
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,
) )
) )
errors.details.email.push({ errors.details.email.push({
error: "ERR_INVALID", error: "ERR_INVALID",
description: `must be a valid email address`, description: "must be a valid email address",
}); });
// Check if email is blocked // Check if email is blocked
if ( if (
config.validation.email_blacklist.includes(body.email ?? "") || config.validation.email_blacklist.includes(body.email ?? "") ||
(config.validation.blacklist_tempmail && (config.validation.blacklist_tempmail &&
tempmailDomains.domains.includes((body.email ?? "").split("@")[1])) tempmailDomains.domains.includes((body.email ?? "").split("@")[1]))
) )
errors.details.email.push({ errors.details.email.push({
error: "ERR_BLOCKED", error: "ERR_BLOCKED",
description: `is from a blocked email provider`, description: "is from a blocked email provider",
}); });
// Check if agreement is accepted // Check if agreement is accepted
if (!body.agreement) if (!body.agreement)
errors.details.agreement.push({ errors.details.agreement.push({
error: "ERR_ACCEPTED", error: "ERR_ACCEPTED",
description: `must be accepted`, description: "must be accepted",
}); });
if (!body.locale) if (!body.locale)
errors.details.locale.push({ errors.details.locale.push({
error: "ERR_BLANK", error: "ERR_BLANK",
description: `can't be blank`, description: `can't be blank`,
}); });
if (!ISO6391.validate(body.locale ?? "")) if (!ISO6391.validate(body.locale ?? ""))
errors.details.locale.push({ errors.details.locale.push({
error: "ERR_INVALID", error: "ERR_INVALID",
description: `must be a valid ISO 639-1 code`, description: "must be a valid ISO 639-1 code",
}); });
// If any errors are present, return them // If any errors are present, return them
if (Object.values(errors.details).some(value => value.length > 0)) { if (Object.values(errors.details).some((value) => value.length > 0)) {
// Error is something like "Validation failed: Password can't be blank, Username must contain only letters, numbers and underscores, Agreement must be accepted" // Error is something like "Validation failed: Password can't be blank, Username must contain only letters, numbers and underscores, Agreement must be accepted"
const errorsText = Object.entries(errors.details) const errorsText = Object.entries(errors.details)
.map( .map(
([name, errors]) => ([name, errors]) =>
`${name} ${errors `${name} ${errors
.map(error => error.description) .map((error) => error.description)
.join(", ")}` .join(", ")}`,
) )
.join(", "); .join(", ");
return jsonResponse( return jsonResponse(
{ {
error: `Validation failed: ${errorsText}`, error: `Validation failed: ${errorsText}`,
details: errors.details, details: errors.details,
}, },
422 422,
); );
} }
await createNewLocalUser({ await createNewLocalUser({
username: body.username ?? "", username: body.username ?? "",
password: body.password ?? "", password: body.password ?? "",
email: body.email ?? "", email: body.email ?? "",
}); });
return new Response("", { return new Response("", {
status: 200, status: 200,
}); });
}); });

View file

@ -1,66 +1,67 @@
import { errorResponse, jsonResponse } from "@response";
import {
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import type { User } from "@prisma/client";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import {
createNewRelationship,
relationshipToAPI,
} from "~database/entities/Relationship";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
route: "/api/v1/accounts/relationships", route: "/api/v1/accounts/relationships",
ratelimits: { ratelimits: {
max: 30, max: 30,
duration: 60, duration: 60,
}, },
auth: { auth: {
required: true, required: true,
oauthPermissions: ["read:follows"], oauthPermissions: ["read:follows"],
}, },
}); });
/** /**
* Find relationships * Find relationships
*/ */
export default apiRoute<{ export default apiRoute<{
id: string[]; id: string[];
}>(async (req, matchedRoute, extraData) => { }>(async (req, matchedRoute, extraData) => {
const { user: self } = extraData.auth; const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
const { id: ids } = extraData.parsedRequest; const { id: ids } = extraData.parsedRequest;
// Minimum id count 1, maximum 10 // Minimum id count 1, maximum 10
if (!ids || ids.length < 1 || ids.length > 10) { if (!ids || ids.length < 1 || ids.length > 10) {
return errorResponse("Number of ids must be between 1 and 10", 422); return errorResponse("Number of ids must be between 1 and 10", 422);
} }
const relationships = await client.relationship.findMany({ const relationships = await client.relationship.findMany({
where: { where: {
ownerId: self.id, ownerId: self.id,
subjectId: { subjectId: {
in: ids, in: ids,
}, },
}, },
}); });
// Find IDs that dont have a relationship // Find IDs that dont have a relationship
const missingIds = ids.filter( const missingIds = ids.filter(
id => !relationships.some(r => r.subjectId === id) (id) => !relationships.some((r) => r.subjectId === id),
); );
// Create the missing relationships // Create the missing relationships
for (const id of missingIds) { for (const id of missingIds) {
const relationship = await createNewRelationship(self, { id } as any); const relationship = await createNewRelationship(self, { id } as User);
relationships.push(relationship); relationships.push(relationship);
} }
// Order in the same order as ids // Order in the same order as ids
relationships.sort( relationships.sort(
(a, b) => ids.indexOf(a.subjectId) - ids.indexOf(b.subjectId) (a, b) => ids.indexOf(a.subjectId) - ids.indexOf(b.subjectId),
); );
return jsonResponse(relationships.map(r => relationshipToAPI(r))); return jsonResponse(relationships.map((r) => relationshipToAPI(r)));
}); });

View file

@ -1,75 +1,75 @@
import { errorResponse, jsonResponse } from "@response";
import { userToAPI } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { userToAPI } from "~database/entities/User";
import { userRelations } from "~database/entities/relations"; import { userRelations } from "~database/entities/relations";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
route: "/api/v1/accounts/search", route: "/api/v1/accounts/search",
ratelimits: { ratelimits: {
max: 100, max: 100,
duration: 60, duration: 60,
}, },
auth: { auth: {
required: true, required: true,
oauthPermissions: ["read:accounts"], oauthPermissions: ["read:accounts"],
}, },
}); });
export default apiRoute<{ export default apiRoute<{
q?: string; q?: string;
limit?: number; limit?: number;
offset?: number; offset?: number;
resolve?: boolean; resolve?: boolean;
following?: boolean; following?: boolean;
}>(async (req, matchedRoute, extraData) => { }>(async (req, matchedRoute, extraData) => {
// TODO: Add checks for disabled or not email verified accounts // TODO: Add checks for disabled or not email verified accounts
const { user } = extraData.auth; const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
const { const {
following = false, following = false,
limit = 40, limit = 40,
offset, offset,
q, q,
} = extraData.parsedRequest; } = extraData.parsedRequest;
if (limit < 1 || limit > 80) { if (limit < 1 || limit > 80) {
return errorResponse("Limit must be between 1 and 80", 400); return errorResponse("Limit must be between 1 and 80", 400);
} }
// TODO: Add WebFinger resolve // TODO: Add WebFinger resolve
const accounts = await client.user.findMany({ const accounts = await client.user.findMany({
where: { where: {
OR: [ OR: [
{ {
displayName: { displayName: {
contains: q, contains: q,
}, },
}, },
{ {
username: { username: {
contains: q, contains: q,
}, },
}, },
], ],
relationshipSubjects: following relationshipSubjects: following
? { ? {
some: { some: {
ownerId: user.id, ownerId: user.id,
following, following,
}, },
} }
: undefined, : undefined,
}, },
take: Number(limit), take: Number(limit),
skip: Number(offset || 0), skip: Number(offset || 0),
include: userRelations, include: userRelations,
}); });
return jsonResponse(accounts.map(acct => userToAPI(acct))); return jsonResponse(accounts.map((acct) => userToAPI(acct)));
}); });

View file

@ -1,72 +1,72 @@
import { errorResponse, jsonResponse } from "@response";
import { userToAPI } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { sanitize } from "isomorphic-dompurify"; import { convertTextToHtml } from "@formatting";
import { errorResponse, jsonResponse } from "@response";
import { sanitizeHtml } from "@sanitization"; import { sanitizeHtml } from "@sanitization";
import ISO6391 from "iso-639-1"; import ISO6391 from "iso-639-1";
import { parseEmojis } from "~database/entities/Emoji"; import { sanitize } from "isomorphic-dompurify";
import { client } from "~database/datasource";
import type { APISource } from "~types/entities/source";
import { convertTextToHtml } from "@formatting";
import { MediaBackendType } from "media-manager"; import { MediaBackendType } from "media-manager";
import type { MediaBackend } from "media-manager"; import type { MediaBackend } from "media-manager";
import { client } from "~database/datasource";
import { getUrl } from "~database/entities/Attachment";
import { parseEmojis } from "~database/entities/Emoji";
import { userToAPI } from "~database/entities/User";
import { userRelations } from "~database/entities/relations";
import { LocalMediaBackend } from "~packages/media-manager/backends/local"; import { LocalMediaBackend } from "~packages/media-manager/backends/local";
import { S3MediaBackend } from "~packages/media-manager/backends/s3"; import { S3MediaBackend } from "~packages/media-manager/backends/s3";
import { getUrl } from "~database/entities/Attachment"; import type { APISource } from "~types/entities/source";
import { userRelations } from "~database/entities/relations";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["PATCH"], allowedMethods: ["PATCH"],
route: "/api/v1/accounts/update_credentials", route: "/api/v1/accounts/update_credentials",
ratelimits: { ratelimits: {
max: 2, max: 2,
duration: 60, duration: 60,
}, },
auth: { auth: {
required: true, required: true,
oauthPermissions: ["write:accounts"], oauthPermissions: ["write:accounts"],
}, },
}); });
export default apiRoute<{ export default apiRoute<{
display_name: string; display_name: string;
note: string; note: string;
avatar: File; avatar: File;
header: File; header: File;
locked: string; locked: string;
bot: string; bot: string;
discoverable: string; discoverable: string;
"source[privacy]": string; "source[privacy]": string;
"source[sensitive]": string; "source[sensitive]": string;
"source[language]": string; "source[language]": string;
}>(async (req, matchedRoute, extraData) => { }>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth; const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
const config = await extraData.configManager.getConfig(); const config = await extraData.configManager.getConfig();
const { const {
display_name, display_name,
note, note,
avatar, avatar,
header, header,
locked, locked,
bot, bot,
discoverable, discoverable,
"source[privacy]": source_privacy, "source[privacy]": source_privacy,
"source[sensitive]": source_sensitive, "source[sensitive]": source_sensitive,
"source[language]": source_language, "source[language]": source_language,
} = extraData.parsedRequest; } = extraData.parsedRequest;
const sanitizedNote = await sanitizeHtml(note ?? ""); const sanitizedNote = await sanitizeHtml(note ?? "");
const sanitizedDisplayName = sanitize(display_name ?? "", { const sanitizedDisplayName = sanitize(display_name ?? "", {
ALLOWED_TAGS: [], ALLOWED_TAGS: [],
ALLOWED_ATTR: [], ALLOWED_ATTR: [],
}); });
/* if (!user.source) { /* if (!user.source) {
user.source = { user.source = {
privacy: "public", privacy: "public",
sensitive: false, sensitive: false,
@ -75,191 +75,192 @@ export default apiRoute<{
}; };
} */ } */
let mediaManager: MediaBackend; let mediaManager: MediaBackend;
switch (config.media.backend as MediaBackendType) { switch (config.media.backend as MediaBackendType) {
case MediaBackendType.LOCAL: case MediaBackendType.LOCAL:
mediaManager = new LocalMediaBackend(config); mediaManager = new LocalMediaBackend(config);
break; break;
case MediaBackendType.S3: case MediaBackendType.S3:
mediaManager = new S3MediaBackend(config); mediaManager = new S3MediaBackend(config);
break; break;
default: default:
// TODO: Replace with logger // TODO: Replace with logger
throw new Error("Invalid media backend"); throw new Error("Invalid media backend");
} }
if (display_name) { if (display_name) {
// Check if within allowed display name lengths // Check if within allowed display name lengths
if ( if (
sanitizedDisplayName.length < 3 || sanitizedDisplayName.length < 3 ||
sanitizedDisplayName.length > config.validation.max_displayname_size sanitizedDisplayName.length > config.validation.max_displayname_size
) { ) {
return errorResponse( return errorResponse(
`Display name must be between 3 and ${config.validation.max_displayname_size} characters`, `Display name must be between 3 and ${config.validation.max_displayname_size} characters`,
422 422,
); );
} }
// Check if display name doesnt match filters // Check if display name doesnt match filters
if ( if (
config.filters.displayname.some(filter => config.filters.displayname.some((filter) =>
sanitizedDisplayName.match(filter) sanitizedDisplayName.match(filter),
) )
) { ) {
return errorResponse("Display name contains blocked words", 422); return errorResponse("Display name contains blocked words", 422);
} }
// Remove emojis // Remove emojis
user.emojis = []; user.emojis = [];
user.displayName = sanitizedDisplayName; user.displayName = sanitizedDisplayName;
} }
if (note && user.source) { if (note && user.source) {
// Check if within allowed note length // Check if within allowed note length
if (sanitizedNote.length > config.validation.max_note_size) { if (sanitizedNote.length > config.validation.max_note_size) {
return errorResponse( return errorResponse(
`Note must be less than ${config.validation.max_note_size} characters`, `Note must be less than ${config.validation.max_note_size} characters`,
422 422,
); );
} }
// Check if bio doesnt match filters // Check if bio doesnt match filters
if (config.filters.bio.some(filter => sanitizedNote.match(filter))) { if (config.filters.bio.some((filter) => sanitizedNote.match(filter))) {
return errorResponse("Bio contains blocked words", 422); return errorResponse("Bio contains blocked words", 422);
} }
(user.source as APISource).note = sanitizedNote; (user.source as APISource).note = sanitizedNote;
// TODO: Convert note to HTML // TODO: Convert note to HTML
user.note = await convertTextToHtml(sanitizedNote); user.note = await convertTextToHtml(sanitizedNote);
} }
if (source_privacy && user.source) { if (source_privacy && user.source) {
// Check if within allowed privacy values // Check if within allowed privacy values
if ( if (
!["public", "unlisted", "private", "direct"].includes( !["public", "unlisted", "private", "direct"].includes(
source_privacy source_privacy,
) )
) { ) {
return errorResponse( return errorResponse(
"Privacy must be one of public, unlisted, private, or direct", "Privacy must be one of public, unlisted, private, or direct",
422 422,
); );
} }
(user.source as APISource).privacy = source_privacy; (user.source as APISource).privacy = source_privacy;
} }
if (source_sensitive && user.source) { if (source_sensitive && user.source) {
// Check if within allowed sensitive values // Check if within allowed sensitive values
if (source_sensitive !== "true" && source_sensitive !== "false") { if (source_sensitive !== "true" && source_sensitive !== "false") {
return errorResponse("Sensitive must be a boolean", 422); return errorResponse("Sensitive must be a boolean", 422);
} }
(user.source as APISource).sensitive = source_sensitive === "true"; (user.source as APISource).sensitive = source_sensitive === "true";
} }
if (source_language && user.source) { if (source_language && user.source) {
if (!ISO6391.validate(source_language)) { if (!ISO6391.validate(source_language)) {
return errorResponse( return errorResponse(
"Language must be a valid ISO 639-1 code", "Language must be a valid ISO 639-1 code",
422 422,
); );
} }
(user.source as APISource).language = source_language; (user.source as APISource).language = source_language;
} }
if (avatar) { if (avatar) {
// Check if within allowed avatar length (avatar is an image) // Check if within allowed avatar length (avatar is an image)
if (avatar.size > config.validation.max_avatar_size) { if (avatar.size > config.validation.max_avatar_size) {
return errorResponse( return errorResponse(
`Avatar must be less than ${config.validation.max_avatar_size} bytes`, `Avatar must be less than ${config.validation.max_avatar_size} bytes`,
422 422,
); );
} }
const { uploadedFile } = await mediaManager.addFile(avatar); const { uploadedFile } = await mediaManager.addFile(avatar);
user.avatar = getUrl(uploadedFile.name, config); user.avatar = getUrl(uploadedFile.name, config);
} }
if (header) { if (header) {
// Check if within allowed header length (header is an image) // Check if within allowed header length (header is an image)
if (header.size > config.validation.max_header_size) { if (header.size > config.validation.max_header_size) {
return errorResponse( return errorResponse(
`Header must be less than ${config.validation.max_avatar_size} bytes`, `Header must be less than ${config.validation.max_avatar_size} bytes`,
422 422,
); );
} }
const { uploadedFile } = await mediaManager.addFile(header); const { uploadedFile } = await mediaManager.addFile(header);
user.header = getUrl(uploadedFile.name, config); user.header = getUrl(uploadedFile.name, config);
} }
if (locked) { if (locked) {
// Check if locked is a boolean // Check if locked is a boolean
if (locked !== "true" && locked !== "false") { if (locked !== "true" && locked !== "false") {
return errorResponse("Locked must be a boolean", 422); return errorResponse("Locked must be a boolean", 422);
} }
user.isLocked = locked === "true"; user.isLocked = locked === "true";
} }
if (bot) { if (bot) {
// Check if bot is a boolean // Check if bot is a boolean
if (bot !== "true" && bot !== "false") { if (bot !== "true" && bot !== "false") {
return errorResponse("Bot must be a boolean", 422); return errorResponse("Bot must be a boolean", 422);
} }
user.isBot = bot === "true"; user.isBot = bot === "true";
} }
if (discoverable) { if (discoverable) {
// Check if discoverable is a boolean // Check if discoverable is a boolean
if (discoverable !== "true" && discoverable !== "false") { if (discoverable !== "true" && discoverable !== "false") {
return errorResponse("Discoverable must be a boolean", 422); return errorResponse("Discoverable must be a boolean", 422);
} }
user.isDiscoverable = discoverable === "true"; user.isDiscoverable = discoverable === "true";
} }
// Parse emojis // Parse emojis
const displaynameEmojis = await parseEmojis(sanitizedDisplayName); const displaynameEmojis = await parseEmojis(sanitizedDisplayName);
const noteEmojis = await parseEmojis(sanitizedNote); const noteEmojis = await parseEmojis(sanitizedNote);
user.emojis = [...displaynameEmojis, ...noteEmojis]; user.emojis = [...displaynameEmojis, ...noteEmojis];
// Deduplicate emojis // Deduplicate emojis
user.emojis = user.emojis.filter( user.emojis = user.emojis.filter(
(emoji, index, self) => self.findIndex(e => e.id === emoji.id) === index (emoji, index, self) =>
); self.findIndex((e) => e.id === emoji.id) === index,
);
const output = await client.user.update({ const output = await client.user.update({
where: { id: user.id }, where: { id: user.id },
data: { data: {
displayName: user.displayName, displayName: user.displayName,
note: user.note, note: user.note,
avatar: user.avatar, avatar: user.avatar,
header: user.header, header: user.header,
isLocked: user.isLocked, isLocked: user.isLocked,
isBot: user.isBot, isBot: user.isBot,
isDiscoverable: user.isDiscoverable, isDiscoverable: user.isDiscoverable,
emojis: { emojis: {
disconnect: user.emojis.map(e => ({ disconnect: user.emojis.map((e) => ({
id: e.id, id: e.id,
})), })),
connect: user.emojis.map(e => ({ connect: user.emojis.map((e) => ({
id: e.id, id: e.id,
})), })),
}, },
source: user.source || undefined, source: user.source || undefined,
}, },
include: userRelations, include: userRelations,
}); });
return jsonResponse(userToAPI(output)); return jsonResponse(userToAPI(output));
}); });

View file

@ -1,28 +1,28 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { userToAPI } from "~database/entities/User"; import { userToAPI } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
route: "/api/v1/accounts/verify_credentials", route: "/api/v1/accounts/verify_credentials",
ratelimits: { ratelimits: {
max: 100, max: 100,
duration: 60, duration: 60,
}, },
auth: { auth: {
required: true, required: true,
oauthPermissions: ["read:accounts"], oauthPermissions: ["read:accounts"],
}, },
}); });
export default apiRoute((req, matchedRoute, extraData) => { export default apiRoute((req, matchedRoute, extraData) => {
// TODO: Add checks for disabled or not email verified accounts // TODO: Add checks for disabled or not email verified accounts
const { user } = extraData.auth; const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
return jsonResponse({ return jsonResponse({
...userToAPI(user, true), ...userToAPI(user, true),
}); });
}); });

View file

@ -1,65 +1,65 @@
import { randomBytes } from "node:crypto";
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { randomBytes } from "crypto";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
route: "/api/v1/apps", route: "/api/v1/apps",
ratelimits: { ratelimits: {
max: 2, max: 2,
duration: 60, duration: 60,
}, },
auth: { auth: {
required: false, required: false,
}, },
}); });
/** /**
* Creates a new application to obtain OAuth 2 credentials * Creates a new application to obtain OAuth 2 credentials
*/ */
export default apiRoute<{ export default apiRoute<{
client_name: string; client_name: string;
redirect_uris: string; redirect_uris: string;
scopes: string; scopes: string;
website: string; website: string;
}>(async (req, matchedRoute, extraData) => { }>(async (req, matchedRoute, extraData) => {
const { client_name, redirect_uris, scopes, website } = const { client_name, redirect_uris, scopes, website } =
extraData.parsedRequest; extraData.parsedRequest;
// Check if redirect URI is a valid URI, and also an absolute URI // Check if redirect URI is a valid URI, and also an absolute URI
if (redirect_uris) { if (redirect_uris) {
try { try {
const redirect_uri = new URL(redirect_uris); const redirect_uri = new URL(redirect_uris);
if (!redirect_uri.protocol.startsWith("http")) { if (!redirect_uri.protocol.startsWith("http")) {
return errorResponse( return errorResponse(
"Redirect URI must be an absolute URI", "Redirect URI must be an absolute URI",
422 422,
); );
} }
} catch { } catch {
return errorResponse("Redirect URI must be a valid URI", 422); return errorResponse("Redirect URI must be a valid URI", 422);
} }
} }
const application = await client.application.create({ const application = await client.application.create({
data: { data: {
name: client_name || "", name: client_name || "",
redirect_uris: redirect_uris || "", redirect_uris: redirect_uris || "",
scopes: scopes || "read", scopes: scopes || "read",
website: website || null, website: website || null,
client_id: randomBytes(32).toString("base64url"), client_id: randomBytes(32).toString("base64url"),
secret: randomBytes(64).toString("base64url"), secret: randomBytes(64).toString("base64url"),
}, },
}); });
return jsonResponse({ return jsonResponse({
id: application.id, id: application.id,
name: application.name, name: application.name,
website: application.website, website: application.website,
client_id: application.client_id, client_id: application.client_id,
client_secret: application.secret, client_secret: application.secret,
redirect_uri: application.redirect_uris, redirect_uri: application.redirect_uris,
vapid_link: application.vapid_key, vapid_link: application.vapid_key,
}); });
}); });

View file

@ -3,32 +3,32 @@ import { errorResponse, jsonResponse } from "@response";
import { getFromToken } from "~database/entities/Application"; import { getFromToken } from "~database/entities/Application";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
route: "/api/v1/apps/verify_credentials", route: "/api/v1/apps/verify_credentials",
ratelimits: { ratelimits: {
max: 100, max: 100,
duration: 60, duration: 60,
}, },
auth: { auth: {
required: true, required: true,
}, },
}); });
/** /**
* Returns OAuth2 credentials * Returns OAuth2 credentials
*/ */
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const { user, token } = extraData.auth; const { user, token } = extraData.auth;
const application = await getFromToken(token); const application = await getFromToken(token);
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
if (!application) return errorResponse("Unauthorized", 401); if (!application) return errorResponse("Unauthorized", 401);
return jsonResponse({ return jsonResponse({
name: application.name, name: application.name,
website: application.website, website: application.website,
vapid_key: application.vapid_key, vapid_key: application.vapid_key,
redirect_uris: application.redirect_uris, redirect_uris: application.redirect_uris,
scopes: application.scopes, scopes: application.scopes,
}); });
}); });

View file

@ -1,37 +1,37 @@
import { errorResponse, jsonResponse } from "@response";
import { userToAPI } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { userToAPI } from "~database/entities/User";
import { userRelations } from "~database/entities/relations"; import { userRelations } from "~database/entities/relations";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
route: "/api/v1/blocks", route: "/api/v1/blocks",
ratelimits: { ratelimits: {
max: 100, max: 100,
duration: 60, duration: 60,
}, },
auth: { auth: {
required: true, required: true,
}, },
}); });
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth; const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
const blocks = await client.user.findMany({ const blocks = await client.user.findMany({
where: { where: {
relationshipSubjects: { relationshipSubjects: {
some: { some: {
ownerId: user.id, ownerId: user.id,
blocking: true, blocking: true,
}, },
}, },
}, },
include: userRelations, include: userRelations,
}); });
return jsonResponse(blocks.map(u => userToAPI(u))); return jsonResponse(blocks.map((u) => userToAPI(u)));
}); });

View file

@ -4,25 +4,25 @@ import { client } from "~database/datasource";
import { emojiToAPI } from "~database/entities/Emoji"; import { emojiToAPI } from "~database/entities/Emoji";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
route: "/api/v1/custom_emojis", route: "/api/v1/custom_emojis",
ratelimits: { ratelimits: {
max: 100, max: 100,
duration: 60, duration: 60,
}, },
auth: { auth: {
required: false, required: false,
}, },
}); });
export default apiRoute(async () => { export default apiRoute(async () => {
const emojis = await client.emoji.findMany({ const emojis = await client.emoji.findMany({
where: { where: {
instanceId: null, instanceId: null,
}, },
}); });
return jsonResponse( return jsonResponse(
await Promise.all(emojis.map(emoji => emojiToAPI(emoji))) await Promise.all(emojis.map((emoji) => emojiToAPI(emoji))),
); );
}); });

View file

@ -1,74 +1,74 @@
import { errorResponse, jsonResponse } from "@response";
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { statusAndUserRelations } from "~database/entities/relations";
import { statusToAPI } from "~database/entities/Status"; import { statusToAPI } from "~database/entities/Status";
import { statusAndUserRelations } from "~database/entities/relations";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
route: "/api/v1/favourites", route: "/api/v1/favourites",
ratelimits: { ratelimits: {
max: 100, max: 100,
duration: 60, duration: 60,
}, },
auth: { auth: {
required: true, required: true,
}, },
}); });
export default apiRoute<{ export default apiRoute<{
max_id?: string; max_id?: string;
since_id?: string; since_id?: string;
min_id?: string; min_id?: string;
limit?: number; limit?: number;
}>(async (req, matchedRoute, extraData) => { }>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth; const { user } = extraData.auth;
const { limit = 20, max_id, min_id, since_id } = extraData.parsedRequest; const { limit = 20, max_id, min_id, since_id } = extraData.parsedRequest;
if (limit < 1 || limit > 40) { if (limit < 1 || limit > 40) {
return errorResponse("Limit must be between 1 and 40", 400); return errorResponse("Limit must be between 1 and 40", 400);
} }
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
const objects = await client.status.findMany({ const objects = await client.status.findMany({
where: { where: {
id: { id: {
lt: max_id ?? undefined, lt: max_id ?? undefined,
gte: since_id ?? undefined, gte: since_id ?? undefined,
gt: min_id ?? undefined, gt: min_id ?? undefined,
}, },
likes: { likes: {
some: { some: {
likerId: user.id, likerId: user.id,
}, },
}, },
}, },
include: statusAndUserRelations, include: statusAndUserRelations,
take: limit, take: limit,
orderBy: { orderBy: {
id: "desc", id: "desc",
}, },
}); });
// Constuct HTTP Link header (next and prev) // Constuct HTTP Link header (next and prev)
const linkHeader = []; const linkHeader = [];
if (objects.length > 0) { if (objects.length > 0) {
const urlWithoutQuery = req.url.split("?")[0]; const urlWithoutQuery = req.url.split("?")[0];
linkHeader.push( linkHeader.push(
`<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`, `<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`,
`<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"` `<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`,
); );
} }
return jsonResponse( return jsonResponse(
await Promise.all( await Promise.all(
objects.map(async status => statusToAPI(status, user)) objects.map(async (status) => statusToAPI(status, user)),
), ),
200, 200,
{ {
Link: linkHeader.join(", "), Link: linkHeader.join(", "),
} },
); );
}); });

View file

@ -1,75 +1,75 @@
import { errorResponse, jsonResponse } from "@response";
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { import {
checkForBidirectionalRelationships, checkForBidirectionalRelationships,
relationshipToAPI, relationshipToAPI,
} from "~database/entities/Relationship"; } from "~database/entities/Relationship";
import { userRelations } from "~database/entities/relations"; import { userRelations } from "~database/entities/relations";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
route: "/api/v1/follow_requests/:account_id/authorize", route: "/api/v1/follow_requests/:account_id/authorize",
ratelimits: { ratelimits: {
max: 100, max: 100,
duration: 60, duration: 60,
}, },
auth: { auth: {
required: true, required: true,
}, },
}); });
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth; const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
const { account_id } = matchedRoute.params; const { account_id } = matchedRoute.params;
const account = await client.user.findUnique({ const account = await client.user.findUnique({
where: { where: {
id: account_id, id: account_id,
}, },
include: userRelations, include: userRelations,
}); });
if (!account) return errorResponse("Account not found", 404); if (!account) return errorResponse("Account not found", 404);
// Check if there is a relationship on both sides // Check if there is a relationship on both sides
await checkForBidirectionalRelationships(user, account); await checkForBidirectionalRelationships(user, account);
// Authorize follow request // Authorize follow request
await client.relationship.updateMany({ await client.relationship.updateMany({
where: { where: {
subjectId: user.id, subjectId: user.id,
ownerId: account.id, ownerId: account.id,
requested: true, requested: true,
}, },
data: { data: {
requested: false, requested: false,
following: true, following: true,
}, },
}); });
// Update followedBy for other user // Update followedBy for other user
await client.relationship.updateMany({ await client.relationship.updateMany({
where: { where: {
subjectId: account.id, subjectId: account.id,
ownerId: user.id, ownerId: user.id,
}, },
data: { data: {
followedBy: true, followedBy: true,
}, },
}); });
const relationship = await client.relationship.findFirst({ const relationship = await client.relationship.findFirst({
where: { where: {
subjectId: account.id, subjectId: account.id,
ownerId: user.id, ownerId: user.id,
}, },
}); });
if (!relationship) return errorResponse("Relationship not found", 404); if (!relationship) return errorResponse("Relationship not found", 404);
return jsonResponse(relationshipToAPI(relationship)); return jsonResponse(relationshipToAPI(relationship));
}); });

View file

@ -1,63 +1,63 @@
import { errorResponse, jsonResponse } from "@response";
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { import {
checkForBidirectionalRelationships, checkForBidirectionalRelationships,
relationshipToAPI, relationshipToAPI,
} from "~database/entities/Relationship"; } from "~database/entities/Relationship";
import { userRelations } from "~database/entities/relations"; import { userRelations } from "~database/entities/relations";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
route: "/api/v1/follow_requests/:account_id/reject", route: "/api/v1/follow_requests/:account_id/reject",
ratelimits: { ratelimits: {
max: 100, max: 100,
duration: 60, duration: 60,
}, },
auth: { auth: {
required: true, required: true,
}, },
}); });
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth; const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
const { account_id } = matchedRoute.params; const { account_id } = matchedRoute.params;
const account = await client.user.findUnique({ const account = await client.user.findUnique({
where: { where: {
id: account_id, id: account_id,
}, },
include: userRelations, include: userRelations,
}); });
if (!account) return errorResponse("Account not found", 404); if (!account) return errorResponse("Account not found", 404);
// Check if there is a relationship on both sides // Check if there is a relationship on both sides
await checkForBidirectionalRelationships(user, account); await checkForBidirectionalRelationships(user, account);
// Reject follow request // Reject follow request
await client.relationship.updateMany({ await client.relationship.updateMany({
where: { where: {
subjectId: user.id, subjectId: user.id,
ownerId: account.id, ownerId: account.id,
requested: true, requested: true,
}, },
data: { data: {
requested: false, requested: false,
}, },
}); });
const relationship = await client.relationship.findFirst({ const relationship = await client.relationship.findFirst({
where: { where: {
subjectId: account.id, subjectId: account.id,
ownerId: user.id, ownerId: user.id,
}, },
}); });
if (!relationship) return errorResponse("Relationship not found", 404); if (!relationship) return errorResponse("Relationship not found", 404);
return jsonResponse(relationshipToAPI(relationship)); return jsonResponse(relationshipToAPI(relationship));
}); });

View file

@ -1,73 +1,73 @@
import { errorResponse, jsonResponse } from "@response";
import { userToAPI } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { userToAPI } from "~database/entities/User";
import { userRelations } from "~database/entities/relations"; import { userRelations } from "~database/entities/relations";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
route: "/api/v1/follow_requests", route: "/api/v1/follow_requests",
ratelimits: { ratelimits: {
max: 100, max: 100,
duration: 60, duration: 60,
}, },
auth: { auth: {
required: true, required: true,
}, },
}); });
export default apiRoute<{ export default apiRoute<{
max_id?: string; max_id?: string;
since_id?: string; since_id?: string;
min_id?: string; min_id?: string;
limit?: number; limit?: number;
}>(async (req, matchedRoute, extraData) => { }>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth; const { user } = extraData.auth;
const { limit = 20, max_id, min_id, since_id } = extraData.parsedRequest; const { limit = 20, max_id, min_id, since_id } = extraData.parsedRequest;
if (limit < 1 || limit > 40) { if (limit < 1 || limit > 40) {
return errorResponse("Limit must be between 1 and 40", 400); return errorResponse("Limit must be between 1 and 40", 400);
} }
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
const objects = await client.user.findMany({ const objects = await client.user.findMany({
where: { where: {
id: { id: {
lt: max_id ?? undefined, lt: max_id ?? undefined,
gte: since_id ?? undefined, gte: since_id ?? undefined,
gt: min_id ?? undefined, gt: min_id ?? undefined,
}, },
relationships: { relationships: {
some: { some: {
subjectId: user.id, subjectId: user.id,
requested: true, requested: true,
}, },
}, },
}, },
include: userRelations, include: userRelations,
take: limit, take: limit,
orderBy: { orderBy: {
id: "desc", id: "desc",
}, },
}); });
// Constuct HTTP Link header (next and prev) // Constuct HTTP Link header (next and prev)
const linkHeader = []; const linkHeader = [];
if (objects.length > 0) { if (objects.length > 0) {
const urlWithoutQuery = req.url.split("?")[0]; const urlWithoutQuery = req.url.split("?")[0];
linkHeader.push( linkHeader.push(
`<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`, `<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`,
`<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"` `<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`,
); );
} }
return jsonResponse( return jsonResponse(
objects.map(user => userToAPI(user)), objects.map((user) => userToAPI(user)),
200, 200,
{ {
Link: linkHeader.join(", "), Link: linkHeader.join(", "),
} },
); );
}); });

View file

@ -2,157 +2,157 @@ import { apiRoute, applyConfig } from "@api";
import { jsonResponse } from "@response"; import { jsonResponse } from "@response";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { userToAPI } from "~database/entities/User"; import { userToAPI } from "~database/entities/User";
import type { APIInstance } from "~types/entities/instance";
import manifest from "~package.json";
import { userRelations } from "~database/entities/relations"; import { userRelations } from "~database/entities/relations";
import manifest from "~package.json";
import type { APIInstance } from "~types/entities/instance";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
route: "/api/v1/instance", route: "/api/v1/instance",
ratelimits: { ratelimits: {
max: 300, max: 300,
duration: 60, duration: 60,
}, },
auth: { auth: {
required: false, required: false,
}, },
}); });
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const config = await extraData.configManager.getConfig(); const config = await extraData.configManager.getConfig();
// Get software version from package.json // Get software version from package.json
const version = manifest.version; const version = manifest.version;
const statusCount = await client.status.count({ const statusCount = await client.status.count({
where: { where: {
instanceId: null, instanceId: null,
}, },
}); });
const userCount = await client.user.count({ const userCount = await client.user.count({
where: { where: {
instanceId: null, instanceId: null,
}, },
}); });
// Get the first created admin user // Get the first created admin user
const contactAccount = await client.user.findFirst({ const contactAccount = await client.user.findFirst({
where: { where: {
instanceId: null, instanceId: null,
isAdmin: true, isAdmin: true,
}, },
orderBy: { orderBy: {
id: "asc", id: "asc",
}, },
include: userRelations, include: userRelations,
}); });
// Get user that have posted once in the last 30 days // Get user that have posted once in the last 30 days
const monthlyActiveUsers = await client.user.count({ const monthlyActiveUsers = await client.user.count({
where: { where: {
instanceId: null, instanceId: null,
statuses: { statuses: {
some: { some: {
createdAt: { createdAt: {
gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
}, },
}, },
}, },
}, },
}); });
const knownDomainsCount = await client.instance.count(); const knownDomainsCount = await client.instance.count();
// TODO: fill in more values // TODO: fill in more values
return jsonResponse({ return jsonResponse({
approval_required: false, approval_required: false,
configuration: { configuration: {
media_attachments: { media_attachments: {
image_matrix_limit: config.validation.max_media_attachments, image_matrix_limit: config.validation.max_media_attachments,
image_size_limit: config.validation.max_media_size, image_size_limit: config.validation.max_media_size,
supported_mime_types: config.validation.allowed_mime_types, supported_mime_types: config.validation.allowed_mime_types,
video_frame_limit: 60, video_frame_limit: 60,
video_matrix_limit: 10, video_matrix_limit: 10,
video_size_limit: config.validation.max_media_size, video_size_limit: config.validation.max_media_size,
}, },
polls: { polls: {
max_characters_per_option: max_characters_per_option:
config.validation.max_poll_option_size, config.validation.max_poll_option_size,
max_expiration: config.validation.max_poll_duration, max_expiration: config.validation.max_poll_duration,
max_options: config.validation.max_poll_options, max_options: config.validation.max_poll_options,
min_expiration: 60, min_expiration: 60,
}, },
statuses: { statuses: {
characters_reserved_per_url: 0, characters_reserved_per_url: 0,
max_characters: config.validation.max_note_size, max_characters: config.validation.max_note_size,
max_media_attachments: config.validation.max_media_attachments, max_media_attachments: config.validation.max_media_attachments,
supported_mime_types: [ supported_mime_types: [
"text/plain", "text/plain",
"text/markdown", "text/markdown",
"text/html", "text/html",
"text/x.misskeymarkdown", "text/x.misskeymarkdown",
], ],
}, },
}, },
description: "A test instance", description: "A test instance",
email: "", email: "",
invites_enabled: false, invites_enabled: false,
registrations: config.signups.registration, registrations: config.signups.registration,
languages: ["en"], languages: ["en"],
rules: config.signups.rules.map((r, index) => ({ rules: config.signups.rules.map((r, index) => ({
id: String(index), id: String(index),
text: r, text: r,
})), })),
stats: { stats: {
domain_count: knownDomainsCount, domain_count: knownDomainsCount,
status_count: statusCount, status_count: statusCount,
user_count: userCount, user_count: userCount,
}, },
thumbnail: "", thumbnail: "",
tos_url: config.signups.tos_url, tos_url: config.signups.tos_url,
title: "Test Instance", title: "Test Instance",
uri: new URL(config.http.base_url).hostname, uri: new URL(config.http.base_url).hostname,
urls: { urls: {
streaming_api: "", streaming_api: "",
}, },
version: `4.2.0+glitch (compatible; Lysand ${version}})`, version: `4.2.0+glitch (compatible; Lysand ${version}})`,
max_toot_chars: config.validation.max_note_size, max_toot_chars: config.validation.max_note_size,
pleroma: { pleroma: {
metadata: { metadata: {
// account_activation_required: false, // account_activation_required: false,
features: [ features: [
"pleroma_api", "pleroma_api",
"akkoma_api", "akkoma_api",
"mastodon_api", "mastodon_api",
// "mastodon_api_streaming", // "mastodon_api_streaming",
// "polls", // "polls",
// "v2_suggestions", // "v2_suggestions",
// "pleroma_explicit_addressing", // "pleroma_explicit_addressing",
// "shareable_emoji_packs", // "shareable_emoji_packs",
// "multifetch", // "multifetch",
// "pleroma:api/v1/notifications:include_types_filter", // "pleroma:api/v1/notifications:include_types_filter",
"quote_posting", "quote_posting",
"editing", "editing",
// "bubble_timeline", // "bubble_timeline",
// "relay", // "relay",
// "pleroma_emoji_reactions", // "pleroma_emoji_reactions",
// "exposable_reactions", // "exposable_reactions",
// "profile_directory", // "profile_directory",
// "custom_emoji_reactions", // "custom_emoji_reactions",
// "pleroma:get:main/ostatus", // "pleroma:get:main/ostatus",
], ],
post_formats: [ post_formats: [
"text/plain", "text/plain",
"text/html", "text/html",
"text/markdown", "text/markdown",
"text/x.misskeymarkdown", "text/x.misskeymarkdown",
], ],
privileged_staff: false, privileged_staff: false,
}, },
stats: { stats: {
mau: monthlyActiveUsers, mau: monthlyActiveUsers,
}, },
}, },
contact_account: contactAccount ? userToAPI(contactAccount) : null, contact_account: contactAccount ? userToAPI(contactAccount) : null,
} as APIInstance); } as APIInstance);
}); });

View file

@ -1,109 +1,108 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import { attachmentToAPI, getUrl } from "~database/entities/Attachment";
import type { MediaBackend } from "media-manager"; import type { MediaBackend } from "media-manager";
import { MediaBackendType } from "media-manager"; import { MediaBackendType } from "media-manager";
import { client } from "~database/datasource";
import { attachmentToAPI, getUrl } from "~database/entities/Attachment";
import { LocalMediaBackend } from "~packages/media-manager/backends/local"; import { LocalMediaBackend } from "~packages/media-manager/backends/local";
import { S3MediaBackend } from "~packages/media-manager/backends/s3"; import { S3MediaBackend } from "~packages/media-manager/backends/s3";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET", "PUT"], allowedMethods: ["GET", "PUT"],
ratelimits: { ratelimits: {
max: 10, max: 10,
duration: 60, duration: 60,
}, },
route: "/api/v1/media/:id", route: "/api/v1/media/:id",
auth: { auth: {
required: true, required: true,
oauthPermissions: ["write:media"], oauthPermissions: ["write:media"],
}, },
}); });
/** /**
* Get media information * Get media information
*/ */
export default apiRoute<{ export default apiRoute<{
thumbnail?: File; thumbnail?: File;
description?: string; description?: string;
focus?: string; focus?: string;
}>(async (req, matchedRoute, extraData) => { }>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth; const { user } = extraData.auth;
if (!user) { if (!user) {
return errorResponse("Unauthorized", 401); return errorResponse("Unauthorized", 401);
} }
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
const attachment = await client.attachment.findUnique({ const attachment = await client.attachment.findUnique({
where: { where: {
id, id,
}, },
}); });
if (!attachment) { if (!attachment) {
return errorResponse("Media not found", 404); return errorResponse("Media not found", 404);
} }
const config = await extraData.configManager.getConfig(); const config = await extraData.configManager.getConfig();
switch (req.method) { switch (req.method) {
case "GET": { case "GET": {
if (attachment.url) { if (attachment.url) {
return jsonResponse(attachmentToAPI(attachment)); return jsonResponse(attachmentToAPI(attachment));
} else { }
return new Response(null, { return new Response(null, {
status: 206, status: 206,
}); });
} }
} case "PUT": {
case "PUT": { const { description, thumbnail } = extraData.parsedRequest;
const { description, thumbnail } = extraData.parsedRequest;
let thumbnailUrl = attachment.thumbnail_url; let thumbnailUrl = attachment.thumbnail_url;
let mediaManager: MediaBackend; let mediaManager: MediaBackend;
switch (config.media.backend as MediaBackendType) { switch (config.media.backend as MediaBackendType) {
case MediaBackendType.LOCAL: case MediaBackendType.LOCAL:
mediaManager = new LocalMediaBackend(config); mediaManager = new LocalMediaBackend(config);
break; break;
case MediaBackendType.S3: case MediaBackendType.S3:
mediaManager = new S3MediaBackend(config); mediaManager = new S3MediaBackend(config);
break; break;
default: default:
// TODO: Replace with logger // TODO: Replace with logger
throw new Error("Invalid media backend"); throw new Error("Invalid media backend");
} }
if (thumbnail) { if (thumbnail) {
const { uploadedFile } = await mediaManager.addFile(thumbnail); const { uploadedFile } = await mediaManager.addFile(thumbnail);
thumbnailUrl = getUrl(uploadedFile.name, config); thumbnailUrl = getUrl(uploadedFile.name, config);
} }
const descriptionText = description || attachment.description; const descriptionText = description || attachment.description;
if ( if (
descriptionText !== attachment.description || descriptionText !== attachment.description ||
thumbnailUrl !== attachment.thumbnail_url thumbnailUrl !== attachment.thumbnail_url
) { ) {
const newAttachment = await client.attachment.update({ const newAttachment = await client.attachment.update({
where: { where: {
id, id,
}, },
data: { data: {
description: descriptionText, description: descriptionText,
thumbnail_url: thumbnailUrl, thumbnail_url: thumbnailUrl,
}, },
}); });
return jsonResponse(attachmentToAPI(newAttachment)); return jsonResponse(attachmentToAPI(newAttachment));
} }
return jsonResponse(attachmentToAPI(attachment)); return jsonResponse(attachmentToAPI(attachment));
} }
} }
return errorResponse("Method not allowed", 405); return errorResponse("Method not allowed", 405);
}); });

View file

@ -1,136 +1,136 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource";
import { encode } from "blurhash"; import { encode } from "blurhash";
import sharp from "sharp";
import { attachmentToAPI, getUrl } from "~database/entities/Attachment";
import { MediaBackendType } from "media-manager"; import { MediaBackendType } from "media-manager";
import type { MediaBackend } from "media-manager"; import type { MediaBackend } from "media-manager";
import sharp from "sharp";
import { client } from "~database/datasource";
import { attachmentToAPI, getUrl } from "~database/entities/Attachment";
import { LocalMediaBackend } from "~packages/media-manager/backends/local"; import { LocalMediaBackend } from "~packages/media-manager/backends/local";
import { S3MediaBackend } from "~packages/media-manager/backends/s3"; import { S3MediaBackend } from "~packages/media-manager/backends/s3";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
ratelimits: { ratelimits: {
max: 10, max: 10,
duration: 60, duration: 60,
}, },
route: "/api/v1/media", route: "/api/v1/media",
auth: { auth: {
required: true, required: true,
oauthPermissions: ["write:media"], oauthPermissions: ["write:media"],
}, },
}); });
/** /**
* Upload new media * Upload new media
*/ */
export default apiRoute<{ export default apiRoute<{
file: File; file: File;
thumbnail?: File; thumbnail?: File;
description?: string; description?: string;
// TODO: Add focus // TODO: Add focus
focus?: string; focus?: string;
}>(async (req, matchedRoute, extraData) => { }>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth; const { user } = extraData.auth;
if (!user) { if (!user) {
return errorResponse("Unauthorized", 401); return errorResponse("Unauthorized", 401);
} }
const { file, thumbnail, description } = extraData.parsedRequest; const { file, thumbnail, description } = extraData.parsedRequest;
if (!file) { if (!file) {
return errorResponse("No file provided", 400); return errorResponse("No file provided", 400);
} }
const config = await extraData.configManager.getConfig(); const config = await extraData.configManager.getConfig();
if (file.size > config.validation.max_media_size) { if (file.size > config.validation.max_media_size) {
return errorResponse( return errorResponse(
`File too large, max size is ${config.validation.max_media_size} bytes`, `File too large, max size is ${config.validation.max_media_size} bytes`,
413 413,
); );
} }
if ( if (
config.validation.enforce_mime_types && config.validation.enforce_mime_types &&
!config.validation.allowed_mime_types.includes(file.type) !config.validation.allowed_mime_types.includes(file.type)
) { ) {
return errorResponse("Invalid file type", 415); return errorResponse("Invalid file type", 415);
} }
if ( if (
description && description &&
description.length > config.validation.max_media_description_size description.length > config.validation.max_media_description_size
) { ) {
return errorResponse( return errorResponse(
`Description too long, max length is ${config.validation.max_media_description_size} characters`, `Description too long, max length is ${config.validation.max_media_description_size} characters`,
413 413,
); );
} }
const sha256 = new Bun.SHA256(); const sha256 = new Bun.SHA256();
const isImage = file.type.startsWith("image/"); const isImage = file.type.startsWith("image/");
const metadata = isImage const metadata = isImage
? await sharp(await file.arrayBuffer()).metadata() ? await sharp(await file.arrayBuffer()).metadata()
: null; : null;
const blurhash = isImage const blurhash = isImage
? encode( ? encode(
new Uint8ClampedArray(await file.arrayBuffer()), new Uint8ClampedArray(await file.arrayBuffer()),
metadata?.width ?? 0, metadata?.width ?? 0,
metadata?.height ?? 0, metadata?.height ?? 0,
4, 4,
4 4,
) )
: null; : null;
let url = ""; let url = "";
let mediaManager: MediaBackend; let mediaManager: MediaBackend;
switch (config.media.backend as MediaBackendType) { switch (config.media.backend as MediaBackendType) {
case MediaBackendType.LOCAL: case MediaBackendType.LOCAL:
mediaManager = new LocalMediaBackend(config); mediaManager = new LocalMediaBackend(config);
break; break;
case MediaBackendType.S3: case MediaBackendType.S3:
mediaManager = new S3MediaBackend(config); mediaManager = new S3MediaBackend(config);
break; break;
default: default:
// TODO: Replace with logger // TODO: Replace with logger
throw new Error("Invalid media backend"); throw new Error("Invalid media backend");
} }
const { uploadedFile } = await mediaManager.addFile(file); const { uploadedFile } = await mediaManager.addFile(file);
url = getUrl(uploadedFile.name, config); url = getUrl(uploadedFile.name, config);
let thumbnailUrl = ""; let thumbnailUrl = "";
if (thumbnail) { if (thumbnail) {
const { uploadedFile } = await mediaManager.addFile(thumbnail); const { uploadedFile } = await mediaManager.addFile(thumbnail);
thumbnailUrl = getUrl(uploadedFile.name, config); thumbnailUrl = getUrl(uploadedFile.name, config);
} }
const newAttachment = await client.attachment.create({ const newAttachment = await client.attachment.create({
data: { data: {
url, url,
thumbnail_url: thumbnailUrl, thumbnail_url: thumbnailUrl,
sha256: sha256.update(await file.arrayBuffer()).digest("hex"), sha256: sha256.update(await file.arrayBuffer()).digest("hex"),
mime_type: file.type, mime_type: file.type,
description: description ?? "", description: description ?? "",
size: file.size, size: file.size,
blurhash: blurhash ?? undefined, blurhash: blurhash ?? undefined,
width: metadata?.width ?? undefined, width: metadata?.width ?? undefined,
height: metadata?.height ?? undefined, height: metadata?.height ?? undefined,
}, },
}); });
// TODO: Add job to process videos and other media // TODO: Add job to process videos and other media
return jsonResponse(attachmentToAPI(newAttachment)); return jsonResponse(attachmentToAPI(newAttachment));
}); });

View file

@ -1,37 +1,37 @@
import { errorResponse, jsonResponse } from "@response";
import { userToAPI } from "~database/entities/User";
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { userToAPI } from "~database/entities/User";
import { userRelations } from "~database/entities/relations"; import { userRelations } from "~database/entities/relations";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
route: "/api/v1/mutes", route: "/api/v1/mutes",
ratelimits: { ratelimits: {
max: 100, max: 100,
duration: 60, duration: 60,
}, },
auth: { auth: {
required: true, required: true,
}, },
}); });
export default apiRoute(async (req, matchedRoute, extraData) => { export default apiRoute(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth; const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
const blocks = await client.user.findMany({ const blocks = await client.user.findMany({
where: { where: {
relationshipSubjects: { relationshipSubjects: {
some: { some: {
ownerId: user.id, ownerId: user.id,
muting: true, muting: true,
}, },
}, },
}, },
include: userRelations, include: userRelations,
}); });
return jsonResponse(blocks.map(u => userToAPI(u))); return jsonResponse(blocks.map((u) => userToAPI(u)));
}); });

Some files were not shown because too many files have changed in this diff Show more