Merge pull request #9 from lysand-org/refactor/packages

refactor: Refactor Lysand into submodules
This commit is contained in:
Gaspard Wierzbinski 2024-03-10 19:57:27 -10:00 committed by GitHub
commit ae857cd4fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
138 changed files with 4345 additions and 2703 deletions

View file

@ -9,7 +9,7 @@ module.exports = {
parserOptions: { parserOptions: {
project: "./tsconfig.json", project: "./tsconfig.json",
}, },
ignorePatterns: ["node_modules/", "dist/", ".eslintrc.cjs"], ignorePatterns: ["node_modules/", "dist/", ".eslintrc.cjs", "cli.ts"],
plugins: ["@typescript-eslint"], plugins: ["@typescript-eslint"],
root: true, root: true,
rules: { rules: {

2
.gitignore vendored
View file

@ -171,3 +171,5 @@ config/config.toml
config/config.internal.toml config/config.internal.toml
uploads/ uploads/
pages/dist pages/dist
log.txt
*.log

18
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,18 @@
{
"configurations": [
{
"type": "node",
"name": "vscode-jest-tests.v2.lysand",
"request": "launch",
"args": [
"test",
"${jest.testFile}"
],
"cwd": "/home/jessew/Dev/lysand",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"disableOptimisticBPs": true,
"program": "/home/jessew/.bun/bin/bun"
}
]
}

View file

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

View file

@ -1,11 +1,10 @@
# use the official Bun image # use the official Bun image
# see all versions at https://hub.docker.com/r/oven/bun/tags # see all versions at https://hub.docker.com/r/oven/bun/tags
FROM oven/bun:1.0.15-alpine as base FROM oven/bun:1.0.30-alpine as base
WORKDIR /usr/src/app WORKDIR /usr/src/app
RUN apk add vips
# Required for Prisma to work # Required for Prisma to work
COPY --from=node:18-alpine /usr/local/bin/node /usr/local/bin/node # COPY --from=node:18-alpine /usr/local/bin/node /usr/local/bin/node
# install dependencies into temp directory # install dependencies into temp directory
# this will cache them and speed up future builds # this will cache them and speed up future builds
@ -15,15 +14,13 @@ FROM base AS install
RUN mkdir -p /temp RUN mkdir -p /temp
COPY . /temp COPY . /temp
WORKDIR /temp WORKDIR /temp
RUN bun install --frozen-lockfile --production. RUN bun install --frozen-lockfile --production
# Build Vite in pages # Build Vite in pages
RUN bunx --bun vite build pages RUN bunx --bun vite build pages
# Build the project # Build the project
RUN bun build --entrypoints ./index.ts ./prisma.ts ./cli.ts --outdir dist --target bun --splitting --minify --external bullmq --external @prisma/client RUN bun run build.ts
RUN mkdir ./dist/pages
RUN cp -r ./pages/dist ./dist/pages
WORKDIR /temp/dist WORKDIR /temp/dist
# copy production dependencies and source code into final image # copy production dependencies and source code into final image

View file

@ -4,14 +4,11 @@
![Postgres](https://img.shields.io/badge/postgres-%23316192.svg?style=for-the-badge&logo=postgresql&logoColor=white) ![Bun](https://img.shields.io/badge/Bun-%23000000.svg?style=for-the-badge&logo=bun&logoColor=white) ![VS Code Insiders](https://img.shields.io/badge/VS%20Code%20Insiders-35b393.svg?style=for-the-badge&logo=visual-studio-code&logoColor=white) ![TypeScript](https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white) ![Linux](https://img.shields.io/badge/Linux-FCC624?style=for-the-badge&logo=linux&logoColor=black) ![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white) ![ESLint](https://img.shields.io/badge/ESLint-4B3263?style=for-the-badge&logo=eslint&logoColor=white) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa?style=for-the-badge)](code_of_conduct.md) ![Postgres](https://img.shields.io/badge/postgres-%23316192.svg?style=for-the-badge&logo=postgresql&logoColor=white) ![Bun](https://img.shields.io/badge/Bun-%23000000.svg?style=for-the-badge&logo=bun&logoColor=white) ![VS Code Insiders](https://img.shields.io/badge/VS%20Code%20Insiders-35b393.svg?style=for-the-badge&logo=visual-studio-code&logoColor=white) ![TypeScript](https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white) ![Linux](https://img.shields.io/badge/Linux-FCC624?style=for-the-badge&logo=linux&logoColor=black) ![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white) ![ESLint](https://img.shields.io/badge/ESLint-4B3263?style=for-the-badge&logo=eslint&logoColor=white) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa?style=for-the-badge)](code_of_conduct.md)
> [!IMPORTANT]
> This project is **not abandoned**, my laptop merely broke and I am waiting for a new one to arrive
## What is this? ## What is this?
This is a project to create a federated social network based on the [Lysand](https://lysand.org) protocol. It is currently in alpha phase, with basic federation and API support. This is a project to create a federated social network based on the [Lysand](https://lysand.org) protocol. It is currently in alpha phase, with basic federation and almost complete Mastodon API support.
This project aims to be a fully featured social network, with a focus on privacy, security, and performance. It will implement the Mastodon API for support with clients that already support Mastodon or Pleroma. This project aims to be a fully featured social network, with a focus on privacy, security, and performance. It implements the Mastodon API for support with clients that already support Mastodon or Pleroma.
> [!NOTE] > [!NOTE]
> This project is not affiliated with Mastodon or Pleroma, and is not a fork of either project. It is a new project built from the ground up. > This project is not affiliated with Mastodon or Pleroma, and is not a fork of either project. It is a new project built from the ground up.
@ -35,7 +32,7 @@ This project aims to be a fully featured social network, with a focus on privacy
## Benchmarks ## Benchmarks
> [!NOTE] > [!NOTE]
> These benchmarks are not representative of real-world performance, and are only meant to be used as a rough guide. > These benchmarks are not representative of real-world performance, and are only meant to be used as a rough guide. Load, and therefore performance, will vary depending on the server's hardware and software configuration, as well as user activity.
### Timeline Benchmarks ### Timeline Benchmarks
@ -67,18 +64,21 @@ $ bun run benchmarks/timelines.ts 10000
✓ 10000 requests fulfilled in 12.44852s ✓ 10000 requests fulfilled in 12.44852s
``` ```
Lysand is extremely fast and can handle tens of thousands of HTTP requests per second on a good server. Lysand is extremely fast and can handle thousands of HTTP requests per second on a good server.
## How do I run it? ## How do I run it?
### Requirements ### Requirements
- The [Bun Runtime](https://bun.sh), version 1.0.5 or later (usage of the latest version is recommended) - The [Bun Runtime](https://bun.sh), version 1.0.30 or later (usage of the latest version is recommended)
- A PostgreSQL database - A PostgreSQL database
- (Optional but recommended) A Linux-based operating system - (Optional but recommended) A Linux-based operating system
- (Optional if you want search) A working Meiliseach instance - (Optional if you want search) A working Meiliseach instance
> **Note**: We will not be offerring support to Windows or MacOS users. If you are using one of these operating systems, please use a virtual machine or container to run Lysand. > [!WARNING]
> Lysand has not been tested on Windows or MacOS. It is recommended to use a Linux-based operating system to run Lysand.
>
> We will not be offerring support to Windows or MacOS users. If you are using one of these operating systems, please use a virtual machine or container to run Lysand.
### Installation ### Installation
@ -152,6 +152,9 @@ bun start
### Using the CLI ### Using the CLI
> [!WARNING]
> The CLI is currently broken due to unknown bugs that are actively being investigated. The following instructions are for when this is fixed.
Lysand includes a built-in CLI for managing the server. To use it, simply run the following command: Lysand includes a built-in CLI for managing the server. To use it, simply run the following command:
```bash ```bash
@ -279,10 +282,12 @@ Working endpoints are:
- `/api/v1/blocks` - `/api/v1/blocks`
- `/api/v1/mutes` - `/api/v1/mutes`
- `/api/v2/media` - `/api/v2/media`
- `/api/v1/notifications`
Tests needed but completed: Tests needed but completed:
- `/api/v1/media/:id` - `/api/v1/media/:id`
- `/api/v2/media`
- `/api/v1/favourites` - `/api/v1/favourites`
- `/api/v1/accounts/:id/followers` - `/api/v1/accounts/:id/followers`
- `/api/v1/accounts/:id/following` - `/api/v1/accounts/:id/following`
@ -335,7 +340,6 @@ Endpoints left:
- `/api/v1/lists/:id` (`GET`, `PUT`, `DELETE`) - `/api/v1/lists/:id` (`GET`, `PUT`, `DELETE`)
- `/api/v1/markers` (`GET`, `POST`) - `/api/v1/markers` (`GET`, `POST`)
- `/api/v1/lists/:id/accounts` (`GET`, `POST`, `DELETE`) - `/api/v1/lists/:id/accounts` (`GET`, `POST`, `DELETE`)
- `/api/v1/notifications`
- `/api/v1/notifications/:id` - `/api/v1/notifications/:id`
- `/api/v1/notifications/clear` - `/api/v1/notifications/clear`
- `/api/v1/notifications/:id/dismiss` - `/api/v1/notifications/:id/dismiss`

View file

@ -2,10 +2,10 @@
* Usage: TOKEN=your_token_here bun benchmark:timeline <request_count> * Usage: TOKEN=your_token_here bun benchmark:timeline <request_count>
*/ */
import { getConfig } from "~classes/configmanager";
import chalk from "chalk"; import chalk from "chalk";
import { ConfigManager } from "config-manager";
const config = getConfig(); const config = await new ConfigManager({}).getConfig();
const token = process.env.TOKEN; const token = process.env.TOKEN;
const requestCount = Number(process.argv[2]) || 100; const requestCount = Number(process.argv[2]) || 100;

View file

@ -17,13 +17,17 @@ 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",
], ],
outdir: process.cwd() + "/dist", outdir: process.cwd() + "/dist",
target: "bun", target: "bun",
splitting: true, splitting: true,
minify: true, minify: true,
external: ["bullmq", "@prisma/client"], external: ["bullmq"],
}).then(output => {
if (!output.success) {
console.log(output.logs);
}
}); });
// Create pages directory // Create pages directory

BIN
bun.lockb

Binary file not shown.

2
bunfig.toml Normal file
View file

@ -0,0 +1,2 @@
[install.scopes]
"@jsr" = "https://npm.jsr.io"

View file

@ -1,444 +0,0 @@
/**
* @file configmanager.ts
* @summary ConfigManager system to retrieve and modify system configuration
* @description Can read from a hand-written file, config.toml, or from a machine-saved file, config.internal.toml
* Fuses both and provides a way to retrieve individual values
*/
import { parse, stringify } from "@iarna/toml";
import chalk from "chalk";
import merge from "merge-deep-ts";
const scanConfig = async () => {
const config = Bun.file(process.cwd() + "/config/config.toml");
if (!(await config.exists())) {
console.error(
`${chalk.red(``)} ${chalk.bold(
"Error while reading config: "
)} Config file not found`
);
process.exit(1);
}
return parse(await config.text()) as ConfigType;
};
// Creates the internal config with nothing in it if it doesnt exist
const scanInternalConfig = async () => {
const config = Bun.file(process.cwd() + "/config/config.internal.toml");
if (!(await config.exists())) {
await Bun.write(config, "");
}
return parse(await config.text()) as ConfigType;
};
let config = await scanConfig();
const internalConfig = await scanInternalConfig();
export interface ConfigType {
database: {
host: string;
port: number;
username: string;
password: string;
database: string;
};
redis: {
queue: {
host: string;
port: number;
password: string;
database: number | null;
};
cache: {
host: string;
port: number;
password: string;
database: number | null;
enabled: boolean;
};
};
meilisearch: {
host: string;
port: number;
api_key: string;
enabled: boolean;
};
signups: {
tos_url: string;
rules: string[];
registration: boolean;
};
oidc: {
providers: {
name: string;
id: string;
url: string;
client_id: string;
client_secret: string;
icon: string;
}[];
};
http: {
base_url: string;
bind: string;
bind_port: string;
banned_ips: string[];
banned_user_agents: string[];
};
instance: {
name: string;
description: string;
banner: string;
logo: string;
};
smtp: {
server: string;
port: number;
username: string;
password: string;
tls: boolean;
};
validation: {
max_displayname_size: number;
max_bio_size: number;
max_username_size: number;
max_note_size: number;
max_avatar_size: number;
max_header_size: number;
max_media_size: number;
max_media_attachments: number;
max_media_description_size: number;
max_poll_options: number;
max_poll_option_size: number;
min_poll_duration: number;
max_poll_duration: number;
username_blacklist: string[];
blacklist_tempmail: boolean;
email_blacklist: string[];
url_scheme_whitelist: string[];
enforce_mime_types: boolean;
allowed_mime_types: string[];
};
media: {
backend: string;
deduplicate_media: boolean;
conversion: {
convert_images: boolean;
convert_to: string;
};
};
s3: {
endpoint: string;
access_key: string;
secret_access_key: string;
region: string;
bucket_name: string;
public_url: string;
};
defaults: {
visibility: string;
language: string;
avatar: string;
header: string;
};
email: {
send_on_report: boolean;
send_on_suspend: boolean;
send_on_unsuspend: boolean;
};
activitypub: {
use_tombstones: boolean;
reject_activities: string[];
force_followers_only: string[];
discard_reports: string[];
discard_deletes: string[];
discard_banners: string[];
discard_avatars: string[];
discard_updates: string[];
discard_follows: string[];
force_sensitive: string[];
remove_media: string[];
fetch_all_collection_members: boolean;
authorized_fetch: boolean;
};
filters: {
note_filters: string[];
username_filters: string[];
displayname_filters: string[];
bio_filters: string[];
emoji_filters: string[];
};
logging: {
log_requests: boolean;
log_requests_verbose: boolean;
log_filters: boolean;
};
ratelimits: {
duration_coeff: number;
max_coeff: number;
};
custom_ratelimits: Record<
string,
{
duration: number;
max: number;
}
>;
[key: string]: unknown;
}
export const configDefaults: ConfigType = {
http: {
bind: "http://0.0.0.0",
bind_port: "8000",
base_url: "http://lysand.localhost:8000",
banned_ips: [],
banned_user_agents: [],
},
database: {
host: "localhost",
port: 5432,
username: "postgres",
password: "postgres",
database: "lysand",
},
redis: {
queue: {
host: "localhost",
port: 6379,
password: "",
database: 0,
},
cache: {
host: "localhost",
port: 6379,
password: "",
database: 1,
enabled: false,
},
},
meilisearch: {
host: "localhost",
port: 1491,
api_key: "",
enabled: false,
},
signups: {
tos_url: "",
rules: [],
registration: false,
},
oidc: {
providers: [],
},
instance: {
banner: "",
description: "",
logo: "",
name: "",
},
smtp: {
password: "",
port: 465,
server: "",
tls: true,
username: "",
},
media: {
backend: "local",
deduplicate_media: true,
conversion: {
convert_images: false,
convert_to: "webp",
},
},
email: {
send_on_report: false,
send_on_suspend: false,
send_on_unsuspend: false,
},
s3: {
access_key: "",
bucket_name: "",
endpoint: "",
public_url: "",
region: "",
secret_access_key: "",
},
validation: {
max_displayname_size: 50,
max_bio_size: 6000,
max_note_size: 5000,
max_avatar_size: 5_000_000,
max_header_size: 5_000_000,
max_media_size: 40_000_000,
max_media_attachments: 10,
max_media_description_size: 1000,
max_poll_options: 20,
max_poll_option_size: 500,
min_poll_duration: 60,
max_poll_duration: 1893456000,
max_username_size: 30,
username_blacklist: [
".well-known",
"~",
"about",
"activities",
"api",
"auth",
"dev",
"inbox",
"internal",
"main",
"media",
"nodeinfo",
"notice",
"oauth",
"objects",
"proxy",
"push",
"registration",
"relay",
"settings",
"status",
"tag",
"users",
"web",
"search",
"mfa",
],
blacklist_tempmail: false,
email_blacklist: [],
url_scheme_whitelist: [
"http",
"https",
"ftp",
"dat",
"dweb",
"gopher",
"hyper",
"ipfs",
"ipns",
"irc",
"xmpp",
"ircs",
"magnet",
"mailto",
"mumble",
"ssb",
],
enforce_mime_types: false,
allowed_mime_types: [],
},
defaults: {
visibility: "public",
language: "en",
avatar: "",
header: "",
},
activitypub: {
use_tombstones: true,
reject_activities: [],
force_followers_only: [],
discard_reports: [],
discard_deletes: [],
discard_banners: [],
discard_avatars: [],
force_sensitive: [],
discard_updates: [],
discard_follows: [],
remove_media: [],
fetch_all_collection_members: false,
authorized_fetch: false,
},
filters: {
note_filters: [],
username_filters: [],
displayname_filters: [],
bio_filters: [],
emoji_filters: [],
},
logging: {
log_requests: false,
log_requests_verbose: false,
log_filters: true,
},
ratelimits: {
duration_coeff: 1,
max_coeff: 1,
},
custom_ratelimits: {},
};
export const getConfig = () => {
// Deeply merge configDefaults, config and internalConfig
return merge([configDefaults, config, internalConfig]) as any as ConfigType;
};
/**
* Sets the internal config
* @param newConfig Any part of ConfigType
*/
export const setConfig = async (newConfig: Partial<ConfigType>) => {
const newInternalConfig = merge([
internalConfig,
newConfig,
]) as any as ConfigType;
// Prepend a warning comment and write the new TOML to the file
await Bun.write(
Bun.file(process.cwd() + "/config/config.internal.toml"),
`# This file is automatically generated. Do not modify it manually.\n${stringify(
newInternalConfig as any
)}`
);
};
export const getHost = () => {
const url = new URL(getConfig().http.base_url);
return url.host;
};
// Refresh config every 5 seconds
setInterval(() => {
scanConfig()
.then(newConfig => {
if (newConfig !== config) {
config = newConfig;
}
})
.catch(e => {
console.error(e);
});
}, 5000);
export { config };

