mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 16:38:19 +01:00
Merge pull request #9 from lysand-org/refactor/packages
refactor: Refactor Lysand into submodules
This commit is contained in:
commit
ae857cd4fa
|
|
@ -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
2
.gitignore
vendored
|
|
@ -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
18
.vscode/launch.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
|
|
@ -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": "."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
11
Dockerfile
11
Dockerfile
|
|
@ -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
|
||||||
|
|
|
||||||
24
README.md
24
README.md
|
|
@ -4,14 +4,11 @@
|
||||||
|
|
||||||
       [](code_of_conduct.md)
|
       [](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`
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
8
build.ts
8
build.ts
|
|
@ -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
|
||||||
|
|
|
||||||
2
bunfig.toml
Normal file
2
bunfig.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
[install.scopes]
|
||||||
|
"@jsr" = "https://npm.jsr.io"
|
||||||
|
|
@ -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 };
|
|
||||||
273
classes/media.ts
273
classes/media.ts
|
|
@ -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
127
cli.ts
|
|
@ -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);
|
||||||
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}`,
|
||||||
|
|
|
||||||
|
|
@ -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 "";
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -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}`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -612,9 +616,9 @@ export const statusToAPI = async (
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
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 {
|
||||||
|
|
|
||||||
|
|
@ -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
201
index.ts
|
|
@ -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`
|
|
||||||
)}`
|
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -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
BIN
packages/cli-parser/bun.lockb
Executable file
Binary file not shown.
23
packages/cli-parser/cli-builder.type.ts
Normal file
23
packages/cli-parser/cli-builder.type.ts
Normal 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",
|
||||||
|
}
|
||||||
398
packages/cli-parser/index.ts
Normal file
398
packages/cli-parser/index.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
6
packages/cli-parser/package.json
Normal file
6
packages/cli-parser/package.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "arg-parser",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"main": "index.ts",
|
||||||
|
"dependencies": { "strip-ansi": "^7.1.0" }
|
||||||
|
}
|
||||||
485
packages/cli-parser/tests/cli-builder.test.ts
Normal file
485
packages/cli-parser/tests/cli-builder.test.ts
Normal 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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 };
|
|
||||||
122
packages/config-manager/index.ts
Normal file
122
packages/config-manager/index.ts
Normal 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;
|
||||||
6
packages/config-manager/package.json
Normal file
6
packages/config-manager/package.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "config-manager",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"main": "index.ts",
|
||||||
|
"dependencies": {}
|
||||||
|
}
|
||||||
96
packages/config-manager/tests/config-manager.test.ts
Normal file
96
packages/config-manager/tests/config-manager.test.ts
Normal 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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
171
packages/log-manager/index.ts
Normal file
171
packages/log-manager/index.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
6
packages/log-manager/package.json
Normal file
6
packages/log-manager/package.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "log-manager",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"main": "index.ts",
|
||||||
|
"dependencies": { }
|
||||||
|
}
|
||||||
231
packages/log-manager/tests/log-manager.test.ts
Normal file
231
packages/log-manager/tests/log-manager.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
64
packages/media-manager/backends/local.ts
Normal file
64
packages/media-manager/backends/local.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
69
packages/media-manager/backends/s3.ts
Normal file
69
packages/media-manager/backends/s3.ts
Normal 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
BIN
packages/media-manager/bun.lockb
Executable file
Binary file not shown.
2
packages/media-manager/bunfig.toml
Normal file
2
packages/media-manager/bunfig.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
[install.scopes]
|
||||||
|
"@jsr" = "https://npm.jsr.io"
|
||||||
83
packages/media-manager/index.ts
Normal file
83
packages/media-manager/index.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
packages/media-manager/media-converter.ts
Normal file
94
packages/media-manager/media-converter.ts
Normal 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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
6
packages/media-manager/package.json
Normal file
6
packages/media-manager/package.json
Normal 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" }
|
||||||
|
}
|
||||||
243
packages/media-manager/tests/media-backends.test.ts
Normal file
243
packages/media-manager/tests/media-backends.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
65
packages/media-manager/tests/media-manager.test.ts
Normal file
65
packages/media-manager/tests/media-manager.test.ts
Normal 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}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
BIN
packages/media-manager/tests/megamind.jpg
Normal file
BIN
packages/media-manager/tests/megamind.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
170
packages/request-parser/index.ts
Normal file
170
packages/request-parser/index.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
packages/request-parser/package.json
Normal file
6
packages/request-parser/package.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "request-parser",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"main": "index.ts",
|
||||||
|
"dependencies": {}
|
||||||
|
}
|
||||||
158
packages/request-parser/tests/request-parser.test.ts
Normal file
158
packages/request-parser/tests/request-parser.test.ts
Normal 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¶m2=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¶m2=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¶m2=value2",
|
||||||
|
});
|
||||||
|
const result = await new RequestParser(request).toObject<{
|
||||||
|
param1: string;
|
||||||
|
param2: string;
|
||||||
|
}>();
|
||||||
|
expect(result).toEqual({ param1: "value1", param2: "value2" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
155
routes.ts
155
routes.ts
|
|
@ -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;
|
meta: APIRouteMeta;
|
||||||
default: (
|
default: RouteHandler<T>;
|
||||||
req: Request,
|
}
|
||||||
matchedRoute: MatchedRoute,
|
| undefined
|
||||||
auth: AuthData
|
>,
|
||||||
) => Response | Promise<Response>;
|
|
||||||
}>,
|
|
||||||
matchedRoute: route,
|
matchedRoute: route,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
186
server.ts
Normal file
186
server.ts
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
`);
|
`);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
})
|
})
|
||||||
};
|
})
|
||||||
|
|
|
||||||
|
|
@ -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`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
21
server/api/[...404].ts
Normal 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);
|
||||||
|
});
|
||||||
|
|
@ -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));
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
matchedRoute: MatchedRoute
|
|
||||||
): Promise<Response> => {
|
|
||||||
const id = matchedRoute.params.id;
|
|
||||||
|
|
||||||
const { user: self } = await getFromRequest(req);
|
|
||||||
|
|
||||||
if (!self) return errorResponse("Unauthorized", 401);
|
|
||||||
|
|
||||||
const { languages, notify, reblogs } = await parseRequest<{
|
|
||||||
reblogs?: boolean;
|
reblogs?: boolean;
|
||||||
notify?: boolean;
|
notify?: boolean;
|
||||||
languages?: string[];
|
languages?: string[];
|
||||||
}>(req);
|
}>(async (req, matchedRoute, extraData) => {
|
||||||
|
const id = matchedRoute.params.id;
|
||||||
|
|
||||||
|
const { user: self } = extraData.auth;
|
||||||
|
|
||||||
|
if (!self) return errorResponse("Unauthorized", 401);
|
||||||
|
|
||||||
|
const { languages, notify, reblogs } = extraData.parsedRequest;
|
||||||
|
|
||||||
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));
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
matchedRoute: MatchedRoute
|
|
||||||
): Promise<Response> => {
|
|
||||||
const id = matchedRoute.params.id;
|
|
||||||
|
|
||||||
// TODO: Add pinned
|
|
||||||
const {
|
|
||||||
max_id,
|
|
||||||
min_id,
|
|
||||||
since_id,
|
|
||||||
limit = 20,
|
|
||||||
}: {
|
|
||||||
max_id?: string;
|
max_id?: string;
|
||||||
since_id?: string;
|
since_id?: string;
|
||||||
min_id?: string;
|
min_id?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
} = matchedRoute.query;
|
}>(async (req, matchedRoute, extraData) => {
|
||||||
|
const id = matchedRoute.params.id;
|
||||||
|
|
||||||
|
// TODO: Add pinned
|
||||||
|
const { max_id, min_id, since_id, limit = 20 } = extraData.parsedRequest;
|
||||||
|
|
||||||
const user = await client.user.findUnique({
|
const user = await client.user.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|
@ -86,4 +77,4 @@ export default async (
|
||||||
Link: linkHeader.join(", "),
|
Link: linkHeader.join(", "),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
matchedRoute: MatchedRoute
|
|
||||||
): Promise<Response> => {
|
|
||||||
const id = matchedRoute.params.id;
|
|
||||||
|
|
||||||
// TODO: Add pinned
|
|
||||||
const {
|
|
||||||
max_id,
|
|
||||||
min_id,
|
|
||||||
since_id,
|
|
||||||
limit = 20,
|
|
||||||
}: {
|
|
||||||
max_id?: string;
|
max_id?: string;
|
||||||
since_id?: string;
|
since_id?: string;
|
||||||
min_id?: string;
|
min_id?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
} = matchedRoute.query;
|
}>(async (req, matchedRoute, extraData) => {
|
||||||
|
const id = matchedRoute.params.id;
|
||||||
|
|
||||||
|
// TODO: Add pinned
|
||||||
|
const { max_id, min_id, since_id, limit = 20 } = extraData.parsedRequest;
|
||||||
|
|
||||||
const user = await client.user.findUnique({
|
const user = await client.user.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|
@ -86,4 +77,4 @@ export default async (
|
||||||
Link: linkHeader.join(", "),
|
Link: linkHeader.join(", "),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,21 +20,7 @@ export const meta = applyConfig({
|
||||||
/**
|
/**
|
||||||
* Fetch all statuses for a user
|
* Fetch all statuses for a user
|
||||||
*/
|
*/
|
||||||
export default async (
|
export default apiRoute<{
|
||||||
req: Request,
|
|
||||||
matchedRoute: MatchedRoute
|
|
||||||
): Promise<Response> => {
|
|
||||||
const id = matchedRoute.params.id;
|
|
||||||
|
|
||||||
// TODO: Add pinned
|
|
||||||
const {
|
|
||||||
max_id,
|
|
||||||
min_id,
|
|
||||||
since_id,
|
|
||||||
limit = "20",
|
|
||||||
exclude_reblogs,
|
|
||||||
pinned,
|
|
||||||
}: {
|
|
||||||
max_id?: string;
|
max_id?: string;
|
||||||
since_id?: string;
|
since_id?: string;
|
||||||
min_id?: string;
|
min_id?: string;
|
||||||
|
|
@ -46,7 +31,18 @@ export default async (
|
||||||
// TODO: Add with_muted
|
// TODO: Add with_muted
|
||||||
pinned?: boolean;
|
pinned?: boolean;
|
||||||
tagged?: string;
|
tagged?: string;
|
||||||
} = matchedRoute.query;
|
}>(async (req, matchedRoute, extraData) => {
|
||||||
|
const id = matchedRoute.params.id;
|
||||||
|
|
||||||
|
// TODO: Add pinned
|
||||||
|
const {
|
||||||
|
max_id,
|
||||||
|
min_id,
|
||||||
|
since_id,
|
||||||
|
limit = "20",
|
||||||
|
exclude_reblogs,
|
||||||
|
pinned,
|
||||||
|
} = extraData.parsedRequest;
|
||||||
|
|
||||||
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(", "),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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)));
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
*/
|
|
||||||
export default async (req: Request): Promise<Response> => {
|
|
||||||
// TODO: Add Authorization check
|
|
||||||
|
|
||||||
const body = await parseRequest<{
|
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
agreement: boolean;
|
agreement: boolean;
|
||||||
locale: string;
|
locale: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
}>(req);
|
}>(async (req, matchedRoute, extraData) => {
|
||||||
|
// TODO: Add Authorization check
|
||||||
|
|
||||||
const config = getConfig();
|
const body = extraData.parsedRequest;
|
||||||
|
|
||||||
|
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,
|
||||||
});
|
});
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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)));
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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)));
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
});
|
});
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}>(req);
|
}>(async (req, matchedRoute, extraData) => {
|
||||||
|
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,
|
||||||
});
|
});
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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)));
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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)))
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
||||||
const {
|
|
||||||
limit = 20,
|
|
||||||
max_id,
|
|
||||||
min_id,
|
|
||||||
since_id,
|
|
||||||
} = await parseRequest<{
|
|
||||||
max_id?: string;
|
max_id?: string;
|
||||||
since_id?: string;
|
since_id?: string;
|
||||||
min_id?: string;
|
min_id?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}>(req);
|
}>(async (req, matchedRoute, extraData) => {
|
||||||
|
const { user } = extraData.auth;
|
||||||
|
|
||||||
|
const { limit = 20, max_id, min_id, since_id } = extraData.parsedRequest;
|
||||||
|
|
||||||
if (limit < 1 || limit > 40) {
|
if (limit < 1 || limit > 40) {
|
||||||
return errorResponse("Limit must be between 1 and 40", 400);
|
return errorResponse("Limit must be between 1 and 40", 400);
|
||||||
|
|
@ -77,4 +70,4 @@ export default async (req: Request): Promise<Response> => {
|
||||||
Link: linkHeader.join(", "),
|
Link: linkHeader.join(", "),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
||||||
const {
|
|
||||||
limit = 20,
|
|
||||||
max_id,
|
|
||||||
min_id,
|
|
||||||
since_id,
|
|
||||||
} = await parseRequest<{
|
|
||||||
max_id?: string;
|
max_id?: string;
|
||||||
since_id?: string;
|
since_id?: string;
|
||||||
min_id?: string;
|
min_id?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}>(req);
|
}>(async (req, matchedRoute, extraData) => {
|
||||||
|
const { user } = extraData.auth;
|
||||||
|
|
||||||
|
const { limit = 20, max_id, min_id, since_id } = extraData.parsedRequest;
|
||||||
|
|
||||||
if (limit < 1 || limit > 40) {
|
if (limit < 1 || limit > 40) {
|
||||||
return errorResponse("Limit must be between 1 and 40", 400);
|
return errorResponse("Limit must be between 1 and 40", 400);
|
||||||
|
|
@ -79,4 +69,4 @@ export default async (req: Request): Promise<Response> => {
|
||||||
Link: linkHeader.join(", "),
|
Link: linkHeader.join(", "),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
@ -91,16 +90,30 @@ export default async (req: Request): Promise<Response> => {
|
||||||
|
|
||||||
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));
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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)));
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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(", "),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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(", "),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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({});
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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(", "),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in a new issue