View file

@ -1,273 +0,0 @@
import type { GetObjectCommandOutput } from "@aws-sdk/client-s3";
import {
GetObjectCommand,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import type { ConfigType } from "~classes/configmanager";
import sharp from "sharp";
import { exists, mkdir } from "fs/promises";
class MediaBackend {
backend: string;
constructor(backend: string) {
this.backend = backend;
}
/**
* Adds media to the media backend
* @param media
* @returns The hash of the file in SHA-256 (hex format) with the file extension added to it
*/
async addMedia(media: File) {
const hash = new Bun.SHA256()
.update(await media.arrayBuffer())
.digest("hex");
return `${hash}.${media.name.split(".").pop()}`;
}
async convertMedia(media: File, config: ConfigType) {
const sharpCommand = sharp(await media.arrayBuffer());
// Rename ".jpg" files to ".jpeg" to avoid sharp errors
let name = media.name;
if (media.name.endsWith(".jpg")) {
name = media.name.replace(".jpg", ".jpeg");
}
const fileFormatToConvertTo = config.media.conversion.convert_to;
switch (fileFormatToConvertTo) {
case "png":
return new File(
[(await sharpCommand.png().toBuffer()).buffer] as any,
// Replace the file extension with PNG
name.replace(/\.[^/.]+$/, ".png"),
{
type: "image/png",
}
);
case "webp":
return new File(
[(await sharpCommand.webp().toBuffer()).buffer] as any,
// Replace the file extension with WebP
name.replace(/\.[^/.]+$/, ".webp"),
{
type: "image/webp",
}
);
case "jpeg":
return new File(
[(await sharpCommand.jpeg().toBuffer()).buffer] as any,
// Replace the file extension with JPEG
name.replace(/\.[^/.]+$/, ".jpeg"),
{
type: "image/jpeg",
}
);
case "avif":
return new File(
[(await sharpCommand.avif().toBuffer()).buffer] as any,
// Replace the file extension with AVIF
name.replace(/\.[^/.]+$/, ".avif"),
{
type: "image/avif",
}
);
// Needs special build of libvips
case "jxl":
return new File(
[(await sharpCommand.jxl().toBuffer()).buffer] as any,
// Replace the file extension with JXL
name.replace(/\.[^/.]+$/, ".jxl"),
{
type: "image/jxl",
}
);
case "heif":
return new File(
[(await sharpCommand.heif().toBuffer()).buffer] as any,
// Replace the file extension with HEIF
name.replace(/\.[^/.]+$/, ".heif"),
{
type: "image/heif",
}
);
default:
return media;
}
}
/**
* Retrieves element from media backend by hash
* @param hash The hash of the element in SHA-256 hex format
* @param extension The extension of the file
* @returns The file as a File object
*/
// eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars
async getMediaByHash(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
hash: string
): Promise<File | null> {
return new File([], "test");
}
}
/**
* S3 Backend, stores files in S3
*/
export class S3Backend extends MediaBackend {
client: S3Client;
config: ConfigType;
constructor(config: ConfigType) {
super("s3");
this.config = config;
this.client = new S3Client({
endpoint: this.config.s3.endpoint,
region: this.config.s3.region || "auto",
credentials: {
accessKeyId: this.config.s3.access_key,
secretAccessKey: this.config.s3.secret_access_key,
},
});
}
async addMedia(media: File): Promise<string> {
if (this.config.media.conversion.convert_images) {
media = await this.convertMedia(media, this.config);
}
const hash = await super.addMedia(media);
if (!hash) {
throw new Error("Failed to hash file");
}
// Check if file is already present
const existingFile = await this.getMediaByHash(hash);
if (existingFile) {
// File already exists, so return the hash without uploading it
return hash;
}
const command = new PutObjectCommand({
Bucket: this.config.s3.bucket_name,
Key: hash,
Body: Buffer.from(await media.arrayBuffer()),
ContentType: media.type,
ContentLength: media.size,
Metadata: {
"x-amz-meta-original-name": media.name,
},
});
const response = await this.client.send(command);
if (response.$metadata.httpStatusCode !== 200) {
throw new Error("Failed to upload file");
}
return hash;
}
async getMediaByHash(hash: string): Promise<File | null> {
const command = new GetObjectCommand({
Bucket: this.config.s3.bucket_name,
Key: hash,
});
let response: GetObjectCommandOutput;
try {
response = await this.client.send(command);
} catch {
return null;
}
if (response.$metadata.httpStatusCode !== 200) {
throw new Error("Failed to get file");
}
const body = await response.Body?.transformToByteArray();
if (!body) {
throw new Error("Failed to get file");
}
return new File([body], hash, {
type: response.ContentType,
});
}
}
/**
* Local backend, stores files on filesystem
*/
export class LocalBackend extends MediaBackend {
config: ConfigType;
constructor(config: ConfigType) {
super("local");
this.config = config;
}
async addMedia(media: File): Promise<string> {
if (this.config.media.conversion.convert_images) {
media = await this.convertMedia(media, this.config);
}
const hash = await super.addMedia(media);
if (!(await exists(`${process.cwd()}/uploads`))) {
await mkdir(`${process.cwd()}/uploads`);
}
await Bun.write(Bun.file(`${process.cwd()}/uploads/${hash}`), media);
return hash;
}
async getMediaByHash(hash: string): Promise<File | null> {
const file = Bun.file(`${process.cwd()}/uploads/${hash}`);
if (!(await file.exists())) {
return null;
}
return new File([await file.arrayBuffer()], `${hash}`, {
type: file.type,
});
}
}
export const uploadFile = (file: File, config: ConfigType) => {
const backend = config.media.backend;
if (backend === "local") {
return new LocalBackend(config).addMedia(file);
} else if (backend === "s3") {
return new S3Backend(config).addMedia(file);
}
};
export const getFile = (
hash: string,
extension: string,
config: ConfigType
) => {
const backend = config.media.backend;
if (backend === "local") {
return new LocalBackend(config).getMediaByHash(hash);
} else if (backend === "s3") {
return new S3Backend(config).getMediaByHash(hash);
}
return null;
};

127
cli.ts
View file

@ -1,23 +1,139 @@
import type { Prisma } from "@prisma/client";
import chalk from "chalk"; import chalk from "chalk";
import { client } from "~database/datasource";
import { createNewLocalUser } from "~database/entities/User"; import { createNewLocalUser } from "~database/entities/User";
import Table from "cli-table"; import Table from "cli-table";
import { rebuildSearchIndexes, MeiliIndexType } from "@meilisearch"; import { rebuildSearchIndexes, MeiliIndexType } from "@meilisearch";
import { getConfig } from "~classes/configmanager";
import { uploadFile } from "~classes/media";
import { getUrl } from "~database/entities/Attachment"; import { getUrl } from "~database/entities/Attachment";
import { mkdir, exists } from "fs/promises"; import { mkdir, exists } from "fs/promises";
import extract from "extract-zip"; import extract from "extract-zip";
import { client } from "~database/datasource";
import { CliBuilder, CliCommand } from "cli-parser";
import { CliParameterType } from "~packages/cli-parser/cli-builder.type";
import { PrismaClient } from "@prisma/client";
import { ConfigManager } from "~packages/config-manager";
const args = process.argv; const args = process.argv;
const config = await new ConfigManager({}).getConfig();
console.error("CLI is temporarily broken, please use the Prisma CLI instead");
process.exit(1);
const cliBuilder = new CliBuilder([
new CliCommand(
["help"],
[],
() => {
cliBuilder.displayHelp();
},
"Shows help for the CLI"
),
new CliCommand<{
username: string;
password: string;
email: string;
admin: boolean;
help: boolean;
}>(
["user", "create"],
[
{
name: "username",
type: CliParameterType.STRING,
description: "Username of the user",
needsValue: true,
positioned: false,
},
{
name: "password",
type: CliParameterType.STRING,
description: "Password of the user",
needsValue: true,
positioned: false,
},
{
name: "email",
type: CliParameterType.STRING,
description: "Email of the user",
needsValue: true,
positioned: false,
},
{
name: "admin",
type: CliParameterType.BOOLEAN,
description: "Make the user an admin",
needsValue: false,
positioned: false,
},
{
name: "help",
shortName: "h",
type: CliParameterType.EMPTY,
description: "Show help message",
needsValue: false,
positioned: false,
},
],
(instance: CliCommand, args) => {
const { username, password, email, admin, help } = args;
if (help) {
instance.displayHelp();
return;
}
// Check if username, password and email are provided
if (!username || !password || !email) {
console.log(
`${chalk.red(``)} Missing username, password or email`
);
return;
}
// Check if user already exists
void client.user
.findFirst({
where: {
OR: [{ username }, { email }],
},
})
.then(user => {
if (user) {
console.log(`${chalk.red(``)} User already exists`);
return;
}
console.log("Sus");
// Create user
/* const newUser = await createNewLocalUser({
email: email,
password: password,
username: username,
admin: admin,
});
console.log(
`${chalk.green(``)} Created user ${chalk.blue(
newUser.username
)}${admin ? chalk.green(" (admin)") : ""}`
); */
});
},
"Creates a new user",
"bun cli user create --username admin --password password123 --email email@email.com"
),
]);
cliBuilder.processArgs(args);
process.exit(0);
/** /**
* Make the text have a width of 20 characters, padding with gray dots * Make the text have a width of 20 characters, padding with gray dots
* Text can be a Chalk string, in which case formatting codes should not be counted in text length * Text can be a Chalk string, in which case formatting codes should not be counted in text length
* @param text The text to align * @param text The text to align
*/ */
const alignDots = (text: string, length = 20) => { /* const alignDots = (text: string, length = 20) => {
// Remove formatting codes // Remove formatting codes
// eslint-disable-next-line no-control-regex // eslint-disable-next-line no-control-regex
const textLength = text.replace(/\u001b\[\d+m/g, "").length; const textLength = text.replace(/\u001b\[\d+m/g, "").length;
@ -1065,3 +1181,4 @@ switch (command) {
} }
process.exit(0); process.exit(0);
*/

View file

@ -71,6 +71,8 @@ tls = true
backend = "s3" backend = "s3"
# Whether to check the hash of media when uploading to avoid duplication # Whether to check the hash of media when uploading to avoid duplication
deduplicate_media = true deduplicate_media = true
# If media backend is "local", this is the folder where the files will be stored
local_uploads_folder = "uploads"
[media.conversion] [media.conversion]
convert_images = false convert_images = false
@ -270,6 +272,8 @@ emoji_filters = [] # NOT IMPLEMENTED
log_requests = true log_requests = true
# Log request and their contents (warning: this is a lot of data) # Log request and their contents (warning: this is a lot of data)
log_requests_verbose = false log_requests_verbose = false
# For GDPR compliance, you can disable logging of IPs
log_ip = false
# Log all filtered objects # Log all filtered objects
log_filters = true log_filters = true

View file

@ -1,8 +1,8 @@
import { Queue } from "bullmq"; import { Queue } from "bullmq";
import { getConfig } from "../utils/config";
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import { ConfigManager } from "config-manager";
const config = getConfig(); const config = await new ConfigManager({}).getConfig();
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}`,

View file

@ -1,5 +1,6 @@
import type { ConfigType } from "~classes/configmanager";
import type { Attachment } from "@prisma/client"; import type { Attachment } from "@prisma/client";
import type { ConfigType } from "config-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";
@ -56,11 +57,13 @@ export const attachmentToAPI = (
}; };
}; };
export const getUrl = (hash: string, config: ConfigType) => { export const getUrl = (name: string, config: ConfigType) => {
if (config.media.backend === "local") { // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
return `${config.http.base_url}/media/${hash}`; if (config.media.backend === MediaBackendType.LOCAL) {
} else if (config.media.backend === "s3") { return `${config.http.base_url}/media/${name}`;
return `${config.s3.public_url}/${hash}`; // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
} else if (config.media.backend === MediaBackendType.S3) {
return `${config.s3.public_url}/${name}`;
} }
return ""; return "";
}; };

View file

@ -95,25 +95,3 @@ export const emojiToActivityPub = (emoji: Emoji): any => {
}, },
}; };
}; };
export const addAPEmojiIfNotExists = async (apEmoji: any) => {
// replace any with your ActivityPub Emoji type
const existingEmoji = await client.emoji.findFirst({
where: {
shortcode: apEmoji.name.replace(/:/g, ""),
instance: null,
},
});
if (existingEmoji) return existingEmoji;
return await client.emoji.create({
data: {
shortcode: apEmoji.name.replace(/:/g, ""),
url: apEmoji.icon.url,
alt: apEmoji.icon.alt || null,
content_type: apEmoji.icon.mediaType,
visible_in_picker: true,
},
});
};

View file

@ -1,10 +1,12 @@
/* 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 { getConfig } from "~classes/configmanager";
import type { Like } from "@prisma/client"; import type { Like } from "@prisma/client";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import type { UserWithRelations } from "./User"; import type { UserWithRelations } from "./User";
import type { StatusWithRelations } from "./Status"; import type { StatusWithRelations } from "./Status";
import { ConfigManager } from "config-manager";
const config = await new ConfigManager({}).getConfig();
/** /**
* Represents a Like entity in the database. * Represents a Like entity in the database.
@ -16,7 +18,7 @@ export const toLysand = (like: Like): LysandLike => {
type: "Like", type: "Like",
created_at: new Date(like.createdAt).toISOString(), created_at: new Date(like.createdAt).toISOString(),
object: (like as any).liked?.uri, object: (like as any).liked?.uri,
uri: `${getConfig().http.base_url}/actions/${like.id}`, uri: `${config.http.base_url}/actions/${like.id}`,
}; };
}; };

View file

@ -1,4 +1,3 @@
import { getConfig } from "~classes/configmanager";
import { Worker } from "bullmq"; import { Worker } from "bullmq";
import { client, federationQueue } from "~database/datasource"; import { client, federationQueue } from "~database/datasource";
import { import {
@ -7,8 +6,9 @@ import {
type StatusWithRelations, type StatusWithRelations,
} from "./Status"; } from "./Status";
import type { User } from "@prisma/client"; import type { User } from "@prisma/client";
import { ConfigManager } from "config-manager";
const config = getConfig(); const config = await new ConfigManager({}).getConfig();
export const federationWorker = new Worker( export const federationWorker = new Worker(
"federation", "federation",
@ -44,7 +44,7 @@ export const federationWorker = new Worker(
instanceId: { instanceId: {
not: null, not: null,
}, },
} }
: {}, : {},
// Mentioned users // Mentioned users
{ {

View file

@ -1,5 +1,4 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { getConfig } from "~classes/configmanager";
import type { UserWithRelations } from "./User"; import type { UserWithRelations } from "./User";
import { import {
fetchRemoteUser, fetchRemoteUser,
@ -29,8 +28,9 @@ import { parse } from "marked";
import linkifyStr from "linkify-string"; import linkifyStr from "linkify-string";
import linkifyHtml from "linkify-html"; import linkifyHtml from "linkify-html";
import { addStausToMeilisearch } from "@meilisearch"; import { addStausToMeilisearch } from "@meilisearch";
import { ConfigManager } from "config-manager";
const config = getConfig(); const config = await new ConfigManager({}).getConfig();
export const statusAndUserRelations: Prisma.StatusInclude = { export const statusAndUserRelations: Prisma.StatusInclude = {
author: { author: {
@ -211,7 +211,7 @@ export const fetchFromRemote = async (uri: string): Promise<Status | null> => {
? { ? {
status: replyStatus, status: replyStatus,
user: (replyStatus as any).author, user: (replyStatus as any).author,
} }
: undefined, : undefined,
quote: quotingStatus || undefined, quote: quotingStatus || undefined,
}); });
@ -349,7 +349,9 @@ export const createNewStatus = async (data: {
// Get HTML version of content // Get HTML version of content
if (data.content_type === "text/markdown") { if (data.content_type === "text/markdown") {
formattedContent = linkifyHtml(await sanitizeHtml(parse(data.content))); formattedContent = linkifyHtml(
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 {
@ -387,7 +389,7 @@ export const createNewStatus = async (data: {
id: attachment, id: attachment,
}; };
}), }),
} }
: undefined, : undefined,
inReplyToPostId: data.reply?.status.id, inReplyToPostId: data.reply?.status.id,
quotingPostId: data.quote?.id, quotingPostId: data.quote?.id,
@ -480,7 +482,9 @@ export const editStatus = async (
// Get HTML version of content // Get HTML version of content
if (data.content_type === "text/markdown") { if (data.content_type === "text/markdown") {
formattedContent = linkifyHtml(await sanitizeHtml(parse(data.content))); formattedContent = linkifyHtml(
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 {
@ -519,7 +523,7 @@ export const editStatus = async (
id: attachment, id: attachment,
}; };
}), }),
} }
: undefined, : undefined,
mentions: { mentions: {
connect: mentions.map(mention => { connect: mentions.map(mention => {
@ -606,15 +610,15 @@ export const statusToAPI = async (
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 (
status: StatusWithRelations, status: StatusWithRelations
user?: UserWithRelations // user?: UserWithRelations
): Promise<any> => { ): Promise<any> => {
// replace any with your ActivityPub type // replace any with your ActivityPub type
return { return {
@ -657,7 +661,7 @@ export const statusToActivityPub = async (
visibility: "public", // adjust as needed visibility: "public", // adjust as needed
// add more fields as needed // add more fields as needed
}; };
}; }; */
export const statusToLysand = (status: StatusWithRelations): Note => { export const statusToLysand = (status: StatusWithRelations): Note => {
return { return {

View file

@ -1,5 +1,3 @@
import type { ConfigType } from "~classes/configmanager";
import { getConfig } from "~classes/configmanager";
import type { APIAccount } from "~types/entities/account"; import type { APIAccount } from "~types/entities/account";
import type { User as LysandUser } from "~types/lysand/Object"; import type { User as LysandUser } from "~types/lysand/Object";
import { htmlToText } from "html-to-text"; import { htmlToText } from "html-to-text";
@ -10,6 +8,10 @@ import { addEmojiIfNotExists, emojiToAPI, emojiToLysand } from "./Emoji";
import { addInstanceIfNotExists } from "./Instance"; import { addInstanceIfNotExists } from "./Instance";
import type { APISource } from "~types/entities/source"; import type { APISource } from "~types/entities/source";
import { addUserToMeilisearch } from "@meilisearch"; import { addUserToMeilisearch } from "@meilisearch";
import { ConfigManager, type ConfigType } from "config-manager";
const configManager = new ConfigManager({});
const config = await configManager.getConfig();
export interface AuthData { export interface AuthData {
user: UserWithRelations | null; user: UserWithRelations | null;
@ -201,7 +203,7 @@ export const createNewLocalUser = async (data: {
header?: string; header?: string;
admin?: boolean; admin?: boolean;
}) => { }) => {
const config = getConfig(); const config = await configManager.getConfig();
const keys = await generateUserKeys(); const keys = await generateUserKeys();
@ -344,8 +346,6 @@ export const userToAPI = (
user: UserWithRelations, user: UserWithRelations,
isOwnAccount = false isOwnAccount = false
): APIAccount => { ): APIAccount => {
const config = getConfig();
return { return {
id: user.id, id: user.id,
username: user.username, username: user.username,
@ -373,7 +373,7 @@ export const userToAPI = (
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,
@ -424,13 +424,13 @@ export const userToLysand = (user: UserWithRelations): LysandUser => {
username: user.username, username: user.username,
avatar: [ avatar: [
{ {
content: getAvatarUrl(user, getConfig()) || "", content: getAvatarUrl(user, config) || "",
content_type: `image/${user.avatar.split(".")[1]}`, content_type: `image/${user.avatar.split(".")[1]}`,
}, },
], ],
header: [ header: [
{ {
content: getHeaderUrl(user, getConfig()) || "", content: getHeaderUrl(user, config) || "",
content_type: `image/${user.header.split(".")[1]}`, content_type: `image/${user.header.split(".")[1]}`,
}, },
], ],
@ -458,7 +458,7 @@ export const userToLysand = (user: UserWithRelations): LysandUser => {
], ],
})), })),
public_key: { public_key: {
actor: `${getConfig().http.base_url}/users/${user.id}`, actor: `${config.http.base_url}/users/${user.id}`,
public_key: user.publicKey, public_key: user.publicKey,
}, },
extensions: { extensions: {

201
index.ts
View file

@ -1,39 +1,36 @@
import { getConfig } from "~classes/configmanager";
import { jsonResponse } from "@response";
import chalk from "chalk";
import { appendFile } from "fs/promises";
import { matches } from "ip-matching";
import { getFromRequest } from "~database/entities/User";
import { mkdir } from "fs/promises";
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 { connectMeili } from "@meilisearch";
import { matchRoute } from "~routes"; import { ConfigManager } from "config-manager";
import { client } from "~database/datasource";
import { LogLevel, LogManager, MultiLogManager } from "log-manager";
import { moduleIsEntry } from "@module";
import { createServer } from "~server";
const timeAtStart = performance.now(); const timeAtStart = performance.now();
console.log(`${chalk.green(`>`)} ${chalk.bold("Starting Lysand...")}`); const configManager = new ConfigManager({});
const config = await configManager.getConfig();
const config = getConfig();
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);
// 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 consoleLogger = new LogManager(
isEntry ? Bun.stdout : Bun.file(`/dev/null`)
);
const dualLogger = new MultiLogManager([logger, consoleLogger]);
// Needs to be imported after config is loaded await dualLogger.log(LogLevel.INFO, "Lysand", "Starting Lysand...");
import { client } from "~database/datasource";
// 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");
if (!(await requests_log.exists())) {
console.log(`${chalk.green(``)} ${chalk.bold("Creating logs folder...")}`);
await mkdir(process.cwd() + "/logs");
await Bun.write(process.cwd() + "/logs/requests.log", "");
}
const redisCache = await initializeRedisCache(); const redisCache = await initializeRedisCache();
if (config.meilisearch.enabled) { if (config.meilisearch.enabled) {
await connectMeili(); await connectMeili(dualLogger);
} }
if (redisCache) { if (redisCache) {
@ -46,165 +43,23 @@ 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;
console.error( await logger.logError(LogLevel.CRITICAL, "Database", error);
`${chalk.red(``)} ${chalk.bold( await consoleLogger.logError(LogLevel.CRITICAL, "Database", error);
"Error while connecting to database: "
)} ${error.message}`
);
process.exit(1); process.exit(1);
} }
Bun.serve({ const server = createServer(config, configManager, dualLogger, isProd);
port: config.http.bind_port,
hostname: config.http.bind || "0.0.0.0", // defaults to "0.0.0.0"
async fetch(req) {
/* Check for banned IPs */
const request_ip = this.requestIP(req)?.address ?? "";
for (const ip of config.http.banned_ips) { await dualLogger.log(
try { LogLevel.INFO,
if (matches(ip, request_ip)) { "Server",
return new Response(undefined, { `Lysand started at ${config.http.bind}:${config.http.bind_port} in ${(performance.now() - timeAtStart).toFixed(0)}ms`
status: 403,
statusText: "Forbidden",
});
}
} catch (e) {
console.error(`[-] Error while parsing banned IP "${ip}" `);
throw e;
}
}
await logRequest(req);
if (req.method === "OPTIONS") {
return jsonResponse({});
}
const { file, matchedRoute } = matchRoute(req.url);
if (matchedRoute) {
const meta = (await file).meta;
// Check for allowed requests
if (!meta.allowedMethods.includes(req.method as any)) {
return new Response(undefined, {
status: 405,
statusText: `Method not allowed: allowed methods are: ${meta.allowedMethods.join(
", "
)}`,
});
}
// TODO: Check for ratelimits
const auth = await getFromRequest(req);
// Check for authentication if required
if (meta.auth.required) {
if (!auth.user) {
return new Response(undefined, {
status: 401,
statusText: "Unauthorized",
});
}
} else if (
(meta.auth.requiredOnMethods ?? []).includes(req.method as any)
) {
if (!auth.user) {
return new Response(undefined, {
status: 401,
statusText: "Unauthorized",
});
}
}
return await (await file).default(req.clone(), matchedRoute, auth);
} else {
// Proxy response from Vite at localhost:5173 if in development mode
if (isProd) {
if (new URL(req.url).pathname.startsWith("/assets")) {
// Serve from pages/dist/assets
return new Response(
Bun.file(`./pages/dist${new URL(req.url).pathname}`)
);
}
// Serve from pages/dist
return new Response(Bun.file(`./pages/dist/index.html`));
} else {
const proxy = await fetch(
req.url.replace(
config.http.base_url,
"http://localhost:5173"
)
);
if (proxy.status !== 404) {
return proxy;
}
}
return new Response(undefined, {
status: 404,
statusText: "Route not found",
});
}
},
});
const logRequest = async (req: Request) => {
if (config.logging.log_requests_verbose) {
await appendFile(
`${process.cwd()}/logs/requests.log`,
`[${new Date().toISOString()}] ${req.method} ${
req.url
}\n\tHeaders:\n`
);
// Add headers
// @ts-expect-error TypeScript is missing entries for some reason
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const headers = req.headers.entries();
for (const [key, value] of headers) {
await appendFile(
`${process.cwd()}/logs/requests.log`,
`\t\t${key}: ${value}\n`
);
}
const body = await req.clone().text();
await appendFile(
`${process.cwd()}/logs/requests.log`,
`\tBody:\n\t${body}\n`
);
} else if (config.logging.log_requests) {
await appendFile(
process.cwd() + "/logs/requests.log",
`[${new Date().toISOString()}] ${req.method} ${req.url}\n`
);
}
};
// Remove previous console.log
// console.clear();
console.log(
`${chalk.green(``)} ${chalk.bold(
`Lysand started at ${chalk.blue(
`${config.http.bind}:${config.http.bind_port}`
)} in ${chalk.gray((performance.now() - timeAtStart).toFixed(0))}ms`
)}`
); );
console.log( await dualLogger.log(
`${chalk.green(``)} ${chalk.bold(`Database is ${chalk.blue("online")}`)}` LogLevel.INFO,
"Database",
`Database is online, now serving ${postCount} posts`
); );
// Print "serving x posts" export { config, server };
console.log(
`${chalk.green(``)} ${chalk.bold(
`Serving ${chalk.blue(postCount)} posts`
)}`
);

View file

@ -38,7 +38,7 @@
"start": "NODE_ENV=production bun run dist/index.js --prod", "start": "NODE_ENV=production bun run dist/index.js --prod",
"migrate-dev": "bun prisma migrate dev", "migrate-dev": "bun prisma migrate dev",
"migrate": "bun prisma migrate deploy", "migrate": "bun prisma migrate deploy",
"lint": "eslint --config .eslintrc.cjs --ext .ts .", "lint": "bunx --bun eslint --config .eslintrc.cjs --ext .ts .",
"prod-build": "bunx --bun vite build pages && bun run build.ts", "prod-build": "bunx --bun vite build pages && bun run build.ts",
"prisma": "DATABASE_URL=$(bun run prisma.ts) bunx prisma", "prisma": "DATABASE_URL=$(bun run prisma.ts) bunx prisma",
"generate": "bun prisma generate", "generate": "bun prisma generate",
@ -109,6 +109,11 @@
"prisma": "^5.6.0", "prisma": "^5.6.0",
"prisma-redis-middleware": "^4.8.0", "prisma-redis-middleware": "^4.8.0",
"semver": "^7.5.4", "semver": "^7.5.4",
"sharp": "^0.33.0-rc.2" "sharp": "^0.33.0-rc.2",
"request-parser": "file:packages/request-parser",
"config-manager": "file:packages/config-manager",
"cli-parser": "file:packages/cli-parser",
"log-manager": "file:packages/log-manager",
"media-manager": "file:packages/media-manager"
} }
} }

BIN
packages/cli-parser/bun.lockb Executable file

Binary file not shown.

View file

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

View file

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

View file

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

View file

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

View file

@ -1,22 +1,4 @@
import { parse } from "@iarna/toml"; import type { MediaBackendType } from "media-manager";
import chalk from "chalk";
const scanConfig = async () => {
const config = Bun.file(process.cwd() + "/config/config.toml");
if (!(await config.exists())) {
console.error(
`${chalk.red(``)} ${chalk.bold(
"Error while reading config: "
)} Config file not found`
);
process.exit(1);
}
return parse(await config.text()) as ConfigType;
};
let config = await scanConfig();
export interface ConfigType { export interface ConfigType {
database: { database: {
@ -72,6 +54,7 @@ export interface ConfigType {
bind: string; bind: string;
bind_port: string; bind_port: string;
banned_ips: string[]; banned_ips: string[];
banned_user_agents: string[];
}; };
instance: { instance: {
@ -114,12 +97,13 @@ export interface ConfigType {
}; };
media: { media: {
backend: string; backend: MediaBackendType;
deduplicate_media: boolean; deduplicate_media: boolean;
conversion: { conversion: {
convert_images: boolean; convert_images: boolean;
convert_to: string; convert_to: string;
}; };
local_uploads_folder: string;
}; };
s3: { s3: {
@ -171,6 +155,7 @@ export interface ConfigType {
logging: { logging: {
log_requests: boolean; log_requests: boolean;
log_requests_verbose: boolean; log_requests_verbose: boolean;
log_ip: boolean;
log_filters: boolean; log_filters: boolean;
}; };
@ -195,6 +180,7 @@ export const configDefaults: ConfigType = {
bind_port: "8000", bind_port: "8000",
base_url: "http://lysand.localhost:8000", base_url: "http://lysand.localhost:8000",
banned_ips: [], banned_ips: [],
banned_user_agents: [],
}, },
database: { database: {
host: "localhost", host: "localhost",
@ -252,6 +238,7 @@ export const configDefaults: ConfigType = {
convert_images: false, convert_images: false,
convert_to: "webp", convert_to: "webp",
}, },
local_uploads_folder: "uploads",
}, },
email: { email: {
send_on_report: false, send_on_report: false,
@ -367,6 +354,7 @@ export const configDefaults: ConfigType = {
logging: { logging: {
log_requests: false, log_requests: false,
log_requests_verbose: false, log_requests_verbose: false,
log_ip: false,
log_filters: true, log_filters: true,
}, },
ratelimits: { ratelimits: {
@ -375,31 +363,3 @@ export const configDefaults: ConfigType = {
}, },
custom_ratelimits: {}, custom_ratelimits: {},
}; };
export const getConfig = () => {
return {
...configDefaults,
...config,
};
};
export const getHost = () => {
const url = new URL(getConfig().http.base_url);
return url.host;
};
// Refresh config every 5 seconds
setInterval(() => {
scanConfig()
.then(newConfig => {
if (newConfig !== config) {
config = newConfig;
}
})
.catch(e => {
console.error(e);
});
}, 5000);
export { config };

View file

@ -0,0 +1,122 @@
/**
* @file index.ts
* @summary ConfigManager system to retrieve and modify system configuration
* @description Can read from a hand-written file, config.toml, or from a machine-saved file, config.internal.toml
* Fuses both and provides a way to retrieve individual values
*/
import { parse, stringify, type JsonMap } from "@iarna/toml";
import type { ConfigType } from "./config-type.type";
import { configDefaults } from "./config-type.type";
import merge from "merge-deep-ts";
export class ConfigManager {
constructor(
public config: {
configPathOverride?: string;
internalConfigPathOverride?: string;
}
) {}
/**
* @summary Reads the config files and returns the merge as a JSON object
* @returns {Promise<T = ConfigType>} The merged config file as a JSON object
*/
async getConfig<T = ConfigType>() {
const config = await this.readConfig<T>();
const internalConfig = await this.readInternalConfig<T>();
return this.mergeConfigs<T>(config, internalConfig);
}
getConfigPath() {
return (
this.config.configPathOverride ||
process.cwd() + "/config/config.toml"
);
}
getInternalConfigPath() {
return (
this.config.internalConfigPathOverride ||
process.cwd() + "/config/config.internal.toml"
);
}
/**
* @summary Reads the internal config file and returns it as a JSON object
* @returns {Promise<T = ConfigType>} The internal config file as a JSON object
*/
private async readInternalConfig<T = ConfigType>() {
const config = Bun.file(this.getInternalConfigPath());
if (!(await config.exists())) {
await Bun.write(config, "");
}
return this.parseConfig<T>(await config.text());
}
/**
* @summary Reads the config file and returns it as a JSON object
* @returns {Promise<T = ConfigType>} The config file as a JSON object
*/
private async readConfig<T = ConfigType>() {
const config = Bun.file(this.getConfigPath());
if (!(await config.exists())) {
throw new Error(
`Error while reading config at path ${this.getConfigPath()}: Config file not found`
);
}
return this.parseConfig<T>(await config.text());
}
/**
* @summary Parses a TOML string and returns it as a JSON object
* @param text The TOML string to parse
* @returns {T = ConfigType} The parsed TOML string as a JSON object
* @throws {Error} If the TOML string is invalid
* @private
*/
private parseConfig<T = ConfigType>(text: string) {
try {
// To all [Symbol] keys from the object
return JSON.parse(JSON.stringify(parse(text))) as T;
} catch (e: any) {
throw new Error(
`Error while parsing config at path ${this.getConfigPath()}: ${e}`
);
}
}
/**
* Writes changed values to the internal config
* @param config The new config object
*/
async writeConfig<T = ConfigType>(config: T) {
const path = this.getInternalConfigPath();
const file = Bun.file(path);
await Bun.write(
file,
`# THIS FILE IS AUTOMATICALLY GENERATED. DO NOT EDIT IT MANUALLY, EDIT THE STANDARD CONFIG.TOML INSTEAD.\n${stringify(
config as JsonMap
)}`
);
}
/**
* @summary Merges two config objects together, with
* the latter configs' values taking precedence
* @param configs
* @returns
*/
private mergeConfigs<T = ConfigType>(...configs: T[]) {
return merge(configs) as T;
}
}
export type { ConfigType };
export const defaultConfig = configDefaults;

View file

@ -0,0 +1,6 @@
{
"name": "config-manager",
"version": "0.0.0",
"main": "index.ts",
"dependencies": {}
}

View file

@ -0,0 +1,96 @@
// FILEPATH: /home/jessew/Dev/lysand/packages/config-manager/config-manager.test.ts
import { stringify } from "@iarna/toml";
import { ConfigManager } from "..";
import { describe, beforeEach, spyOn, it, expect } from "bun:test";
describe("ConfigManager", () => {
let configManager: ConfigManager;
beforeEach(() => {
configManager = new ConfigManager({
configPathOverride: "./config/config.toml",
internalConfigPathOverride: "./config/config.internal.toml",
});
});
it("should get the correct config path", () => {
expect(configManager.getConfigPath()).toEqual("./config/config.toml");
});
it("should get the correct internal config path", () => {
expect(configManager.getInternalConfigPath()).toEqual(
"./config/config.internal.toml"
);
});
it("should read the config file correctly", async () => {
const mockConfig = { key: "value" };
// @ts-expect-error This is a mock
spyOn(Bun, "file").mockImplementationOnce(() => ({
exists: () =>
new Promise(resolve => {
resolve(true);
}),
text: () =>
new Promise(resolve => {
resolve(stringify(mockConfig));
}),
}));
const config = await configManager.getConfig<typeof mockConfig>();
expect(config).toEqual(mockConfig);
});
it("should read the internal config file correctly", async () => {
const mockConfig = { key: "value" };
// @ts-expect-error This is a mock
spyOn(Bun, "file").mockImplementationOnce(() => ({
exists: () =>
new Promise(resolve => {
resolve(true);
}),
text: () =>
new Promise(resolve => {
resolve(stringify(mockConfig));
}),
}));
const config =
// @ts-expect-error Force call private function for testing
await configManager.readInternalConfig<typeof mockConfig>();
expect(config).toEqual(mockConfig);
});
it("should write to the internal config file correctly", async () => {
const mockConfig = { key: "value" };
spyOn(Bun, "write").mockImplementationOnce(
() =>
new Promise(resolve => {
resolve(10);
})
);
await configManager.writeConfig(mockConfig);
});
it("should merge configs correctly", () => {
const config1 = { key1: "value1", key2: "value2" };
const config2 = { key2: "newValue2", key3: "value3" };
// @ts-expect-error Force call private function for testing
const mergedConfig = configManager.mergeConfigs<Record<string, string>>(
config1,
config2
);
expect(mergedConfig).toEqual({
key1: "value1",
key2: "newValue2",
key3: "value3",
});
});
});

View file

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

View file

@ -0,0 +1,6 @@
{
"name": "log-manager",
"version": "0.0.0",
"main": "index.ts",
"dependencies": { }
}

View file

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

View file

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

View file

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

BIN
packages/media-manager/bun.lockb Executable file

Binary file not shown.

View file

@ -0,0 +1,2 @@
[install.scopes]
"@jsr" = "https://npm.jsr.io"

View file

@ -0,0 +1,83 @@
import type { ConfigType } from "config-manager";
export enum MediaBackendType {
LOCAL = "local",
S3 = "s3",
}
interface UploadedFileMetadata {
uploadedFile: File;
path?: string;
hash: string;
}
export class MediaHasher {
/**
* Returns the SHA-256 hash of a file in hex format
* @param media The file to hash
* @returns The SHA-256 hash of the file in hex format
*/
public async getMediaHash(media: File) {
const hash = new Bun.SHA256()
.update(await media.arrayBuffer())
.digest("hex");
return hash;
}
}
export class MediaBackend {
constructor(
public config: ConfigType,
public backend: MediaBackendType
) {}
public getBackendType() {
return this.backend;
}
public shouldConvertImages(config: ConfigType) {
return config.media.conversion.convert_images;
}
/**
* Fetches file from backend from SHA-256 hash
* @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
* @returns The file as a File object
*/
public getFileByHash(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
file: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
databaseHashFetcher: (sha256: string) => Promise<string>
): Promise<File | null> {
return Promise.reject(
new Error("Do not call MediaBackend directly: use a subclass")
);
}
/**
* Fetches file from backend from filename
* @param filename File name
* @returns The file as a File object
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public getFile(filename: string): Promise<File | null> {
return Promise.reject(
new Error("Do not call MediaBackend directly: use a subclass")
);
}
/**
* Adds file to backend
* @param file File to add
* @returns Metadata about the uploaded file
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public addFile(file: File): Promise<UploadedFileMetadata> {
return Promise.reject(
new Error("Do not call MediaBackend directly: use a subclass")
);
}
}

View file

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

View file

@ -0,0 +1,6 @@
{
"name": "media-manager",
"version": "0.0.0",
"main": "index.ts",
"dependencies": { "@bradenmacdonald/s3-lite-client": "npm:@jsr/bradenmacdonald__s3-lite-client" }
}

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

View file

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

View file

@ -0,0 +1,6 @@
{
"name": "request-parser",
"version": "0.0.0",
"main": "index.ts",
"dependencies": {}
}

View file

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

View file

@ -1,8 +1,7 @@
import { ConfigManager } from "config-manager";
// Proxies all `bunx prisma` commands with an environment variable // Proxies all `bunx prisma` commands with an environment variable
const config = await new ConfigManager({}).getConfig();
import { getConfig } from "~classes/configmanager";
const config = getConfig();
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`

157
routes.ts
View file

@ -1,5 +1,4 @@
import type { MatchedRoute } from "bun"; import type { RouteHandler } from "~server/api/routes.type";
import type { AuthData } from "~database/entities/User";
import type { APIRouteMeta } from "~types/api"; import type { APIRouteMeta } from "~types/api";
const serverPath = process.cwd() + "/server/api"; const serverPath = process.cwd() + "/server/api";
@ -8,148 +7,161 @@ const serverPath = process.cwd() + "/server/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": import(serverPath + "/api/v1/accounts/index.ts"), "/api/v1/accounts": await import(serverPath + "/api/v1/accounts/index.ts"),
"/api/v1/accounts/familiar_followers": import( "/api/v1/accounts/familiar_followers": await import(
serverPath + "/api/v1/accounts/familiar_followers/index.ts" serverPath + "/api/v1/accounts/familiar_followers/index.ts"
), ),
"/api/v1/accounts/relationships": import( "/api/v1/accounts/relationships": await import(
serverPath + "/api/v1/accounts/relationships/index.ts" serverPath + "/api/v1/accounts/relationships/index.ts"
), ),
"/api/v1/accounts/search": import( "/api/v1/accounts/search": await import(
serverPath + "/api/v1/accounts/search/index.ts" serverPath + "/api/v1/accounts/search/index.ts"
), ),
"/api/v1/accounts/update_credentials": import( "/api/v1/accounts/update_credentials": await import(
serverPath + "/api/v1/accounts/update_credentials/index.ts" serverPath + "/api/v1/accounts/update_credentials/index.ts"
), ),
"/api/v1/accounts/verify_credentials": import( "/api/v1/accounts/verify_credentials": await import(
serverPath + "/api/v1/accounts/verify_credentials/index.ts" serverPath + "/api/v1/accounts/verify_credentials/index.ts"
), ),
"/api/v1/apps": import(serverPath + "/api/v1/apps/index.ts"), "/api/v1/apps": await import(serverPath + "/api/v1/apps/index.ts"),
"/api/v1/apps/verify_credentials": import( "/api/v1/apps/verify_credentials": await import(
serverPath + "/api/v1/apps/verify_credentials/index.ts" serverPath + "/api/v1/apps/verify_credentials/index.ts"
), ),
"/api/v1/blocks": import(serverPath + "/api/v1/blocks/index.ts"), "/api/v1/blocks": await import(serverPath + "/api/v1/blocks/index.ts"),
"/api/v1/custom_emojis": import( "/api/v1/custom_emojis": await import(
serverPath + "/api/v1/custom_emojis/index.ts" serverPath + "/api/v1/custom_emojis/index.ts"
), ),
"/api/v1/favourites": import(serverPath + "/api/v1/favourites/index.ts"), "/api/v1/favourites": await import(
"/api/v1/follow_requests": import( serverPath + "/api/v1/favourites/index.ts"
),
"/api/v1/follow_requests": await import(
serverPath + "/api/v1/follow_requests/index.ts" serverPath + "/api/v1/follow_requests/index.ts"
), ),
"/api/v1/instance": import(serverPath + "/api/v1/instance/index.ts"), "/api/v1/instance": await import(serverPath + "/api/v1/instance/index.ts"),
"/api/v1/media": import(serverPath + "/api/v1/media/index.ts"), "/api/v1/media": await import(serverPath + "/api/v1/media/index.ts"),
"/api/v1/mutes": import(serverPath + "/api/v1/mutes/index.ts"), "/api/v1/mutes": await import(serverPath + "/api/v1/mutes/index.ts"),
"/api/v1/notifications": import( "/api/v1/notifications": await import(
serverPath + "/api/v1/notifications/index.ts" serverPath + "/api/v1/notifications/index.ts"
), ),
"/api/v1/profile/avatar": import(serverPath + "/api/v1/profile/avatar.ts"), "/api/v1/profile/avatar": await import(
"/api/v1/profile/header": import(serverPath + "/api/v1/profile/header.ts"), serverPath + "/api/v1/profile/avatar.ts"
"/api/v1/statuses": import(serverPath + "/api/v1/statuses/index.ts"), ),
"/api/v1/timelines/home": import(serverPath + "/api/v1/timelines/home.ts"), "/api/v1/profile/header": await import(
"/api/v1/timelines/public": import( serverPath + "/api/v1/profile/header.ts"
),
"/api/v1/statuses": await import(serverPath + "/api/v1/statuses/index.ts"),
"/api/v1/timelines/home": await import(
serverPath + "/api/v1/timelines/home.ts"
),
"/api/v1/timelines/public": await import(
serverPath + "/api/v1/timelines/public.ts" serverPath + "/api/v1/timelines/public.ts"
), ),
"/api/v2/media": import(serverPath + "/api/v2/media/index.ts"), "/api/v2/media": await import(serverPath + "/api/v2/media/index.ts"),
"/api/v2/search": import(serverPath + "/api/v2/search/index.ts"), "/api/v2/search": await import(serverPath + "/api/v2/search/index.ts"),
"/auth/login": import(serverPath + "/auth/login/index.ts"), "/auth/login": await import(serverPath + "/auth/login/index.ts"),
"/nodeinfo/2.0": import(serverPath + "/nodeinfo/2.0/index.ts"), "/nodeinfo/2.0": await import(serverPath + "/nodeinfo/2.0/index.ts"),
"/oauth/authorize-external": import( "/oauth/authorize-external": await import(
serverPath + "/oauth/authorize-external/index.ts" serverPath + "/oauth/authorize-external/index.ts"
), ),
"/oauth/providers": import(serverPath + "/oauth/providers/index.ts"), "/oauth/providers": await import(serverPath + "/oauth/providers/index.ts"),
"/oauth/token": import(serverPath + "/oauth/token/index.ts"), "/oauth/token": await import(serverPath + "/oauth/token/index.ts"),
"/api/v1/accounts/[id]": import( "/api/v1/accounts/[id]": await import(
serverPath + "/api/v1/accounts/[id]/index.ts" serverPath + "/api/v1/accounts/[id]/index.ts"
), ),
"/api/v1/accounts/[id]/block": import( "/api/v1/accounts/[id]/block": await import(
serverPath + "/api/v1/accounts/[id]/block.ts" serverPath + "/api/v1/accounts/[id]/block.ts"
), ),
"/api/v1/accounts/[id]/follow": import( "/api/v1/accounts/[id]/follow": await import(
serverPath + "/api/v1/accounts/[id]/follow.ts" serverPath + "/api/v1/accounts/[id]/follow.ts"
), ),
"/api/v1/accounts/[id]/followers": import( "/api/v1/accounts/[id]/followers": await import(
serverPath + "/api/v1/accounts/[id]/followers.ts" serverPath + "/api/v1/accounts/[id]/followers.ts"
), ),
"/api/v1/accounts/[id]/following": import( "/api/v1/accounts/[id]/following": await import(
serverPath + "/api/v1/accounts/[id]/following.ts" serverPath + "/api/v1/accounts/[id]/following.ts"
), ),
"/api/v1/accounts/[id]/mute": import( "/api/v1/accounts/[id]/mute": await import(
serverPath + "/api/v1/accounts/[id]/mute.ts" serverPath + "/api/v1/accounts/[id]/mute.ts"
), ),
"/api/v1/accounts/[id]/note": import( "/api/v1/accounts/[id]/note": await import(
serverPath + "/api/v1/accounts/[id]/note.ts" serverPath + "/api/v1/accounts/[id]/note.ts"
), ),
"/api/v1/accounts/[id]/pin": import( "/api/v1/accounts/[id]/pin": await import(
serverPath + "/api/v1/accounts/[id]/pin.ts" serverPath + "/api/v1/accounts/[id]/pin.ts"
), ),
"/api/v1/accounts/[id]/remove_from_followers": import( "/api/v1/accounts/[id]/remove_from_followers": await import(
serverPath + "/api/v1/accounts/[id]/remove_from_followers.ts" serverPath + "/api/v1/accounts/[id]/remove_from_followers.ts"
), ),
"/api/v1/accounts/[id]/statuses": import( "/api/v1/accounts/[id]/statuses": await import(
serverPath + "/api/v1/accounts/[id]/statuses.ts" serverPath + "/api/v1/accounts/[id]/statuses.ts"
), ),
"/api/v1/accounts/[id]/unblock": import( "/api/v1/accounts/[id]/unblock": await import(
serverPath + "/api/v1/accounts/[id]/unblock.ts" serverPath + "/api/v1/accounts/[id]/unblock.ts"
), ),
"/api/v1/accounts/[id]/unfollow": import( "/api/v1/accounts/[id]/unfollow": await import(
serverPath + "/api/v1/accounts/[id]/unfollow.ts" serverPath + "/api/v1/accounts/[id]/unfollow.ts"
), ),
"/api/v1/accounts/[id]/unmute": import( "/api/v1/accounts/[id]/unmute": await import(
serverPath + "/api/v1/accounts/[id]/unmute.ts" serverPath + "/api/v1/accounts/[id]/unmute.ts"
), ),
"/api/v1/accounts/[id]/unpin": import( "/api/v1/accounts/[id]/unpin": await import(
serverPath + "/api/v1/accounts/[id]/unpin.ts" serverPath + "/api/v1/accounts/[id]/unpin.ts"
), ),
"/api/v1/follow_requests/[account_id]/authorize": import( "/api/v1/follow_requests/[account_id]/authorize": await import(
serverPath + "/api/v1/follow_requests/[account_id]/authorize.ts" serverPath + "/api/v1/follow_requests/[account_id]/authorize.ts"
), ),
"/api/v1/follow_requests/[account_id]/reject": import( "/api/v1/follow_requests/[account_id]/reject": await import(
serverPath + "/api/v1/follow_requests/[account_id]/reject.ts" serverPath + "/api/v1/follow_requests/[account_id]/reject.ts"
), ),
"/api/v1/media/[id]": import(serverPath + "/api/v1/media/[id]/index.ts"), "/api/v1/media/[id]": await import(
"/api/v1/statuses/[id]": import( serverPath + "/api/v1/media/[id]/index.ts"
),
"/api/v1/statuses/[id]": await import(
serverPath + "/api/v1/statuses/[id]/index.ts" serverPath + "/api/v1/statuses/[id]/index.ts"
), ),
"/api/v1/statuses/[id]/context": import( "/api/v1/statuses/[id]/context": await import(
serverPath + "/api/v1/statuses/[id]/context.ts" serverPath + "/api/v1/statuses/[id]/context.ts"
), ),
"/api/v1/statuses/[id]/favourite": import( "/api/v1/statuses/[id]/favourite": await import(
serverPath + "/api/v1/statuses/[id]/favourite.ts" serverPath + "/api/v1/statuses/[id]/favourite.ts"
), ),
"/api/v1/statuses/[id]/favourited_by": import( "/api/v1/statuses/[id]/favourited_by": await import(
serverPath + "/api/v1/statuses/[id]/favourited_by.ts" serverPath + "/api/v1/statuses/[id]/favourited_by.ts"
), ),
"/api/v1/statuses/[id]/pin": import( "/api/v1/statuses/[id]/pin": await import(
serverPath + "/api/v1/statuses/[id]/pin.ts" serverPath + "/api/v1/statuses/[id]/pin.ts"
), ),
"/api/v1/statuses/[id]/reblog": import( "/api/v1/statuses/[id]/reblog": await import(
serverPath + "/api/v1/statuses/[id]/reblog.ts" serverPath + "/api/v1/statuses/[id]/reblog.ts"
), ),
"/api/v1/statuses/[id]/reblogged_by": import( "/api/v1/statuses/[id]/reblogged_by": await import(
serverPath + "/api/v1/statuses/[id]/reblogged_by.ts" serverPath + "/api/v1/statuses/[id]/reblogged_by.ts"
), ),
"/api/v1/statuses/[id]/source": import( "/api/v1/statuses/[id]/source": await import(
serverPath + "/api/v1/statuses/[id]/source.ts" serverPath + "/api/v1/statuses/[id]/source.ts"
), ),
"/api/v1/statuses/[id]/unfavourite": import( "/api/v1/statuses/[id]/unfavourite": await import(
serverPath + "/api/v1/statuses/[id]/unfavourite.ts" serverPath + "/api/v1/statuses/[id]/unfavourite.ts"
), ),
"/api/v1/statuses/[id]/unpin": import( "/api/v1/statuses/[id]/unpin": await import(
serverPath + "/api/v1/statuses/[id]/unpin.ts" serverPath + "/api/v1/statuses/[id]/unpin.ts"
), ),
"/api/v1/statuses/[id]/unreblog": import( "/api/v1/statuses/[id]/unreblog": await import(
serverPath + "/api/v1/statuses/[id]/unreblog.ts" serverPath + "/api/v1/statuses/[id]/unreblog.ts"
), ),
"/media/[id]": import(serverPath + "/media/[id]/index.ts"), "/media/[id]": await import(serverPath + "/media/[id]/index.ts"),
"/oauth/callback/[issuer]": import( "/oauth/callback/[issuer]": await import(
serverPath + "/oauth/callback/[issuer]/index.ts" serverPath + "/oauth/callback/[issuer]/index.ts"
), ),
"/object/[uuid]": import(serverPath + "/object/[uuid]/index.ts"), "/object/[uuid]": await import(serverPath + "/object/[uuid]/index.ts"),
"/users/[uuid]": import(serverPath + "/users/[uuid]/index.ts"), "/users/[uuid]": await import(serverPath + "/users/[uuid]/index.ts"),
"/users/[uuid]/inbox": import(serverPath + "/users/[uuid]/inbox/index.ts"), "/users/[uuid]/inbox": await import(
"/users/[uuid]/outbox": import( serverPath + "/users/[uuid]/inbox/index.ts"
),
"/users/[uuid]/outbox": await import(
serverPath + "/users/[uuid]/outbox/index.ts" serverPath + "/users/[uuid]/outbox/index.ts"
), ),
"/[...404]": await import(serverPath + "/[...404].ts"),
}; };
// Returns the route filesystem path when given a URL // Returns the route filesystem path when given a URL
@ -158,20 +170,19 @@ export const routeMatcher = new Bun.FileSystemRouter({
dir: process.cwd() + "/server/api", dir: process.cwd() + "/server/api",
}); });
export const matchRoute = (url: string) => { export const matchRoute = <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 {
// @ts-expect-error TypeScript parses this as a defined object instead of an arbitrarily editable route file // @ts-expect-error TypeScript parses this as a defined object instead of an arbitrarily editable route file
file: rawRoutes[route.name] as Promise<{ file: rawRoutes[route.name] as Promise<
meta: APIRouteMeta; | {
default: ( meta: APIRouteMeta;
req: Request, default: RouteHandler<T>;
matchedRoute: MatchedRoute, }
auth: AuthData | undefined
) => Response | Promise<Response>; >,
}>,
matchedRoute: route, matchedRoute: route,
}; };
}; };

186
server.ts Normal file
View file

@ -0,0 +1,186 @@
import { errorResponse, jsonResponse } from "@response";
import { matches } from "ip-matching";
import { getFromRequest } from "~database/entities/User";
import type { ConfigManager, ConfigType } from "config-manager";
import type { LogManager, MultiLogManager } from "log-manager";
import { LogLevel } from "log-manager";
import { RequestParser } from "request-parser";
export const createServer = (
config: ConfigType,
configManager: ConfigManager,
logger: LogManager | MultiLogManager,
isProd: boolean
) =>
Bun.serve({
port: config.http.bind_port,
hostname: config.http.bind || "0.0.0.0", // defaults to "0.0.0.0"
async fetch(req) {
// Check for banned IPs
const request_ip = this.requestIP(req)?.address ?? "";
for (const ip of config.http.banned_ips) {
try {
if (matches(ip, request_ip)) {
return new Response(undefined, {
status: 403,
statusText: "Forbidden",
});
}
} catch (e) {
console.error(`[-] Error while parsing banned IP "${ip}" `);
throw e;
}
}
// Check for banned user agents (regex)
const ua = req.headers.get("User-Agent") ?? "";
for (const agent of config.http.banned_user_agents) {
if (new RegExp(agent).test(ua)) {
return new Response(undefined, {
status: 403,
statusText: "Forbidden",
});
}
}
if (config.logging.log_requests) {
await logger.logRequest(
req,
config.logging.log_ip ? request_ip : undefined,
config.logging.log_requests_verbose
);
}
if (req.method === "OPTIONS") {
return jsonResponse({});
}
// If it isn't dynamically imported, it causes trouble with imports
// There shouldn't be a performance hit after bundling right?
const { matchRoute } = await import("~routes");
const { file: filePromise, matchedRoute } = matchRoute(req.url);
const file = await filePromise;
if (matchedRoute && file == undefined) {
await logger.log(
LogLevel.ERROR,
"Server",
`Route file ${matchedRoute.filePath} not found or not registered in the routes file`
);
return errorResponse("Route not found", 500);
}
if (
matchedRoute &&
matchedRoute.name !== "/[...404]" &&
file != undefined
) {
const meta = file.meta;
// Check for allowed requests
if (!meta.allowedMethods.includes(req.method as any)) {
return new Response(undefined, {
status: 405,
statusText: `Method not allowed: allowed methods are: ${meta.allowedMethods.join(
", "
)}`,
});
}
// TODO: Check for ratelimits
const auth = await getFromRequest(req);
// Check for authentication if required
if (meta.auth.required) {
if (!auth.user) {
return new Response(undefined, {
status: 401,
statusText: "Unauthorized",
});
}
} else if (
(meta.auth.requiredOnMethods ?? []).includes(
req.method as any
)
) {
if (!auth.user) {
return new Response(undefined, {
status: 401,
statusText: "Unauthorized",
});
}
}
let parsedRequest = {};
try {
parsedRequest = await new RequestParser(req).toObject();
} catch (e) {
await logger.logError(
LogLevel.ERROR,
"Server.RouteRequestParser",
e as Error
);
return new Response(undefined, {
status: 400,
statusText: "Bad request",
});
}
return await file.default(req.clone(), matchedRoute, {
auth,
configManager,
parsedRequest,
});
} else if (matchedRoute?.name === "/[...404]") {
// Proxy response from Vite at localhost:5173 if in development mode
if (isProd) {
if (new URL(req.url).pathname.startsWith("/assets")) {
const file = Bun.file(
`./pages/dist${new URL(req.url).pathname}`
);
// Serve from pages/dist/assets
if (await file.exists()) {
return new Response(file);
} else return errorResponse("Asset 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`);
// Serve from pages/dist
return new Response(file);
} else {
const proxy = await fetch(
req.url.replace(
config.http.base_url,
"http://localhost:5173"
)
).catch(async e => {
await logger.logError(
LogLevel.ERROR,
"Server.Proxy",
e as Error
);
return errorResponse("Route not found", 404);
});
if (proxy.status !== 404) {
return proxy;
}
}
return errorResponse("Route not found", 404);
} else {
return errorResponse("Route not found", 404);
}
},
});

View file

@ -1,7 +1,5 @@
import { MatchedRoute } from "bun";
import { getConfig, getHost } from "~classes/configmanager";
import { xmlResponse } from "@response"; import { xmlResponse } from "@response";
import { applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -16,18 +14,13 @@ export const meta = applyConfig({
}); });
/** export default apiRoute(async (req, matchedRoute, extraData) => {
* Host meta endpoint const config = await extraData.configManager.getConfig();
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const config = 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}"/>
</XRD> </XRD>
`); `);
}; });

View file

@ -1,7 +1,5 @@
import { jsonResponse } from "@response"; import { jsonResponse } from "@response";
import { MatchedRoute } from "bun"; import { apiRoute, applyConfig } from "@api";
import { getConfig } from "~classes/configmanager";
import { applyConfig } from "@api";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -15,14 +13,9 @@ export const meta = applyConfig({
route: "/.well-known/lysand", route: "/.well-known/lysand",
}); });
/** export default apiRoute(async (req, matchedRoute, extraData) => {
* Lysand instance metadata endpoint const config = await extraData.configManager.getConfig();
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const config = getConfig();
// In the format acct:name@example.com // In the format acct:name@example.com
return jsonResponse({ return jsonResponse({
type: "ServerMetadata", type: "ServerMetadata",
@ -47,4 +40,4 @@ export default async (
website: "https://lysand.org", website: "https://lysand.org",
// TODO: Add admins, moderators field // TODO: Add admins, moderators field
}) })
}; })

View file

@ -1,6 +1,4 @@
import { MatchedRoute } from "bun"; import { apiRoute, applyConfig } from "@api";
import { getConfig, getHost } from "~classes/configmanager";
import { applyConfig } from "@api";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -15,14 +13,8 @@ export const meta = applyConfig({
}); });
/** export default apiRoute(async (req, matchedRoute, extraData) => {
* Redirect to /nodeinfo/2.0 const config = await extraData.configManager.getConfig();
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const config = getConfig();
return new Response("", { return new Response("", {
status: 301, status: 301,
@ -30,4 +22,4 @@ export default async (
Location: `${config.http.base_url}/.well-known/nodeinfo/2.0`, Location: `${config.http.base_url}/.well-known/nodeinfo/2.0`,
}, },
}); });
}; });

View file

@ -1,7 +1,5 @@
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun"; import { apiRoute, applyConfig } from "@api";
import { getConfig, getHost } from "~classes/configmanager";
import { applyConfig } from "@api";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
@ -16,21 +14,16 @@ export const meta = applyConfig({
route: "/.well-known/webfinger", route: "/.well-known/webfinger",
}); });
/** export default apiRoute(async (req, matchedRoute, extraData) => {
* ActivityPub WebFinger endpoint
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
// 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 = getConfig(); const config = await extraData.configManager.getConfig();
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] !== getHost()) { if (requestedUser.split("@")[1] !== host) {
return errorResponse("User is a remote user", 404); return errorResponse("User is a remote user", 404);
} }
@ -43,7 +36,7 @@ export default async (
} }
return jsonResponse({ return jsonResponse({
subject: `acct:${user.username}@${getHost()}`, subject: `acct:${user.username}@${host}`,
links: [ links: [
{ {
@ -63,4 +56,4 @@ export default async (
} }
] ]
}) })
}; });

21
server/api/[...404].ts Normal file
View file

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

View file

@ -1,14 +1,10 @@
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import { import {
createNewRelationship, createNewRelationship,
relationshipToAPI, relationshipToAPI,
} from "~database/entities/Relationship"; } from "~database/entities/Relationship";
import { import { getRelationshipToOtherUser } from "~database/entities/User";
getFromRequest, import { apiRoute, applyConfig } from "@api";
getRelationshipToOtherUser,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
@ -26,13 +22,10 @@ export const meta = applyConfig({
/** /**
* Blocks a user * Blocks a user
*/ */
export default async ( export default apiRoute(async (req, matchedRoute, extraData) => {
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
const { user: self } = await getFromRequest(req); const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
@ -84,4 +77,4 @@ export default async (
}); });
return jsonResponse(relationshipToAPI(relationship)); return jsonResponse(relationshipToAPI(relationship));
}; });

View file

@ -1,15 +1,10 @@
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import { import {
createNewRelationship, createNewRelationship,
relationshipToAPI, relationshipToAPI,
} from "~database/entities/Relationship"; } from "~database/entities/Relationship";
import { import { getRelationshipToOtherUser } from "~database/entities/User";
getFromRequest, import { apiRoute, applyConfig } from "@api";
getRelationshipToOtherUser,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
@ -27,21 +22,18 @@ export const meta = applyConfig({
/** /**
* Follow a user * Follow a user
*/ */
export default async ( export default apiRoute<{
req: Request, reblogs?: boolean;
matchedRoute: MatchedRoute notify?: boolean;
): Promise<Response> => { languages?: string[];
}>(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
const { user: self } = await getFromRequest(req); const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
const { languages, notify, reblogs } = await parseRequest<{ const { languages, notify, reblogs } = extraData.parsedRequest;
reblogs?: boolean;
notify?: boolean;
languages?: string[];
}>(req);
const user = await client.user.findUnique({ const user = await client.user.findUnique({
where: { id }, where: { id },
@ -103,4 +95,4 @@ export default async (
}); });
return jsonResponse(relationshipToAPI(relationship)); return jsonResponse(relationshipToAPI(relationship));
}; });

View file

@ -1,8 +1,7 @@
/* 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 type { MatchedRoute } from "bun";
import { userRelations, userToAPI } from "~database/entities/User"; import { userRelations, userToAPI } from "~database/entities/User";
import { applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
@ -20,24 +19,16 @@ export const meta = applyConfig({
/** /**
* Fetch all statuses for a user * Fetch all statuses for a user
*/ */
export default async ( export default apiRoute<{
req: Request, max_id?: string;
matchedRoute: MatchedRoute since_id?: string;
): Promise<Response> => { min_id?: string;
limit?: number;
}>(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
// TODO: Add pinned // TODO: Add pinned
const { const { max_id, min_id, since_id, limit = 20 } = extraData.parsedRequest;
max_id,
min_id,
since_id,
limit = 20,
}: {
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
} = matchedRoute.query;
const user = await client.user.findUnique({ const user = await client.user.findUnique({
where: { id }, where: { id },
@ -86,4 +77,4 @@ export default async (
Link: linkHeader.join(", "), Link: linkHeader.join(", "),
} }
); );
}; });

View file

@ -1,8 +1,7 @@
/* 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 type { MatchedRoute } from "bun";
import { userRelations, userToAPI } from "~database/entities/User"; import { userRelations, userToAPI } from "~database/entities/User";
import { applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
@ -20,24 +19,16 @@ export const meta = applyConfig({
/** /**
* Fetch all statuses for a user * Fetch all statuses for a user
*/ */
export default async ( export default apiRoute<{
req: Request, max_id?: string;
matchedRoute: MatchedRoute since_id?: string;
): Promise<Response> => { min_id?: string;
limit?: number;
}>(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
// TODO: Add pinned // TODO: Add pinned
const { const { max_id, min_id, since_id, limit = 20 } = extraData.parsedRequest;
max_id,
min_id,
since_id,
limit = 20,
}: {
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
} = matchedRoute.query;
const user = await client.user.findUnique({ const user = await client.user.findUnique({
where: { id }, where: { id },
@ -86,4 +77,4 @@ export default async (
Link: linkHeader.join(", "), Link: linkHeader.join(", "),
} }
); );
}; });

View file

@ -1,12 +1,7 @@
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import type { UserWithRelations } from "~database/entities/User"; import type { UserWithRelations } from "~database/entities/User";
import { import { userRelations, userToAPI } from "~database/entities/User";
getFromRequest, import { apiRoute, applyConfig } from "@api";
userRelations,
userToAPI,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
@ -24,17 +19,14 @@ export const meta = applyConfig({
/** /**
* Fetch a user * Fetch a user
*/ */
export default async ( export default apiRoute(async (req, matchedRoute, extraData) => {
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
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 } = await getFromRequest(req); const { user } = extraData.auth;
let foundUser: UserWithRelations | null; let foundUser: UserWithRelations | null;
try { try {
@ -49,4 +41,4 @@ export default async (
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,15 +1,10 @@
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import { import {
createNewRelationship, createNewRelationship,
relationshipToAPI, relationshipToAPI,
} from "~database/entities/Relationship"; } from "~database/entities/Relationship";
import { import { getRelationshipToOtherUser } from "~database/entities/User";
getFromRequest, import { apiRoute, applyConfig } from "@api";
getRelationshipToOtherUser,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
@ -27,21 +22,18 @@ export const meta = applyConfig({
/** /**
* Mute a user * Mute a user
*/ */
export default async ( export default apiRoute<{
req: Request, notifications: boolean;
matchedRoute: MatchedRoute duration: number;
): Promise<Response> => { }>(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
const { user: self } = await getFromRequest(req); 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 } = await parseRequest<{ const { notifications, duration } = extraData.parsedRequest;
notifications: boolean;
duration: number;
}>(req);
const user = await client.user.findUnique({ const user = await client.user.findUnique({
where: { id }, where: { id },
@ -97,4 +89,4 @@ export default async (
// TODO: Implement duration // TODO: Implement duration
return jsonResponse(relationshipToAPI(relationship)); return jsonResponse(relationshipToAPI(relationship));
}; });

View file

@ -1,15 +1,10 @@
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import { import {
createNewRelationship, createNewRelationship,
relationshipToAPI, relationshipToAPI,
} from "~database/entities/Relationship"; } from "~database/entities/Relationship";
import { import { getRelationshipToOtherUser } from "~database/entities/User";
getFromRequest, import { apiRoute, applyConfig } from "@api";
getRelationshipToOtherUser,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
@ -27,19 +22,16 @@ export const meta = applyConfig({
/** /**
* Sets a user note * Sets a user note
*/ */
export default async ( export default apiRoute<{
req: Request, comment: string;
matchedRoute: MatchedRoute }>(async (req, matchedRoute, extraData) => {
): Promise<Response> => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
const { user: self } = await getFromRequest(req); const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
const { comment } = await parseRequest<{ const { comment } = extraData.parsedRequest;
comment: string;
}>(req);
const user = await client.user.findUnique({ const user = await client.user.findUnique({
where: { id }, where: { id },
@ -87,4 +79,4 @@ export default async (
}); });
return jsonResponse(relationshipToAPI(relationship)); return jsonResponse(relationshipToAPI(relationship));
}; });

View file

@ -1,14 +1,10 @@
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import { import {
createNewRelationship, createNewRelationship,
relationshipToAPI, relationshipToAPI,
} from "~database/entities/Relationship"; } from "~database/entities/Relationship";
import { import { getRelationshipToOtherUser } from "~database/entities/User";
getFromRequest, import { apiRoute, applyConfig } from "@api";
getRelationshipToOtherUser,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
@ -26,13 +22,10 @@ export const meta = applyConfig({
/** /**
* Pin a user * Pin a user
*/ */
export default async ( export default apiRoute(async (req, matchedRoute, extraData) => {
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
const { user: self } = await getFromRequest(req); const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
@ -84,4 +77,4 @@ export default async (
}); });
return jsonResponse(relationshipToAPI(relationship)); return jsonResponse(relationshipToAPI(relationship));
}; });

View file

@ -1,14 +1,10 @@
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import { import {
createNewRelationship, createNewRelationship,
relationshipToAPI, relationshipToAPI,
} from "~database/entities/Relationship"; } from "~database/entities/Relationship";
import { import { getRelationshipToOtherUser } from "~database/entities/User";
getFromRequest, import { apiRoute, applyConfig } from "@api";
getRelationshipToOtherUser,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
@ -26,13 +22,10 @@ export const meta = applyConfig({
/** /**
* Removes an account from your followers list * Removes an account from your followers list
*/ */
export default async ( export default apiRoute(async (req, matchedRoute, extraData) => {
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
const { user: self } = await getFromRequest(req); const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
@ -98,4 +91,4 @@ export default async (
} }
return jsonResponse(relationshipToAPI(relationship)); return jsonResponse(relationshipToAPI(relationship));
}; });

View file

@ -1,9 +1,8 @@
/* 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 type { MatchedRoute } from "bun";
import { statusAndUserRelations, statusToAPI } from "~database/entities/Status"; import { statusAndUserRelations, statusToAPI } from "~database/entities/Status";
import { userRelations } from "~database/entities/User"; import { userRelations } from "~database/entities/User";
import { applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
@ -21,10 +20,18 @@ export const meta = applyConfig({
/** /**
* Fetch all statuses for a user * Fetch all statuses for a user
*/ */
export default async ( export default apiRoute<{
req: Request, max_id?: string;
matchedRoute: MatchedRoute since_id?: string;
): Promise<Response> => { min_id?: string;
limit?: string;
only_media?: boolean;
exclude_replies?: boolean;
exclude_reblogs?: boolean;
// TODO: Add with_muted
pinned?: boolean;
tagged?: string;
}>(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
// TODO: Add pinned // TODO: Add pinned
@ -35,18 +42,7 @@ export default async (
limit = "20", limit = "20",
exclude_reblogs, exclude_reblogs,
pinned, pinned,
}: { } = extraData.parsedRequest;
max_id?: string;
since_id?: string;
min_id?: string;
limit?: string;
only_media?: boolean;
exclude_replies?: boolean;
exclude_reblogs?: boolean;
// TODO: Add with_muted
pinned?: boolean;
tagged?: string;
} = matchedRoute.query;
const user = await client.user.findUnique({ const user = await client.user.findUnique({
where: { id }, where: { id },
@ -131,4 +127,4 @@ export default async (
Link: linkHeader.join(", "), Link: linkHeader.join(", "),
} }
); );
}; });

View file

@ -1,14 +1,10 @@
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import { import {
createNewRelationship, createNewRelationship,
relationshipToAPI, relationshipToAPI,
} from "~database/entities/Relationship"; } from "~database/entities/Relationship";
import { import { getRelationshipToOtherUser } from "~database/entities/User";
getFromRequest, import { apiRoute, applyConfig } from "@api";
getRelationshipToOtherUser,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
@ -26,13 +22,10 @@ export const meta = applyConfig({
/** /**
* Blocks a user * Blocks a user
*/ */
export default async ( export default apiRoute(async (req, matchedRoute, extraData) => {
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
const { user: self } = await getFromRequest(req); const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
@ -84,4 +77,4 @@ export default async (
}); });
return jsonResponse(relationshipToAPI(relationship)); return jsonResponse(relationshipToAPI(relationship));
}; });

View file

@ -1,14 +1,10 @@
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import { import {
createNewRelationship, createNewRelationship,
relationshipToAPI, relationshipToAPI,
} from "~database/entities/Relationship"; } from "~database/entities/Relationship";
import { import { getRelationshipToOtherUser } from "~database/entities/User";
getFromRequest, import { apiRoute, applyConfig } from "@api";
getRelationshipToOtherUser,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
@ -26,13 +22,10 @@ export const meta = applyConfig({
/** /**
* Unfollows a user * Unfollows a user
*/ */
export default async ( export default apiRoute(async (req, matchedRoute, extraData) => {
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
const { user: self } = await getFromRequest(req); const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
@ -84,4 +77,4 @@ export default async (
}); });
return jsonResponse(relationshipToAPI(relationship)); return jsonResponse(relationshipToAPI(relationship));
}; });

View file

@ -1,14 +1,10 @@
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import { import {
createNewRelationship, createNewRelationship,
relationshipToAPI, relationshipToAPI,
} from "~database/entities/Relationship"; } from "~database/entities/Relationship";
import { import { getRelationshipToOtherUser } from "~database/entities/User";
getFromRequest, import { apiRoute, applyConfig } from "@api";
getRelationshipToOtherUser,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
@ -26,13 +22,10 @@ export const meta = applyConfig({
/** /**
* Unmute a user * Unmute a user
*/ */
export default async ( export default apiRoute(async (req, matchedRoute, extraData) => {
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
const { user: self } = await getFromRequest(req); const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
@ -86,4 +79,4 @@ export default async (
}); });
return jsonResponse(relationshipToAPI(relationship)); return jsonResponse(relationshipToAPI(relationship));
}; });

View file

@ -1,14 +1,10 @@
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import { import {
createNewRelationship, createNewRelationship,
relationshipToAPI, relationshipToAPI,
} from "~database/entities/Relationship"; } from "~database/entities/Relationship";
import { import { getRelationshipToOtherUser } from "~database/entities/User";
getFromRequest, import { apiRoute, applyConfig } from "@api";
getRelationshipToOtherUser,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
@ -26,13 +22,10 @@ export const meta = applyConfig({
/** /**
* Unpin a user * Unpin a user
*/ */
export default async ( export default apiRoute(async (req, matchedRoute, extraData) => {
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
const { user: self } = await getFromRequest(req); const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
@ -84,4 +77,4 @@ export default async (
}); });
return jsonResponse(relationshipToAPI(relationship)); return jsonResponse(relationshipToAPI(relationship));
}; });

View file

@ -1,11 +1,6 @@
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { import { userRelations, userToAPI } from "~database/entities/User";
getFromRequest, import { apiRoute, applyConfig } from "@api";
userRelations,
userToAPI,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
@ -23,14 +18,14 @@ export const meta = applyConfig({
/** /**
* Find familiar followers (followers of a user that you also follow) * Find familiar followers (followers of a user that you also follow)
*/ */
export default async (req: Request): Promise<Response> => { export default apiRoute<{
const { user: self } = await getFromRequest(req); id: string[];
}>(async (req, matchedRoute, extraData) => {
const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
const { "id[]": ids } = await parseRequest<{ const { id: ids } = extraData.parsedRequest;
"id[]": string[];
}>(req);
// 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) {
@ -67,4 +62,4 @@ export default async (req: Request): Promise<Response> => {
}); });
return jsonResponse(output.map(o => userToAPI(o))); return jsonResponse(output.map(o => userToAPI(o)));
}; });

View file

@ -1,8 +1,6 @@
import { getConfig } from "~classes/configmanager";
import { parseRequest } from "@request";
import { jsonResponse } from "@response"; import { jsonResponse } from "@response";
import { tempmailDomains } from "@tempmail"; import { tempmailDomains } from "@tempmail";
import { applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
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"; import ISO6391 from "iso-639-1";
@ -19,22 +17,19 @@ export const meta = applyConfig({
}, },
}); });
/** export default apiRoute<{
* Creates a new user username: string;
*/ email: string;
export default async (req: Request): Promise<Response> => { password: string;
agreement: boolean;
locale: string;
reason: string;
}>(async (req, matchedRoute, extraData) => {
// TODO: Add Authorization check // TODO: Add Authorization check
const body = await parseRequest<{ const body = extraData.parsedRequest;
username: string;
email: string;
password: string;
agreement: boolean;
locale: string;
reason: string;
}>(req);
const config = getConfig(); const config = await extraData.configManager.getConfig();
if (!config.signups.registration) { if (!config.signups.registration) {
return jsonResponse( return jsonResponse(
@ -94,8 +89,8 @@ export default async (req: Request): Promise<Response> => {
// Check if username doesnt match filters // Check if username doesnt match filters
if ( if (
config.filters.username_filters.some( config.filters.username_filters.some(filter =>
filter => body.username?.match(filter) body.username?.match(filter)
) )
) { ) {
errors.details.username.push({ errors.details.username.push({
@ -203,4 +198,4 @@ export default async (req: Request): Promise<Response> => {
return new Response("", { return new Response("", {
status: 200, status: 200,
}); });
}; });

View file

@ -1,11 +1,9 @@
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { import {
createNewRelationship, createNewRelationship,
relationshipToAPI, relationshipToAPI,
} from "~database/entities/Relationship"; } from "~database/entities/Relationship";
import { getFromRequest } from "~database/entities/User"; import { apiRoute, applyConfig } from "@api";
import { applyConfig } from "@api";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
@ -23,14 +21,14 @@ export const meta = applyConfig({
/** /**
* Find relationships * Find relationships
*/ */
export default async (req: Request): Promise<Response> => { export default apiRoute<{
const { user: self } = await getFromRequest(req); id: string[];
}>(async (req, matchedRoute, extraData) => {
const { user: self } = extraData.auth;
if (!self) return errorResponse("Unauthorized", 401); if (!self) return errorResponse("Unauthorized", 401);
const { "id[]": ids } = await parseRequest<{ const { id: ids } = extraData.parsedRequest;
"id[]": string[];
}>(req);
// 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) {
@ -64,4 +62,4 @@ export default async (req: Request): Promise<Response> => {
); );
return jsonResponse(relationships.map(r => relationshipToAPI(r))); return jsonResponse(relationships.map(r => relationshipToAPI(r)));
}; });

View file

@ -1,11 +1,6 @@
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { import { userRelations, userToAPI } from "~database/entities/User";
getFromRequest, import { apiRoute, applyConfig } from "@api";
userRelations,
userToAPI,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { parseRequest } from "@request";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
@ -20,10 +15,16 @@ export const meta = applyConfig({
}, },
}); });
export default async (req: Request): Promise<Response> => { export default apiRoute<{
q?: string;
limit?: number;
offset?: number;
resolve?: boolean;
following?: boolean;
}>(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 } = await getFromRequest(req); const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
@ -32,13 +33,7 @@ export default async (req: Request): Promise<Response> => {
limit = 40, limit = 40,
offset, offset,
q, q,
} = await parseRequest<{ } = extraData.parsedRequest;
q?: string;
limit?: number;
offset?: number;
resolve?: boolean;
following?: boolean;
}>(req);
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);
@ -66,7 +61,7 @@ export default async (req: Request): Promise<Response> => {
ownerId: user.id, ownerId: user.id,
following, following,
}, },
} }
: undefined, : undefined,
}, },
take: Number(limit), take: Number(limit),
@ -75,4 +70,4 @@ export default async (req: Request): Promise<Response> => {
}); });
return jsonResponse(accounts.map(acct => userToAPI(acct))); return jsonResponse(accounts.map(acct => userToAPI(acct)));
}; });

View file

@ -1,21 +1,18 @@
import { getConfig } from "~classes/configmanager";
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { import { userRelations, userToAPI } from "~database/entities/User";
userRelations, import { apiRoute, applyConfig } from "@api";
userToAPI,
type AuthData,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { sanitize } from "isomorphic-dompurify"; import { sanitize } from "isomorphic-dompurify";
import { sanitizeHtml } from "@sanitization"; import { sanitizeHtml } from "@sanitization";
import { uploadFile } from "~classes/media";
import ISO6391 from "iso-639-1"; import ISO6391 from "iso-639-1";
import { parseEmojis } from "~database/entities/Emoji"; import { parseEmojis } from "~database/entities/Emoji";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import type { APISource } from "~types/entities/source"; import type { APISource } from "~types/entities/source";
import { convertTextToHtml } from "@formatting"; import { convertTextToHtml } from "@formatting";
import type { MatchedRoute } from "bun"; import { MediaBackendType } from "media-manager";
import type { MediaBackend } from "media-manager";
import { LocalMediaBackend } from "~packages/media-manager/backends/local";
import { S3MediaBackend } from "~packages/media-manager/backends/s3";
import { getUrl } from "~database/entities/Attachment";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["PATCH"], allowedMethods: ["PATCH"],
@ -29,19 +26,23 @@ export const meta = applyConfig({
}, },
}); });
/** export default apiRoute<{
* Patches a user display_name: string;
*/ note: string;
export default async ( avatar: File;
req: Request, header: File;
matchedRoute: MatchedRoute, locked: string;
auth: AuthData bot: string;
): Promise<Response> => { discoverable: string;
const { user } = auth; "source[privacy]": string;
"source[sensitive]": string;
"source[language]": string;
}>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
const config = getConfig(); const config = await extraData.configManager.getConfig();
const { const {
display_name, display_name,
@ -54,18 +55,7 @@ export default async (
"source[privacy]": source_privacy, "source[privacy]": source_privacy,
"source[sensitive]": source_sensitive, "source[sensitive]": source_sensitive,
"source[language]": source_language, "source[language]": source_language,
} = await parseRequest<{ } = extraData.parsedRequest;
display_name: string;
note: string;
avatar: File;
header: File;
locked: string;
bot: string;
discoverable: string;
"source[privacy]": string;
"source[sensitive]": string;
"source[language]": string;
}>(req);
const sanitizedNote = await sanitizeHtml(note ?? ""); const sanitizedNote = await sanitizeHtml(note ?? "");
@ -83,6 +73,20 @@ export default async (
}; };
} }
let mediaManager: MediaBackend;
switch (config.media.backend as MediaBackendType) {
case MediaBackendType.LOCAL:
mediaManager = new LocalMediaBackend(config);
break;
case MediaBackendType.S3:
mediaManager = new S3MediaBackend(config);
break;
default:
// TODO: Replace with logger
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 (
@ -146,8 +150,8 @@ export default async (
); );
} }
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access // @ts-expect-error Prisma Typescript doesn't include relations
(user.source as any).privacy = source_privacy; user.source.privacy = source_privacy;
} }
if (source_sensitive && user.source) { if (source_sensitive && user.source) {
@ -156,8 +160,8 @@ export default async (
return errorResponse("Sensitive must be a boolean", 422); return errorResponse("Sensitive must be a boolean", 422);
} }
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access // @ts-expect-error Prisma Typescript doesn't include relations
(user.source as any).sensitive = source_sensitive === "true"; user.source.sensitive = source_sensitive === "true";
} }
if (source_language && user.source) { if (source_language && user.source) {
@ -168,8 +172,8 @@ export default async (
); );
} }
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access // @ts-expect-error Prisma Typescript doesn't include relations
(user.source as any).language = source_language; user.source.language = source_language;
} }
if (avatar) { if (avatar) {
@ -181,9 +185,9 @@ export default async (
); );
} }
const hash = await uploadFile(avatar, config); const { uploadedFile } = await mediaManager.addFile(avatar);
user.avatar = hash || ""; user.avatar = getUrl(uploadedFile.name, config);
} }
if (header) { if (header) {
@ -195,9 +199,9 @@ export default async (
); );
} }
const hash = await uploadFile(header, config); const { uploadedFile } = await mediaManager.addFile(header);
user.header = hash || ""; user.header = getUrl(uploadedFile.name, config);
} }
if (locked) { if (locked) {
@ -263,4 +267,4 @@ export default async (
}); });
return jsonResponse(userToAPI(output)); return jsonResponse(userToAPI(output));
}; });

View file

@ -1,6 +1,6 @@
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { getFromRequest, userToAPI } from "~database/entities/User"; import { userToAPI } from "~database/entities/User";
import { applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -14,14 +14,14 @@ export const meta = applyConfig({
}, },
}); });
export default async (req: Request): Promise<Response> => { 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 } = await getFromRequest(req); 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,5 +1,4 @@
import { applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { randomBytes } from "crypto"; import { randomBytes } from "crypto";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
@ -19,13 +18,14 @@ export const meta = applyConfig({
/** /**
* Creates a new application to obtain OAuth 2 credentials * Creates a new application to obtain OAuth 2 credentials
*/ */
export default async (req: Request): Promise<Response> => { export default apiRoute<{
const { client_name, redirect_uris, scopes, website } = await parseRequest<{ client_name: string;
client_name: string; redirect_uris: string;
redirect_uris: string; scopes: string;
scopes: string; website: string;
website: string; }>(async (req, matchedRoute, extraData) => {
}>(req); const { client_name, redirect_uris, scopes, website } =
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) {
@ -62,4 +62,4 @@ export default async (req: Request): Promise<Response> => {
redirect_uri: application.redirect_uris, redirect_uri: application.redirect_uris,
vapid_link: application.vapid_key, vapid_link: application.vapid_key,
}); });
}; });

View file

@ -1,7 +1,6 @@
import { applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { getFromToken } from "~database/entities/Application"; import { getFromToken } from "~database/entities/Application";
import { getFromRequest } from "~database/entities/User";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -18,8 +17,8 @@ export const meta = applyConfig({
/** /**
* Returns OAuth2 credentials * Returns OAuth2 credentials
*/ */
export default async (req: Request): Promise<Response> => { export default apiRoute(async (req, matchedRoute, extraData) => {
const { user, token } = await getFromRequest(req); 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);
@ -32,4 +31,4 @@ export default async (req: Request): Promise<Response> => {
redirect_uris: application.redirect_uris, redirect_uris: application.redirect_uris,
scopes: application.scopes, scopes: application.scopes,
}); });
}; });

View file

@ -1,10 +1,6 @@
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { import { userRelations, userToAPI } from "~database/entities/User";
getFromRequest, import { apiRoute, applyConfig } from "@api";
userRelations,
userToAPI,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
@ -19,8 +15,8 @@ export const meta = applyConfig({
}, },
}); });
export default async (req: Request): Promise<Response> => { export default apiRoute(async (req, matchedRoute, extraData) => {
const { user } = await getFromRequest(req); const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
@ -37,4 +33,4 @@ export default async (req: Request): Promise<Response> => {
}); });
return jsonResponse(blocks.map(u => userToAPI(u))); return jsonResponse(blocks.map(u => userToAPI(u)));
}; });

View file

@ -1,4 +1,4 @@
import { applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { jsonResponse } from "@response"; import { jsonResponse } from "@response";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { emojiToAPI } from "~database/entities/Emoji"; import { emojiToAPI } from "~database/entities/Emoji";
@ -15,11 +15,7 @@ export const meta = applyConfig({
}, },
}); });
/** export default apiRoute(async () => {
* S
*/
// eslint-disable-next-line @typescript-eslint/require-await
export default async (): Promise<Response> => {
const emojis = await client.emoji.findMany({ const emojis = await client.emoji.findMany({
where: { where: {
instanceId: null, instanceId: null,
@ -29,4 +25,4 @@ export default async (): Promise<Response> => {
return jsonResponse( return jsonResponse(
await Promise.all(emojis.map(emoji => emojiToAPI(emoji))) await Promise.all(emojis.map(emoji => emojiToAPI(emoji)))
); );
}; });

View file

@ -1,8 +1,6 @@
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { getFromRequest } from "~database/entities/User"; import { apiRoute, applyConfig } from "@api";
import { applyConfig } from "@api";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { parseRequest } from "@request";
import { statusAndUserRelations, statusToAPI } from "~database/entities/Status"; import { statusAndUserRelations, statusToAPI } from "~database/entities/Status";
export const meta = applyConfig({ export const meta = applyConfig({
@ -17,20 +15,15 @@ export const meta = applyConfig({
}, },
}); });
export default async (req: Request): Promise<Response> => { export default apiRoute<{
const { user } = await getFromRequest(req); max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
}>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const { const { limit = 20, max_id, min_id, since_id } = extraData.parsedRequest;
limit = 20,
max_id,
min_id,
since_id,
} = await parseRequest<{
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
}>(req);
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);
@ -77,4 +70,4 @@ export default async (req: Request): Promise<Response> => {
Link: linkHeader.join(", "), Link: linkHeader.join(", "),
} }
); );
}; });

View file

@ -1,8 +1,7 @@
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { getFromRequest, userRelations } from "~database/entities/User"; import { userRelations } from "~database/entities/User";
import { applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import type { MatchedRoute } from "bun";
import { import {
checkForBidirectionalRelationships, checkForBidirectionalRelationships,
relationshipToAPI, relationshipToAPI,
@ -20,11 +19,8 @@ export const meta = applyConfig({
}, },
}); });
export default async ( export default apiRoute(async (req, matchedRoute, extraData) => {
req: Request, const { user } = extraData.auth;
matchedRoute: MatchedRoute
): Promise<Response> => {
const { user } = await getFromRequest(req);
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
@ -76,4 +72,4 @@ export default async (
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,8 +1,7 @@
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { getFromRequest, userRelations } from "~database/entities/User"; import { userRelations } from "~database/entities/User";
import { applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import type { MatchedRoute } from "bun";
import { import {
checkForBidirectionalRelationships, checkForBidirectionalRelationships,
relationshipToAPI, relationshipToAPI,
@ -20,11 +19,8 @@ export const meta = applyConfig({
}, },
}); });
export default async ( export default apiRoute(async (req, matchedRoute, extraData) => {
req: Request, const { user } = extraData.auth;
matchedRoute: MatchedRoute
): Promise<Response> => {
const { user } = await getFromRequest(req);
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
@ -64,4 +60,4 @@ export default async (
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,12 +1,7 @@
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { import { userRelations, userToAPI } from "~database/entities/User";
getFromRequest, import { apiRoute, applyConfig } from "@api";
userRelations,
userToAPI,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { parseRequest } from "@request";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
@ -20,20 +15,15 @@ export const meta = applyConfig({
}, },
}); });
export default async (req: Request): Promise<Response> => { export default apiRoute<{
const { user } = await getFromRequest(req); max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
}>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
const { const { limit = 20, max_id, min_id, since_id } = extraData.parsedRequest;
limit = 20,
max_id,
min_id,
since_id,
} = await parseRequest<{
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
}>(req);
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);
@ -79,4 +69,4 @@ export default async (req: Request): Promise<Response> => {
Link: linkHeader.join(", "), Link: linkHeader.join(", "),
} }
); );
}; });

View file

@ -1,5 +1,4 @@
import { applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { getConfig } from "~classes/configmanager";
import { jsonResponse } from "@response"; import { jsonResponse } from "@response";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { userRelations, userToAPI } from "~database/entities/User"; import { userRelations, userToAPI } from "~database/entities/User";
@ -18,12 +17,8 @@ export const meta = applyConfig({
}, },
}); });
/** export default apiRoute(async (req, matchedRoute, extraData) => {
* Creates a new user const config = await extraData.configManager.getConfig();
*/
// eslint-disable-next-line @typescript-eslint/require-await
export default async (): Promise<Response> => {
const config = getConfig();
// Get software version from package.json // Get software version from package.json
const version = manifest.version; const version = manifest.version;
@ -159,4 +154,4 @@ export default async (): Promise<Response> => {
}, },
contact_account: contactAccount ? userToAPI(contactAccount) : null, contact_account: contactAccount ? userToAPI(contactAccount) : null,
} as APIInstance); } as APIInstance);
}; });

View file

@ -1,15 +1,13 @@
import { applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { getFromRequest } from "~database/entities/User";
import type { APIRouteMeta } from "~types/api";
import { uploadFile } from "~classes/media";
import { getConfig } from "~classes/configmanager";
import { attachmentToAPI, getUrl } from "~database/entities/Attachment"; import { attachmentToAPI, getUrl } from "~database/entities/Attachment";
import type { MatchedRoute } from "bun"; import type { MediaBackend } from "media-manager";
import { parseRequest } from "@request"; import { MediaBackendType } from "media-manager";
import { LocalMediaBackend } from "~packages/media-manager/backends/local";
import { S3MediaBackend } from "~packages/media-manager/backends/s3";
export const meta: APIRouteMeta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET", "PUT"], allowedMethods: ["GET", "PUT"],
ratelimits: { ratelimits: {
max: 10, max: 10,
@ -25,11 +23,12 @@ export const meta: APIRouteMeta = applyConfig({
/** /**
* Get media information * Get media information
*/ */
export default async ( export default apiRoute<{
req: Request, thumbnail?: File;
matchedRoute: MatchedRoute description?: string;
): Promise<Response> => { focus?: string;
const { user } = await getFromRequest(req); }>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
if (!user) { if (!user) {
return errorResponse("Unauthorized", 401); return errorResponse("Unauthorized", 401);
@ -47,7 +46,7 @@ export default async (
return errorResponse("Media not found", 404); return errorResponse("Media not found", 404);
} }
const config = getConfig(); const config = await extraData.configManager.getConfig();
switch (req.method) { switch (req.method) {
case "GET": { case "GET": {
@ -60,21 +59,27 @@ export default async (
} }
} }
case "PUT": { case "PUT": {
const { description, thumbnail } = await parseRequest<{ const { description, thumbnail } = extraData.parsedRequest;
thumbnail?: File;
description?: string;
focus?: string;
}>(req);
let thumbnailUrl = attachment.thumbnail_url; let thumbnailUrl = attachment.thumbnail_url;
if (thumbnail) { let mediaManager: MediaBackend;
const hash = await uploadFile(
thumbnail as unknown as File,
config
);
thumbnailUrl = hash ? getUrl(hash, config) : ""; switch (config.media.backend as MediaBackendType) {
case MediaBackendType.LOCAL:
mediaManager = new LocalMediaBackend(config);
break;
case MediaBackendType.S3:
mediaManager = new S3MediaBackend(config);
break;
default:
// TODO: Replace with logger
throw new Error("Invalid media backend");
}
if (thumbnail) {
const { uploadedFile } = await mediaManager.addFile(thumbnail);
thumbnailUrl = getUrl(uploadedFile.name, config);
} }
const descriptionText = description || attachment.description; const descriptionText = description || attachment.description;
@ -101,4 +106,4 @@ export default async (
} }
return errorResponse("Method not allowed", 405); return errorResponse("Method not allowed", 405);
}; });

View file

@ -1,15 +1,15 @@
import { applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { encode } from "blurhash"; import { encode } from "blurhash";
import { getFromRequest } from "~database/entities/User";
import type { APIRouteMeta } from "~types/api";
import sharp from "sharp"; import sharp from "sharp";
import { uploadFile } from "~classes/media";
import { getConfig } from "~classes/configmanager";
import { attachmentToAPI, getUrl } from "~database/entities/Attachment"; import { attachmentToAPI, getUrl } from "~database/entities/Attachment";
import { MediaBackendType } from "media-manager";
import type { MediaBackend } from "media-manager";
import { LocalMediaBackend } from "~packages/media-manager/backends/local";
import { S3MediaBackend } from "~packages/media-manager/backends/s3";
export const meta: APIRouteMeta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
ratelimits: { ratelimits: {
max: 10, max: 10,
@ -25,27 +25,26 @@ export const meta: APIRouteMeta = applyConfig({
/** /**
* Upload new media * Upload new media
*/ */
export default async (req: Request): Promise<Response> => { export default apiRoute<{
const { user } = await getFromRequest(req); file: File;
thumbnail?: File;
description?: string;
// TODO: Add focus
focus?: string;
}>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
if (!user) { if (!user) {
return errorResponse("Unauthorized", 401); return errorResponse("Unauthorized", 401);
} }
const form = await req.formData(); const { file, thumbnail, description } = extraData.parsedRequest;
const file = form.get("file") as unknown as File | undefined;
const thumbnail = form.get("thumbnail");
const description = form.get("description") as string | undefined;
// Floating point numbers from -1.0 to 1.0, comma delimited
// const focus = form.get("focus");
if (!file) { if (!file) {
return errorResponse("No file provided", 400); return errorResponse("No file provided", 400);
} }
const config = 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(
@ -86,21 +85,35 @@ export default async (req: Request): Promise<Response> => {
metadata?.height ?? 0, metadata?.height ?? 0,
4, 4,
4 4
) )
: null; : null;
let url = ""; let url = "";
const hash = await uploadFile(file, config); let mediaManager: MediaBackend;
url = hash ? getUrl(hash, config) : ""; switch (config.media.backend as MediaBackendType) {
case MediaBackendType.LOCAL:
mediaManager = new LocalMediaBackend(config);
break;
case MediaBackendType.S3:
mediaManager = new S3MediaBackend(config);
break;
default:
// TODO: Replace with logger
throw new Error("Invalid media backend");
}
const { uploadedFile } = await mediaManager.addFile(file);
url = getUrl(uploadedFile.name, config);
let thumbnailUrl = ""; let thumbnailUrl = "";
if (thumbnail) { if (thumbnail) {
const hash = await uploadFile(thumbnail as unknown as File, config); const { uploadedFile } = await mediaManager.addFile(thumbnail);
thumbnailUrl = hash ? getUrl(hash, config) : ""; thumbnailUrl = getUrl(uploadedFile.name, config);
} }
const newAttachment = await client.attachment.create({ const newAttachment = await client.attachment.create({
@ -120,4 +133,4 @@ export default async (req: Request): Promise<Response> => {
// 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,10 +1,6 @@
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { import { userRelations, userToAPI } from "~database/entities/User";
getFromRequest, import { apiRoute, applyConfig } from "@api";
userRelations,
userToAPI,
} from "~database/entities/User";
import { applyConfig } from "@api";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
export const meta = applyConfig({ export const meta = applyConfig({
@ -19,8 +15,8 @@ export const meta = applyConfig({
}, },
}); });
export default async (req: Request): Promise<Response> => { export default apiRoute(async (req, matchedRoute, extraData) => {
const { user } = await getFromRequest(req); const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
@ -37,4 +33,4 @@ export default async (req: Request): Promise<Response> => {
}); });
return jsonResponse(blocks.map(u => userToAPI(u))); return jsonResponse(blocks.map(u => userToAPI(u)));
}; });

View file

@ -1,9 +1,8 @@
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { getFromRequest, userRelations } from "~database/entities/User"; import { userRelations } from "~database/entities/User";
import { applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { statusAndUserRelations } from "~database/entities/Status"; import { statusAndUserRelations } from "~database/entities/Status";
import { parseRequest } from "@request";
import { notificationToAPI } from "~database/entities/Notification"; import { notificationToAPI } from "~database/entities/Notification";
export const meta = applyConfig({ export const meta = applyConfig({
@ -18,8 +17,16 @@ export const meta = applyConfig({
}, },
}); });
export default async (req: Request): Promise<Response> => { export default apiRoute<{
const { user } = await getFromRequest(req); max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
exclude_types?: string[];
types?: string[];
account_id?: string;
}>(async (req, matchedRoute, extraData) => {
const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
@ -31,15 +38,7 @@ export default async (req: Request): Promise<Response> => {
min_id, min_id,
since_id, since_id,
types, types,
} = await parseRequest<{ } = extraData.parsedRequest;
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
exclude_types?: string[];
types?: string[];
account_id?: string;
}>(req);
if (limit > 30) return errorResponse("Limit too high", 400); if (limit > 30) return errorResponse("Limit too high", 400);
@ -85,8 +84,9 @@ export default async (req: Request): Promise<Response> => {
`<${urlWithoutQuery}?max_id=${objects[0].id}&limit=${limit}>; rel="next"` `<${urlWithoutQuery}?max_id=${objects[0].id}&limit=${limit}>; rel="next"`
); );
linkHeader.push( linkHeader.push(
`<${urlWithoutQuery}?since_id=${objects.at(-1) `<${urlWithoutQuery}?since_id=${
?.id}&limit=${limit}>; rel="prev"` objects.at(-1)?.id
}&limit=${limit}>; rel="prev"`
); );
} }
@ -97,4 +97,4 @@ export default async (req: Request): Promise<Response> => {
Link: linkHeader.join(", "), Link: linkHeader.join(", "),
} }
); );
}; });

View file

@ -1,14 +1,9 @@
import { applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { import { userRelations, userToAPI } from "~database/entities/User";
getFromRequest,
userRelations,
userToAPI,
} from "~database/entities/User";
import type { APIRouteMeta } from "~types/api";
export const meta: APIRouteMeta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["DELETE"], allowedMethods: ["DELETE"],
ratelimits: { ratelimits: {
max: 10, max: 10,
@ -23,8 +18,8 @@ export const meta: APIRouteMeta = applyConfig({
/** /**
* Deletes a user avatar * Deletes a user avatar
*/ */
export default async (req: Request): Promise<Response> => { export default apiRoute(async (req, matchedRoute, extraData) => {
const { user } = await getFromRequest(req); const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
@ -40,4 +35,4 @@ export default async (req: Request): Promise<Response> => {
}); });
return jsonResponse(userToAPI(newUser)); return jsonResponse(userToAPI(newUser));
}; });

View file

@ -1,14 +1,9 @@
import { applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { import { userRelations, userToAPI } from "~database/entities/User";
getFromRequest,
userRelations,
userToAPI,
} from "~database/entities/User";
import type { APIRouteMeta } from "~types/api";
export const meta: APIRouteMeta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["DELETE"], allowedMethods: ["DELETE"],
ratelimits: { ratelimits: {
max: 10, max: 10,
@ -23,8 +18,8 @@ export const meta: APIRouteMeta = applyConfig({
/** /**
* Deletes a user header * Deletes a user header
*/ */
export default async (req: Request): Promise<Response> => { export default apiRoute(async (req, matchedRoute, extraData) => {
const { user } = await getFromRequest(req); const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
@ -40,4 +35,4 @@ export default async (req: Request): Promise<Response> => {
}); });
return jsonResponse(userToAPI(newUser)); return jsonResponse(userToAPI(newUser));
}; });

View file

@ -1,6 +1,5 @@
import { applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { import {
getAncestors, getAncestors,
@ -8,10 +7,8 @@ import {
statusAndUserRelations, statusAndUserRelations,
statusToAPI, statusToAPI,
} from "~database/entities/Status"; } from "~database/entities/Status";
import { getFromRequest } from "~database/entities/User";
import type { APIRouteMeta } from "~types/api";
export const meta: APIRouteMeta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
ratelimits: { ratelimits: {
max: 8, max: 8,
@ -26,15 +23,12 @@ export const meta: APIRouteMeta = applyConfig({
/** /**
* Fetch a user * Fetch a user
*/ */
export default async ( export default apiRoute(async (req, matchedRoute, extraData) => {
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
// Public for public statuses limited to 40 ancestors and 60 descendants with a maximum depth of 20. // Public for public statuses limited to 40 ancestors and 60 descendants with a maximum depth of 20.
// User token + read:statuses for up to 4,096 ancestors, 4,096 descendants, unlimited depth, and private statuses. // User token + read:statuses for up to 4,096 ancestors, 4,096 descendants, unlimited depth, and private statuses.
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
const { user } = await getFromRequest(req); const { user } = extraData.auth;
const foundStatus = await client.status.findUnique({ const foundStatus = await client.status.findUnique({
where: { id }, where: { id },
@ -55,4 +49,4 @@ export default async (
descendants.map(status => statusToAPI(status, user || undefined)) descendants.map(status => statusToAPI(status, user || undefined))
), ),
}); });
}; });

View file

@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { createLike } from "~database/entities/Like"; import { createLike } from "~database/entities/Like";
import { import {
@ -9,11 +8,9 @@ import {
statusAndUserRelations, statusAndUserRelations,
statusToAPI, statusToAPI,
} from "~database/entities/Status"; } from "~database/entities/Status";
import { getFromRequest } from "~database/entities/User";
import type { APIRouteMeta } from "~types/api";
import type { APIStatus } from "~types/entities/status"; import type { APIStatus } from "~types/entities/status";
export const meta: APIRouteMeta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
ratelimits: { ratelimits: {
max: 100, max: 100,
@ -28,13 +25,10 @@ export const meta: APIRouteMeta = applyConfig({
/** /**
* Favourite a post * Favourite a post
*/ */
export default async ( export default apiRoute(async (req, matchedRoute, extraData) => {
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
const { user } = await getFromRequest(req); const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
@ -63,4 +57,4 @@ export default async (
favourited: true, favourited: true,
favourites_count: status._count.likes + 1, favourites_count: status._count.likes + 1,
} as APIStatus); } as APIStatus);
}; });

View file

@ -1,21 +1,13 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { apiRoute, applyConfig } from "@api";
import { applyConfig } from "@api";
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { import {
isViewableByUser, isViewableByUser,
statusAndUserRelations, statusAndUserRelations,
} from "~database/entities/Status"; } from "~database/entities/Status";
import { import { userRelations, userToAPI } from "~database/entities/User";
getFromRequest,
userRelations,
userToAPI,
} from "~database/entities/User";
import type { APIRouteMeta } from "~types/api";
export const meta: APIRouteMeta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
ratelimits: { ratelimits: {
max: 100, max: 100,
@ -30,13 +22,15 @@ export const meta: APIRouteMeta = applyConfig({
/** /**
* Fetch users who favourited the post * Fetch users who favourited the post
*/ */
export default async ( export default apiRoute<{
req: Request, max_id?: string;
matchedRoute: MatchedRoute min_id?: string;
): Promise<Response> => { since_id?: string;
limit?: number;
}>(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
const { user } = await getFromRequest(req); const { user } = extraData.auth;
const status = await client.status.findUnique({ const status = await client.status.findUnique({
where: { id }, where: { id },
@ -52,12 +46,7 @@ export default async (
min_id = null, min_id = null,
since_id = null, since_id = null,
limit = 40, limit = 40,
} = await parseRequest<{ } = extraData.parsedRequest;
max_id?: string;
min_id?: string;
since_id?: string;
limit?: number;
}>(req);
// Check for limit limits // Check for limit limits
if (limit > 80) return errorResponse("Invalid limit (maximum is 80)", 400); if (limit > 80) return errorResponse("Invalid limit (maximum is 80)", 400);
@ -111,4 +100,4 @@ export default async (
Link: linkHeader.join(", "), Link: linkHeader.join(", "),
} }
); );
}; });

View file

@ -1,9 +1,6 @@
import { applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { getConfig } from "~classes/configmanager";
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { sanitizeHtml } from "@sanitization"; import { sanitizeHtml } from "@sanitization";
import type { MatchedRoute } from "bun";
import { parse } from "marked"; import { parse } from "marked";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { import {
@ -12,10 +9,8 @@ import {
statusAndUserRelations, statusAndUserRelations,
statusToAPI, statusToAPI,
} from "~database/entities/Status"; } from "~database/entities/Status";
import { getFromRequest } from "~database/entities/User";
import type { APIRouteMeta } from "~types/api";
export const meta: APIRouteMeta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET", "DELETE", "PUT"], allowedMethods: ["GET", "DELETE", "PUT"],
ratelimits: { ratelimits: {
max: 100, max: 100,
@ -31,20 +26,28 @@ export const meta: APIRouteMeta = applyConfig({
/** /**
* Fetch a user * Fetch a user
*/ */
export default async ( export default apiRoute<{
req: Request, status?: string;
matchedRoute: MatchedRoute spoiler_text?: string;
): Promise<Response> => { sensitive?: boolean;
language?: string;
content_type?: string;
media_ids?: string[];
"poll[options]"?: string[];
"poll[expires_in]"?: number;
"poll[multiple]"?: boolean;
"poll[hide_totals]"?: boolean;
}>(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
const { user } = await getFromRequest(req); const { user } = extraData.auth;
const status = await client.status.findUnique({ const status = await client.status.findUnique({
where: { id }, where: { id },
include: statusAndUserRelations, include: statusAndUserRelations,
}); });
const config = getConfig(); const config = await extraData.configManager.getConfig();
// Check if user is authorized to view this status (if it's private) // Check if user is authorized to view this status (if it's private)
if (!status || !isViewableByUser(status, user)) if (!status || !isViewableByUser(status, user))
@ -85,22 +88,11 @@ export default async (
status: statusText, status: statusText,
content_type, content_type,
"poll[expires_in]": expires_in, "poll[expires_in]": expires_in,
"poll[options][]": options, "poll[options]": options,
"media_ids[]": media_ids, media_ids: media_ids,
spoiler_text, spoiler_text,
sensitive, sensitive,
} = await parseRequest<{ } = extraData.parsedRequest;
status?: string;
spoiler_text?: string;
sensitive?: boolean;
language?: string;
content_type?: string;
"media_ids[]"?: string[];
"poll[options][]"?: string[];
"poll[expires_in]"?: number;
"poll[multiple]"?: boolean;
"poll[hide_totals]"?: boolean;
}>(req);
// TODO: Add Poll support // TODO: Add Poll support
// Validate status // Validate status
@ -171,11 +163,11 @@ export default async (
let sanitizedStatus: string; let sanitizedStatus: string;
if (content_type === "text/markdown") { if (content_type === "text/markdown") {
sanitizedStatus = await sanitizeHtml(parse(statusText ?? "")); sanitizedStatus = await sanitizeHtml(await parse(statusText ?? ""));
} else if (content_type === "text/x.misskeymarkdown") { } else if (content_type === "text/x.misskeymarkdown") {
// Parse as MFM // Parse as MFM
// TODO: Parse as MFM // TODO: Parse as MFM
sanitizedStatus = await sanitizeHtml(parse(statusText ?? "")); sanitizedStatus = await sanitizeHtml(await parse(statusText ?? ""));
} else { } else {
sanitizedStatus = await sanitizeHtml(statusText ?? ""); sanitizedStatus = await sanitizeHtml(statusText ?? "");
} }
@ -189,8 +181,8 @@ export default async (
// Check if status body doesnt match filters // Check if status body doesnt match filters
if ( if (
config.filters.note_filters.some( config.filters.note_filters.some(filter =>
filter => statusText?.match(filter) statusText?.match(filter)
) )
) { ) {
return errorResponse("Status contains blocked words", 422); return errorResponse("Status contains blocked words", 422);
@ -223,4 +215,4 @@ export default async (
} }
return jsonResponse({}); return jsonResponse({});
}; });

View file

@ -1,13 +1,10 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { statusAndUserRelations, statusToAPI } from "~database/entities/Status"; import { statusAndUserRelations, statusToAPI } from "~database/entities/Status";
import { getFromRequest } from "~database/entities/User";
import type { APIRouteMeta } from "~types/api";
export const meta: APIRouteMeta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
ratelimits: { ratelimits: {
max: 100, max: 100,
@ -22,13 +19,10 @@ export const meta: APIRouteMeta = applyConfig({
/** /**
* Pin a post * Pin a post
*/ */
export default async ( export default apiRoute(async (req, matchedRoute, extraData) => {
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
const { user } = await getFromRequest(req); const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
@ -62,4 +56,4 @@ export default async (
if (!status) return errorResponse("Record not found", 404); if (!status) return errorResponse("Record not found", 404);
return jsonResponse(statusToAPI(status, user)); return jsonResponse(statusToAPI(status, user));
}; });

View file

@ -1,22 +1,15 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { getConfig } from "~classes/configmanager";
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { import {
isViewableByUser, isViewableByUser,
statusAndUserRelations, statusAndUserRelations,
statusToAPI, statusToAPI,
} from "~database/entities/Status"; } from "~database/entities/Status";
import { import { type UserWithRelations } from "~database/entities/User";
getFromRequest,
type UserWithRelations,
} from "~database/entities/User";
import type { APIRouteMeta } from "~types/api";
export const meta: APIRouteMeta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
ratelimits: { ratelimits: {
max: 100, max: 100,
@ -31,20 +24,17 @@ export const meta: APIRouteMeta = applyConfig({
/** /**
* Reblogs a post * Reblogs a post
*/ */
export default async ( export default apiRoute<{
req: Request, visibility: "public" | "unlisted" | "private";
matchedRoute: MatchedRoute }>(async (req, matchedRoute, extraData) => {
): Promise<Response> => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
const config = getConfig(); const config = await extraData.configManager.getConfig();
const { user } = await getFromRequest(req); const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
const { visibility = "public" } = await parseRequest<{ const { visibility = "public" } = extraData.parsedRequest;
visibility: "public" | "unlisted" | "private";
}>(req);
const status = await client.status.findUnique({ const status = await client.status.findUnique({
where: { id }, where: { id },
@ -107,4 +97,4 @@ export default async (
user user
) )
); );
}; });

View file

@ -1,21 +1,13 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { apiRoute, applyConfig } from "@api";
import { applyConfig } from "@api";
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { import {
isViewableByUser, isViewableByUser,
statusAndUserRelations, statusAndUserRelations,
} from "~database/entities/Status"; } from "~database/entities/Status";
import { import { userRelations, userToAPI } from "~database/entities/User";
getFromRequest,
userRelations,
userToAPI,
} from "~database/entities/User";
import type { APIRouteMeta } from "~types/api";
export const meta: APIRouteMeta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
ratelimits: { ratelimits: {
max: 100, max: 100,
@ -30,13 +22,15 @@ export const meta: APIRouteMeta = applyConfig({
/** /**
* Fetch users who reblogged the post * Fetch users who reblogged the post
*/ */
export default async ( export default apiRoute<{
req: Request, max_id?: string;
matchedRoute: MatchedRoute min_id?: string;
): Promise<Response> => { since_id?: string;
limit?: number;
}>(async (req, matchedRoute, extraData) => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
const { user } = await getFromRequest(req); const { user } = extraData.auth;
const status = await client.status.findUnique({ const status = await client.status.findUnique({
where: { id }, where: { id },
@ -52,12 +46,7 @@ export default async (
min_id = null, min_id = null,
since_id = null, since_id = null,
limit = 40, limit = 40,
} = await parseRequest<{ } = extraData.parsedRequest;
max_id?: string;
min_id?: string;
since_id?: string;
limit?: number;
}>(req);
// Check for limit limits // Check for limit limits
if (limit > 80) return errorResponse("Invalid limit (maximum is 80)", 400); if (limit > 80) return errorResponse("Invalid limit (maximum is 80)", 400);
@ -112,4 +101,4 @@ export default async (
Link: linkHeader.join(", "), Link: linkHeader.join(", "),
} }
); );
}; });

View file

@ -1,19 +1,12 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { apiRoute, applyConfig } from "@api";
import { applyConfig } from "@api"; import { errorResponse } from "@response";
import { errorResponse, jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { createLike } from "~database/entities/Like";
import { import {
isViewableByUser, isViewableByUser,
statusAndUserRelations, statusAndUserRelations,
statusToAPI,
} from "~database/entities/Status"; } from "~database/entities/Status";
import { getFromRequest } from "~database/entities/User";
import type { APIRouteMeta } from "~types/api";
import type { APIStatus } from "~types/entities/status";
export const meta: APIRouteMeta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["GET"], allowedMethods: ["GET"],
ratelimits: { ratelimits: {
max: 100, max: 100,
@ -28,13 +21,10 @@ export const meta: APIRouteMeta = applyConfig({
/** /**
* Favourite a post * Favourite a post
*/ */
export default async ( export default apiRoute(async (req, matchedRoute, extraData) => {
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
const { user } = await getFromRequest(req); const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
@ -46,4 +36,6 @@ export default async (
// Check if user is authorized to view this status (if it's private) // Check if user is authorized to view this status (if it's private)
if (!status || !isViewableByUser(status, user)) if (!status || !isViewableByUser(status, user))
return errorResponse("Record not found", 404); return errorResponse("Record not found", 404);
};
return errorResponse("Not implemented yet");
});

View file

@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import type { MatchedRoute } from "bun";
import { client } from "~database/datasource"; import { client } from "~database/datasource";
import { deleteLike } from "~database/entities/Like"; import { deleteLike } from "~database/entities/Like";
import { import {
@ -9,11 +8,9 @@ import {
statusAndUserRelations, statusAndUserRelations,
statusToAPI, statusToAPI,
} from "~database/entities/Status"; } from "~database/entities/Status";
import { getFromRequest } from "~database/entities/User";
import type { APIRouteMeta } from "~types/api";
import type { APIStatus } from "~types/entities/status"; import type { APIStatus } from "~types/entities/status";
export const meta: APIRouteMeta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
ratelimits: { ratelimits: {
max: 100, max: 100,
@ -28,13 +25,10 @@ export const meta: APIRouteMeta = applyConfig({
/** /**
* Unfavourite a post * Unfavourite a post
*/ */
export default async ( export default apiRoute(async (req, matchedRoute, extraData) => {
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const id = matchedRoute.params.id; const id = matchedRoute.params.id;
const { user } = await getFromRequest(req); const { user } = extraData.auth;
if (!user) return errorResponse("Unauthorized", 401); if (!user) return errorResponse("Unauthorized", 401);
@ -54,4 +48,4 @@ export default async (
favourited: false, favourited: false,
favourites_count: status._count.likes - 1, favourites_count: status._count.likes - 1,
} as APIStatus); } as APIStatus);
}; });

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