mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 00:18:19 +01:00
Compare commits
29 commits
79742f47dc
...
a6c9d6cd4f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6c9d6cd4f | ||
|
|
b5e9e35427 | ||
|
|
278bf960cb | ||
|
|
0bf5f7c983 | ||
|
|
870b6dbe85 | ||
|
|
2fffbcbede | ||
|
|
551b9a94fe | ||
|
|
24d4150da4 | ||
|
|
add2429606 | ||
|
|
eb096c5991 | ||
|
|
30bb801f9f | ||
|
|
6d7c545c88 | ||
|
|
a1300466f4 | ||
|
|
90b6399407 | ||
|
|
7de4b573e3 | ||
|
|
dc802ff5f6 | ||
|
|
59cd519337 | ||
|
|
aff51b651c | ||
|
|
e1bd389bf1 | ||
|
|
2310e8b33d | ||
|
|
129bc97b09 | ||
|
|
1a666e8371 | ||
|
|
03940cd8fd | ||
|
|
1f03017327 | ||
|
|
3798e170d0 | ||
|
|
5cae547f8d | ||
|
|
fde70fa61a | ||
|
|
a211772309 | ||
|
|
a6d3ebbeef |
|
|
@ -1,9 +0,0 @@
|
||||||
# Bun doesn't run well on Musl but this seems to work
|
|
||||||
FROM oven/bun:1.2.15-alpine as base
|
|
||||||
|
|
||||||
# Switch to Bash by editing /etc/passwd
|
|
||||||
RUN apk add --no-cache libstdc++ git bash curl openssh cloc && \
|
|
||||||
sed -i -e 's|/bin/ash|/bin/bash|g' /etc/passwd
|
|
||||||
|
|
||||||
# Extract Node from its docker image (node:22-alpine)
|
|
||||||
COPY --from=node:22-alpine /usr/local/bin/node /usr/local/bin/node
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
{
|
|
||||||
"name": "versia Dev Container",
|
|
||||||
"dockerFile": "Dockerfile",
|
|
||||||
"runArgs": [
|
|
||||||
"-v",
|
|
||||||
"${localWorkspaceFolder}/config:/workspace/config",
|
|
||||||
"-v",
|
|
||||||
"${localWorkspaceFolder}/logs:/workspace/logs",
|
|
||||||
"-v",
|
|
||||||
"${localWorkspaceFolder}/uploads:/workspace/uploads",
|
|
||||||
"--network=host"
|
|
||||||
],
|
|
||||||
"mounts": [
|
|
||||||
"source=node_modules,target=/workspace/node_modules,type=bind,consistency=cached",
|
|
||||||
"type=bind,source=/home/${localEnv:USER}/.ssh,target=/root/.ssh,readonly"
|
|
||||||
],
|
|
||||||
"customizations": {
|
|
||||||
"vscode": {
|
|
||||||
"settings": {
|
|
||||||
"terminal.integrated.shell.linux": "/bin/bash"
|
|
||||||
},
|
|
||||||
"extensions": [
|
|
||||||
"biomejs.biome",
|
|
||||||
"ms-vscode-remote.remote-containers",
|
|
||||||
"oven.bun-vscode",
|
|
||||||
"vivaxy.vscode-conventional-commits",
|
|
||||||
"EditorConfig.EditorConfig",
|
|
||||||
"tamasfe.even-better-toml",
|
|
||||||
"YoavBls.pretty-ts-errors",
|
|
||||||
"eamodio.gitlens"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
44
.github/config.workflow.toml
vendored
44
.github/config.workflow.toml
vendored
|
|
@ -429,50 +429,38 @@ text = "No spam"
|
||||||
|
|
||||||
[logging]
|
[logging]
|
||||||
|
|
||||||
# Available levels: debug, info, warning, error, fatal
|
# Available levels: trace, debug, info, warning, error, fatal
|
||||||
log_level = "debug"
|
log_level = "info" # For console output
|
||||||
|
|
||||||
log_file_path = "logs/versia.log"
|
|
||||||
|
|
||||||
[logging.types]
|
|
||||||
# Either pass a boolean
|
|
||||||
# requests = true
|
|
||||||
# Or a table with the following keys:
|
|
||||||
# requests_content = { level = "debug", log_file_path = "logs/requests.log" }
|
|
||||||
# Available types are: requests, responses, requests_content, filters
|
|
||||||
|
|
||||||
|
# [logging.file]
|
||||||
|
# path = "logs/versia.log"
|
||||||
|
# log_level = "info"
|
||||||
|
#
|
||||||
|
# [logging.file.rotation]
|
||||||
|
# max_size = 10_000_000 # 10 MB
|
||||||
|
# max_files = 10 # Keep 10 rotated files
|
||||||
|
#
|
||||||
# https://sentry.io support
|
# https://sentry.io support
|
||||||
# Uncomment to enable
|
|
||||||
# [logging.sentry]
|
# [logging.sentry]
|
||||||
# Sentry DSN for error logging
|
|
||||||
# dsn = "https://example.com"
|
# dsn = "https://example.com"
|
||||||
# debug = false
|
# debug = false
|
||||||
|
|
||||||
# sample_rate = 1.0
|
# sample_rate = 1.0
|
||||||
# traces_sample_rate = 1.0
|
# traces_sample_rate = 1.0
|
||||||
# Can also be regex
|
# Can also be regex
|
||||||
# trace_propagation_targets = []
|
# trace_propagation_targets = []
|
||||||
# max_breadcrumbs = 100
|
# max_breadcrumbs = 100
|
||||||
# environment = "production"
|
# environment = "production"
|
||||||
|
# log_level = "info"
|
||||||
|
|
||||||
[plugins]
|
[authentication]
|
||||||
# Whether to automatically load all plugins in the plugins directory
|
|
||||||
autoload = true
|
|
||||||
|
|
||||||
# Override for autoload
|
|
||||||
[plugins.overrides]
|
|
||||||
enabled = []
|
|
||||||
disabled = []
|
|
||||||
|
|
||||||
[plugins.config."@versia/openid"]
|
|
||||||
# If enabled, Versia will require users to log in with an OpenID provider
|
# If enabled, Versia will require users to log in with an OpenID provider
|
||||||
forced = false
|
forced_openid = false
|
||||||
|
|
||||||
# Allow registration with OpenID providers
|
# Allow registration with OpenID providers
|
||||||
# If signups.registration is false, it will only be possible to register with OpenID
|
# If signups.registration is false, it will only be possible to register with OpenID
|
||||||
allow_registration = true
|
openid_registration = true
|
||||||
|
|
||||||
[plugins.config."@versia/openid".keys]
|
[authentication.keys]
|
||||||
# Run Versia Server with those values missing to generate a new key
|
# Run Versia Server with those values missing to generate a new key
|
||||||
public = "MCowBQYDK2VwAyEAfyZx8r98gVHtdH5EF1NYrBeChOXkt50mqiwKO2TX0f8="
|
public = "MCowBQYDK2VwAyEAfyZx8r98gVHtdH5EF1NYrBeChOXkt50mqiwKO2TX0f8="
|
||||||
private = "MC4CAQAwBQYDK2VwBCIEILDi1g7+bwNjBBvL4CRWHZpCFBR2m2OPCot62Wr+TCbq"
|
private = "MC4CAQAwBQYDK2VwBCIEILDi1g7+bwNjBBvL4CRWHZpCFBR2m2OPCot62Wr+TCbq"
|
||||||
|
|
@ -484,7 +472,7 @@ private = "MC4CAQAwBQYDK2VwBCIEILDi1g7+bwNjBBvL4CRWHZpCFBR2m2OPCot62Wr+TCbq"
|
||||||
# The asterisk is important, as it allows for any query parameters to be passed
|
# The asterisk is important, as it allows for any query parameters to be passed
|
||||||
# Authentik for example uses regex so it can be set to (regex):
|
# Authentik for example uses regex so it can be set to (regex):
|
||||||
# <base_url>/oauth/sso/<provider_id>/callback.*
|
# <base_url>/oauth/sso/<provider_id>/callback.*
|
||||||
# [[plugins.config."@versia/openid".providers]]
|
# [[authentication.openid_providers]]
|
||||||
# name = "CPlusPatch ID"
|
# name = "CPlusPatch ID"
|
||||||
# id = "cpluspatch-id"
|
# id = "cpluspatch-id"
|
||||||
# This MUST match the provider's issuer URI, including the trailing slash (or lack thereof)
|
# This MUST match the provider's issuer URI, including the trailing slash (or lack thereof)
|
||||||
|
|
|
||||||
8
.github/copilot-instructions.md
vendored
8
.github/copilot-instructions.md
vendored
|
|
@ -13,4 +13,10 @@ const add = (a: number, b: number): number => a + b;
|
||||||
|
|
||||||
We always write TypeScript with double quotes and four spaces for indentation, so when your responses include TypeScript code, please follow those conventions.
|
We always write TypeScript with double quotes and four spaces for indentation, so when your responses include TypeScript code, please follow those conventions.
|
||||||
|
|
||||||
Our codebase uses Drizzle as an ORM, with custom abstractions in `classes/database/` for interacting with the database. The `@versia/kit/db` and `@versia/kit/tables` packages are aliases for these abstractions.
|
Our codebase uses Drizzle as an ORM, which is exposed in the `@versia-server/kit/db` and `@versia-server/kit/tables` packages. This project uses a monorepo structure with Bun as the package manager.
|
||||||
|
|
||||||
|
The app has two modes: worker and API. The worker mode is used for background tasks, while the API mode serves HTTP requests. The entry point for the worker is `worker.ts`, and for the API, it is `api.ts`.
|
||||||
|
|
||||||
|
Run the typechecker with `bun run typecheck` to ensure that all TypeScript code is type-checked correctly. Run tests with `bun test` to ensure that all tests pass. Run the linter and formatter with `bun lint` to ensure that the code adheres to our style guidelines, and `bun lint --write` to automatically fix minor/formatting issues.
|
||||||
|
|
||||||
|
Cover all new functionality with tests, and ensure that all tests pass before submitting your code.
|
||||||
|
|
|
||||||
2
.github/workflows/check.yml
vendored
2
.github/workflows/check.yml
vendored
|
|
@ -24,4 +24,4 @@ jobs:
|
||||||
|
|
||||||
- name: Run typechecks
|
- name: Run typechecks
|
||||||
run: |
|
run: |
|
||||||
bun run check
|
bun run typecheck
|
||||||
|
|
|
||||||
27
.github/workflows/circular-imports.yml
vendored
Normal file
27
.github/workflows/circular-imports.yml
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
name: Check Circular Imports
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Install NPM packages
|
||||||
|
run: |
|
||||||
|
bun install
|
||||||
|
|
||||||
|
- name: Run typechecks
|
||||||
|
run: |
|
||||||
|
bun run detect-circular
|
||||||
3
.github/workflows/docker.yml
vendored
3
.github/workflows/docker.yml
vendored
|
|
@ -18,6 +18,9 @@ jobs:
|
||||||
tests:
|
tests:
|
||||||
uses: ./.github/workflows/tests.yml
|
uses: ./.github/workflows/tests.yml
|
||||||
|
|
||||||
|
detect-circular:
|
||||||
|
uses: ./.github/workflows/circular-imports.yml
|
||||||
|
|
||||||
build:
|
build:
|
||||||
if: ${{ success() }}
|
if: ${{ success() }}
|
||||||
needs: [lint, check, tests]
|
needs: [lint, check, tests]
|
||||||
|
|
|
||||||
7
.madgerc
Normal file
7
.madgerc
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"detectiveOptions": {
|
||||||
|
"ts": {
|
||||||
|
"skipTypeImports": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
|
@ -6,7 +6,6 @@
|
||||||
"cli",
|
"cli",
|
||||||
"federation",
|
"federation",
|
||||||
"config",
|
"config",
|
||||||
"plugin",
|
|
||||||
"worker",
|
"worker",
|
||||||
"media",
|
"media",
|
||||||
"packages/client",
|
"packages/client",
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
|
|
||||||
- [x] 🚀 Upgraded Bun to `1.2.15`
|
- [x] 🚀 Upgraded Bun to `1.2.18`
|
||||||
|
|
||||||
# `0.8.0` • Federation 2: Electric Boogaloo
|
# `0.8.0` • Federation 2: Electric Boogaloo
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ TypeScript errors should be ignored with `// @ts-expect-error` comments, as well
|
||||||
|
|
||||||
To scan for all TypeScript errors, run:
|
To scan for all TypeScript errors, run:
|
||||||
```sh
|
```sh
|
||||||
bun check
|
bun typecheck
|
||||||
```
|
```
|
||||||
|
|
||||||
### Commit messages
|
### Commit messages
|
||||||
|
|
@ -153,4 +153,4 @@ If you find a bug, please open an issue on GitHub. Please make sure to include t
|
||||||
|
|
||||||
# License
|
# License
|
||||||
|
|
||||||
Versia Server is licensed under the [AGPLv3 or later](https://www.gnu.org/licenses/agpl-3.0.en.html) license. By contributing to Versia, you agree to license your contributions under the same license.
|
Versia Server is licensed under the [AGPLv3 or later](https://www.gnu.org/licenses/agpl-3.0.en.html) license. By contributing to Versia, you agree to license your contributions under the same license.
|
||||||
|
|
|
||||||
15
Dockerfile
15
Dockerfile
|
|
@ -1,7 +1,5 @@
|
||||||
# Node is required for building the project
|
# Node is required for building the project
|
||||||
FROM imbios/bun-node:1-20-alpine AS base
|
FROM imbios/bun-node:latest-23-alpine AS base
|
||||||
|
|
||||||
RUN apk add --no-cache libstdc++
|
|
||||||
|
|
||||||
# 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
|
||||||
|
|
@ -22,20 +20,19 @@ COPY --from=install /temp/node_modules /temp/node_modules
|
||||||
|
|
||||||
# Build the project
|
# Build the project
|
||||||
WORKDIR /temp
|
WORKDIR /temp
|
||||||
RUN bun run build
|
RUN bun run build api
|
||||||
WORKDIR /temp/dist
|
WORKDIR /temp/dist
|
||||||
|
|
||||||
# Copy production dependencies and source code into final image
|
# Copy production dependencies and source code into final image
|
||||||
FROM oven/bun:1.2.15-alpine
|
FROM oven/bun:1.2.18-alpine
|
||||||
|
|
||||||
# Install libstdc++ for Bun and create app directory
|
# Install libstdc++ for Bun and create app directory
|
||||||
RUN apk add --no-cache libstdc++ && \
|
RUN mkdir -p /app
|
||||||
mkdir -p /app
|
|
||||||
|
|
||||||
COPY --from=build /temp/dist /app/dist
|
COPY --from=build /temp/dist /app/dist
|
||||||
COPY entrypoint.sh /app
|
COPY entrypoint.sh /app
|
||||||
|
|
||||||
LABEL org.opencontainers.image.authors="Gaspard Wierzbinski (https://cpluspatch.dev)"
|
LABEL org.opencontainers.image.authors="Gaspard Wierzbinski (https://cpluspatch.com)"
|
||||||
LABEL org.opencontainers.image.source="https://github.com/versia-pub/server"
|
LABEL org.opencontainers.image.source="https://github.com/versia-pub/server"
|
||||||
LABEL org.opencontainers.image.vendor="Versia Pub"
|
LABEL org.opencontainers.image.vendor="Versia Pub"
|
||||||
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
|
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
|
||||||
|
|
@ -51,4 +48,4 @@ WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENTRYPOINT [ "/bin/sh", "/app/entrypoint.sh" ]
|
ENTRYPOINT [ "/bin/sh", "/app/entrypoint.sh" ]
|
||||||
# Run migrations and start the server
|
# Run migrations and start the server
|
||||||
CMD [ "bun", "run", "index.js" ]
|
CMD [ "bun", "run", "api.js" ]
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
# Node is required for building the project
|
# Node is required for building the project
|
||||||
FROM imbios/bun-node:1-20-alpine AS base
|
FROM imbios/bun-node:latest-23-alpine AS base
|
||||||
|
|
||||||
RUN apk add --no-cache libstdc++
|
|
||||||
|
|
||||||
# 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
|
||||||
|
|
@ -22,20 +20,19 @@ COPY --from=install /temp/node_modules /temp/node_modules
|
||||||
|
|
||||||
# Build the project
|
# Build the project
|
||||||
WORKDIR /temp
|
WORKDIR /temp
|
||||||
RUN bun run build:worker
|
RUN bun run build worker
|
||||||
WORKDIR /temp/dist
|
WORKDIR /temp/dist
|
||||||
|
|
||||||
# Copy production dependencies and source code into final image
|
# Copy production dependencies and source code into final image
|
||||||
FROM oven/bun:1.2.15-alpine
|
FROM oven/bun:1.2.18-alpine
|
||||||
|
|
||||||
# Install libstdc++ for Bun and create app directory
|
# Install libstdc++ for Bun and create app directory
|
||||||
RUN apk add --no-cache libstdc++ && \
|
RUN mkdir -p /app
|
||||||
mkdir -p /app
|
|
||||||
|
|
||||||
COPY --from=build /temp/dist /app/dist
|
COPY --from=build /temp/dist /app/dist
|
||||||
COPY entrypoint.sh /app
|
COPY entrypoint.sh /app
|
||||||
|
|
||||||
LABEL org.opencontainers.image.authors="Gaspard Wierzbinski (https://cpluspatch.dev)"
|
LABEL org.opencontainers.image.authors="Gaspard Wierzbinski (https://cpluspatch.com)"
|
||||||
LABEL org.opencontainers.image.source="https://github.com/versia-pub/server"
|
LABEL org.opencontainers.image.source="https://github.com/versia-pub/server"
|
||||||
LABEL org.opencontainers.image.vendor="Versia Pub"
|
LABEL org.opencontainers.image.vendor="Versia Pub"
|
||||||
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
|
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
|
||||||
|
|
@ -47,7 +44,8 @@ ARG GIT_COMMIT
|
||||||
ENV GIT_COMMIT=$GIT_COMMIT
|
ENV GIT_COMMIT=$GIT_COMMIT
|
||||||
|
|
||||||
# CD to app
|
# CD to app
|
||||||
WORKDIR /app/dist
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
ENTRYPOINT [ "/bin/sh", "/app/entrypoint.sh" ]
|
||||||
# Run migrations and start the server
|
# Run migrations and start the server
|
||||||
CMD [ "bun", "run", "worker.js" ]
|
CMD [ "bun", "run", "worker.js" ]
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
|
import { appFactory } from "@versia-server/api";
|
||||||
|
import { config } from "@versia-server/config";
|
||||||
import { Youch } from "youch";
|
import { Youch } from "youch";
|
||||||
import { sentry } from "@/sentry";
|
|
||||||
import { createServer } from "@/server";
|
import { createServer } from "@/server";
|
||||||
import { appFactory } from "~/app";
|
|
||||||
import { config } from "~/config.ts";
|
|
||||||
|
|
||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
process.exit();
|
process.exit();
|
||||||
|
|
@ -15,7 +14,6 @@ process.on("uncaughtException", async (error) => {
|
||||||
console.error(await youch.toANSI(error));
|
console.error(await youch.toANSI(error));
|
||||||
});
|
});
|
||||||
|
|
||||||
await import("~/entrypoints/api/setup.ts");
|
await import("@versia-server/api/setup");
|
||||||
sentry?.captureMessage("Server started", "info");
|
|
||||||
|
|
||||||
createServer(config, await appFactory());
|
createServer(config, await appFactory());
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import type { Status } from "@versia/client/schemas";
|
import type { Status } from "@versia/client/schemas";
|
||||||
|
import {
|
||||||
|
fakeRequest,
|
||||||
|
getTestStatuses,
|
||||||
|
getTestUsers,
|
||||||
|
} from "@versia-server/tests";
|
||||||
import { bench, run } from "mitata";
|
import { bench, run } from "mitata";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod/v4";
|
||||||
import { configureLoggers } from "@/loggers";
|
|
||||||
import { fakeRequest, getTestStatuses, getTestUsers } from "~/tests/utils";
|
|
||||||
|
|
||||||
await configureLoggers(true);
|
|
||||||
|
|
||||||
const { users, tokens, deleteUsers } = await getTestUsers(5);
|
const { users, tokens, deleteUsers } = await getTestUsers(5);
|
||||||
await getTestStatuses(40, users[0]);
|
await getTestStatuses(40, users[0]);
|
||||||
|
|
|
||||||
19
biome.json
19
biome.json
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.0.0-beta.5/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.0.6/schema.json",
|
||||||
"assist": {
|
"assist": {
|
||||||
"actions": {
|
"actions": {
|
||||||
"source": {
|
"source": {
|
||||||
|
|
@ -49,7 +49,6 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"useLiteralEnumMembers": "error",
|
"useLiteralEnumMembers": "error",
|
||||||
"noCommaOperator": "error",
|
|
||||||
"noNegationElse": "error",
|
"noNegationElse": "error",
|
||||||
"noYodaExpression": "error",
|
"noYodaExpression": "error",
|
||||||
"useBlockStatements": "error",
|
"useBlockStatements": "error",
|
||||||
|
|
@ -89,7 +88,6 @@
|
||||||
"accessibility": "explicit"
|
"accessibility": "explicit"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"noArguments": "error",
|
|
||||||
"useImportType": "error",
|
"useImportType": "error",
|
||||||
"useExportType": "error",
|
"useExportType": "error",
|
||||||
"noUselessElse": "error",
|
"noUselessElse": "error",
|
||||||
|
|
@ -99,7 +97,15 @@
|
||||||
"noCommonJs": "warn",
|
"noCommonJs": "warn",
|
||||||
"noExportedImports": "warn",
|
"noExportedImports": "warn",
|
||||||
"noSubstr": "warn",
|
"noSubstr": "warn",
|
||||||
"useTrimStartEnd": "warn"
|
"useTrimStartEnd": "warn",
|
||||||
|
"noRestrictedImports": {
|
||||||
|
"options": {
|
||||||
|
"paths": {
|
||||||
|
"~/packages/": "Use the appropriate package instead of importing from the packages directory directly."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"level": "error"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"performance": {
|
"performance": {
|
||||||
"noDynamicNamespaceImportAccess": "warn"
|
"noDynamicNamespaceImportAccess": "warn"
|
||||||
|
|
@ -119,6 +125,7 @@
|
||||||
"noGlobalDirnameFilename": "error",
|
"noGlobalDirnameFilename": "error",
|
||||||
"noProcessGlobal": "warn",
|
"noProcessGlobal": "warn",
|
||||||
"noTsIgnore": "warn",
|
"noTsIgnore": "warn",
|
||||||
|
"useReadonlyClassProperties": "error",
|
||||||
"useConsistentObjectDefinition": {
|
"useConsistentObjectDefinition": {
|
||||||
"level": "warn",
|
"level": "warn",
|
||||||
"options": {
|
"options": {
|
||||||
|
|
@ -135,7 +142,9 @@
|
||||||
"noUselessEscapeInRegex": "warn",
|
"noUselessEscapeInRegex": "warn",
|
||||||
"useSimplifiedLogicExpression": "error",
|
"useSimplifiedLogicExpression": "error",
|
||||||
"useWhile": "error",
|
"useWhile": "error",
|
||||||
"useNumericLiterals": "error"
|
"useNumericLiterals": "error",
|
||||||
|
"noArguments": "error",
|
||||||
|
"noCommaOperator": "error"
|
||||||
},
|
},
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
"noDuplicateTestHooks": "error",
|
"noDuplicateTestHooks": "error",
|
||||||
|
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import { $, build } from "bun";
|
|
||||||
|
|
||||||
console.log("Building...");
|
|
||||||
|
|
||||||
await $`rm -rf dist && mkdir dist`;
|
|
||||||
|
|
||||||
await build({
|
|
||||||
entrypoints: [
|
|
||||||
"worker.ts",
|
|
||||||
// HACK: Include to avoid cyclical import errors
|
|
||||||
"config.ts",
|
|
||||||
],
|
|
||||||
outdir: "dist",
|
|
||||||
target: "bun",
|
|
||||||
splitting: true,
|
|
||||||
minify: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("Copying files...");
|
|
||||||
|
|
||||||
// Copy Drizzle migrations to dist
|
|
||||||
await $`cp -rL drizzle dist/drizzle`;
|
|
||||||
|
|
||||||
// Copy Sharp to dist
|
|
||||||
await $`mkdir -p dist/node_modules/@img`;
|
|
||||||
await $`cp -rL node_modules/@img/sharp-libvips-linux* dist/node_modules/@img`;
|
|
||||||
await $`cp -rL node_modules/@img/sharp-linux* dist/node_modules/@img`;
|
|
||||||
|
|
||||||
await $`cp -rL node_modules/detect-libc dist/node_modules/`;
|
|
||||||
|
|
||||||
console.log("Build complete!");
|
|
||||||
84
build.ts
84
build.ts
|
|
@ -1,63 +1,55 @@
|
||||||
import { readdir } from "node:fs/promises";
|
import process from "node:process";
|
||||||
import { $, build } from "bun";
|
import { $, build, file, write } from "bun";
|
||||||
import { routes } from "~/routes";
|
import manifest from "./package.json" with { type: "json" };
|
||||||
|
|
||||||
console.log("Building...");
|
console.log("Building...");
|
||||||
|
|
||||||
await $`rm -rf dist && mkdir dist`;
|
await $`rm -rf dist && mkdir dist`;
|
||||||
|
|
||||||
// Get all directories under the plugins/ directory
|
const type = process.argv[2] as "api" | "worker";
|
||||||
const pluginDirs = await readdir("plugins", { withFileTypes: true });
|
|
||||||
|
if (type !== "api" && type !== "worker") {
|
||||||
|
throw new Error("Invalid build type. Use 'api' or 'worker'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const packages = Object.keys(manifest.dependencies)
|
||||||
|
.filter((dep) => dep.startsWith("@versia"))
|
||||||
|
.filter((dep) => dep !== "@versia-server/tests");
|
||||||
|
|
||||||
await build({
|
await build({
|
||||||
entrypoints: [
|
entrypoints: [`./${type}.ts`],
|
||||||
"index.ts",
|
|
||||||
// HACK: Include to avoid cyclical import errors
|
|
||||||
"config.ts",
|
|
||||||
"cli/index.ts",
|
|
||||||
// Force Bun to include endpoints
|
|
||||||
...Object.values(routes),
|
|
||||||
// Include all plugins
|
|
||||||
...pluginDirs
|
|
||||||
.filter((dir) => dir.isDirectory())
|
|
||||||
.map((dir) => `plugins/${dir.name}/index.ts`),
|
|
||||||
],
|
|
||||||
outdir: "dist",
|
outdir: "dist",
|
||||||
target: "bun",
|
target: "bun",
|
||||||
splitting: true,
|
splitting: true,
|
||||||
minify: false,
|
minify: true,
|
||||||
external: ["acorn", "@bull-board/ui"],
|
external: [...packages],
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("Copying files...");
|
console.log("Copying files...");
|
||||||
|
|
||||||
// Copy Drizzle migrations to dist
|
// Copy each package into dist/node_modules
|
||||||
await $`cp -r drizzle dist/drizzle`;
|
for (const pkg of packages) {
|
||||||
|
const directory = pkg.split("/")[1] || pkg;
|
||||||
|
await $`mkdir -p dist/node_modules/${pkg}`;
|
||||||
|
// Copy the built package files
|
||||||
|
await $`cp -rL packages/${directory}/{dist,package.json} dist/node_modules/${pkg}`;
|
||||||
|
|
||||||
// Copy plugin manifests
|
// Rewrite package.json "exports" field to point to the dist directory and use .js extension
|
||||||
await $`cp plugins/openid/manifest.json dist/plugins/openid/manifest.json`;
|
const packageJsonPath = `dist/node_modules/${pkg}/package.json`;
|
||||||
|
const packageJson = await file(packageJsonPath).json();
|
||||||
await $`mkdir -p dist/node_modules`;
|
for (const [key, value] of Object.entries(packageJson.exports) as [
|
||||||
|
string,
|
||||||
// Copy Sharp to dist
|
{ import?: string },
|
||||||
await $`mkdir -p dist/node_modules/@img`;
|
][]) {
|
||||||
await $`cp -rL node_modules/@img/sharp-libvips-linux* dist/node_modules/@img`;
|
if (value.import) {
|
||||||
await $`cp -rL node_modules/@img/sharp-linux* dist/node_modules/@img`;
|
packageJson.exports[key] = {
|
||||||
|
import: value.import
|
||||||
// Copy acorn to dist
|
.replace("./", "./dist/")
|
||||||
await $`cp -rL node_modules/acorn dist/node_modules/acorn`;
|
.replace(/\.ts$/, ".js"),
|
||||||
|
};
|
||||||
// Copy bull-board to dist
|
}
|
||||||
await $`mkdir -p dist/node_modules/@bull-board`;
|
}
|
||||||
await $`cp -rL node_modules/@bull-board/ui dist/node_modules/@bull-board/ui`;
|
await write(packageJsonPath, JSON.stringify(packageJson, null, 4));
|
||||||
|
}
|
||||||
// Copy the Bee Movie script from pages
|
|
||||||
await $`cp beemovie.txt dist/beemovie.txt`;
|
|
||||||
|
|
||||||
// Copy package.json
|
|
||||||
await $`cp package.json dist/package.json`;
|
|
||||||
|
|
||||||
// Fixes issues with sharp
|
|
||||||
await $`cp -rL node_modules/detect-libc dist/node_modules/`;
|
|
||||||
|
|
||||||
console.log("Build complete!");
|
console.log("Build complete!");
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,4 @@
|
||||||
"@jsr" = "https://npm.jsr.io"
|
"@jsr" = "https://npm.jsr.io"
|
||||||
|
|
||||||
[test]
|
[test]
|
||||||
preload = ["./tests/setup.ts"]
|
preload = ["./packages/tests/setup.ts"]
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
||||||
|
|
||||||
await import("~/config.ts");
|
|
||||||
|
|
||||||
// This is an awkward way to avoid import cycles for some reason
|
|
||||||
await (async () => {
|
|
||||||
const { ConfigSchema } = await import("./schema.ts");
|
|
||||||
|
|
||||||
const jsonSchema = zodToJsonSchema(ConfigSchema, {});
|
|
||||||
|
|
||||||
console.write(`${JSON.stringify(jsonSchema, null, 4)}\n`);
|
|
||||||
})();
|
|
||||||
|
|
@ -1,359 +0,0 @@
|
||||||
import markdownItTaskLists from "@hackmd/markdown-it-task-lists";
|
|
||||||
import { db, type Note, User } from "@versia/kit/db";
|
|
||||||
import { Instances, Users } from "@versia/kit/tables";
|
|
||||||
import { FederationRequester } from "@versia/sdk/http";
|
|
||||||
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
|
||||||
import linkifyHtml from "linkify-html";
|
|
||||||
import {
|
|
||||||
anyOf,
|
|
||||||
charIn,
|
|
||||||
createRegExp,
|
|
||||||
digit,
|
|
||||||
exactly,
|
|
||||||
global,
|
|
||||||
letter,
|
|
||||||
} from "magic-regexp";
|
|
||||||
import MarkdownIt from "markdown-it";
|
|
||||||
import markdownItContainer from "markdown-it-container";
|
|
||||||
import markdownItTocDoneRight from "markdown-it-toc-done-right";
|
|
||||||
import { mentionValidator } from "@/api";
|
|
||||||
import { sanitizeHtml, sanitizeHtmlInline } from "@/sanitization";
|
|
||||||
import { config } from "~/config.ts";
|
|
||||||
import type * as VersiaEntities from "~/packages/sdk/entities/index.ts";
|
|
||||||
import { transformOutputToUserWithRelations, userRelations } from "./user.ts";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrapper against the Status object to make it easier to work with
|
|
||||||
* @param query
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
export const findManyNotes = async (
|
|
||||||
query: Parameters<typeof db.query.Notes.findMany>[0],
|
|
||||||
userId?: string,
|
|
||||||
): Promise<(typeof Note.$type)[]> => {
|
|
||||||
const output = await db.query.Notes.findMany({
|
|
||||||
...query,
|
|
||||||
with: {
|
|
||||||
...query?.with,
|
|
||||||
attachments: {
|
|
||||||
with: {
|
|
||||||
media: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
reactions: {
|
|
||||||
with: {
|
|
||||||
emoji: {
|
|
||||||
with: {
|
|
||||||
instance: true,
|
|
||||||
media: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emojis: {
|
|
||||||
with: {
|
|
||||||
emoji: {
|
|
||||||
with: {
|
|
||||||
instance: true,
|
|
||||||
media: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
author: {
|
|
||||||
with: {
|
|
||||||
...userRelations,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mentions: {
|
|
||||||
with: {
|
|
||||||
user: {
|
|
||||||
with: {
|
|
||||||
instance: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
reblog: {
|
|
||||||
with: {
|
|
||||||
attachments: {
|
|
||||||
with: {
|
|
||||||
media: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
reactions: {
|
|
||||||
with: {
|
|
||||||
emoji: {
|
|
||||||
with: {
|
|
||||||
instance: true,
|
|
||||||
media: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emojis: {
|
|
||||||
with: {
|
|
||||||
emoji: {
|
|
||||||
with: {
|
|
||||||
instance: true,
|
|
||||||
media: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
likes: true,
|
|
||||||
application: true,
|
|
||||||
mentions: {
|
|
||||||
with: {
|
|
||||||
user: {
|
|
||||||
with: userRelations,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
author: {
|
|
||||||
with: {
|
|
||||||
...userRelations,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
extras: {
|
|
||||||
pinned: userId
|
|
||||||
? sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."noteId" = "Notes_reblog".id AND "UserToPinnedNotes"."userId" = ${userId})`.as(
|
|
||||||
"pinned",
|
|
||||||
)
|
|
||||||
: sql`false`.as("pinned"),
|
|
||||||
reblogged: userId
|
|
||||||
? sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."authorId" = ${userId} AND "Notes"."reblogId" = "Notes_reblog".id)`.as(
|
|
||||||
"reblogged",
|
|
||||||
)
|
|
||||||
: sql`false`.as("reblogged"),
|
|
||||||
muted: userId
|
|
||||||
? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."ownerId" = ${userId} AND "Relationships"."subjectId" = "Notes_reblog"."authorId" AND "Relationships"."muting" = true)`.as(
|
|
||||||
"muted",
|
|
||||||
)
|
|
||||||
: sql`false`.as("muted"),
|
|
||||||
liked: userId
|
|
||||||
? sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = "Notes_reblog".id AND "Likes"."likerId" = ${userId})`.as(
|
|
||||||
"liked",
|
|
||||||
)
|
|
||||||
: sql`false`.as("liked"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
reply: true,
|
|
||||||
quote: true,
|
|
||||||
},
|
|
||||||
extras: {
|
|
||||||
pinned: userId
|
|
||||||
? sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."noteId" = "Notes".id AND "UserToPinnedNotes"."userId" = ${userId})`.as(
|
|
||||||
"pinned",
|
|
||||||
)
|
|
||||||
: sql`false`.as("pinned"),
|
|
||||||
reblogged: userId
|
|
||||||
? sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."authorId" = ${userId} AND "Notes"."reblogId" = "Notes".id)`.as(
|
|
||||||
"reblogged",
|
|
||||||
)
|
|
||||||
: sql`false`.as("reblogged"),
|
|
||||||
muted: userId
|
|
||||||
? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."ownerId" = ${userId} AND "Relationships"."subjectId" = "Notes"."authorId" AND "Relationships"."muting" = true)`.as(
|
|
||||||
"muted",
|
|
||||||
)
|
|
||||||
: sql`false`.as("muted"),
|
|
||||||
liked: userId
|
|
||||||
? sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = "Notes".id AND "Likes"."likerId" = ${userId})`.as(
|
|
||||||
"liked",
|
|
||||||
)
|
|
||||||
: sql`false`.as("liked"),
|
|
||||||
...query?.extras,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return output.map((post) => ({
|
|
||||||
...post,
|
|
||||||
author: transformOutputToUserWithRelations(post.author),
|
|
||||||
mentions: post.mentions.map((mention) => ({
|
|
||||||
...mention.user,
|
|
||||||
endpoints: mention.user.endpoints,
|
|
||||||
})),
|
|
||||||
attachments: post.attachments.map((attachment) => attachment.media),
|
|
||||||
emojis: (post.emojis ?? []).map((emoji) => emoji.emoji),
|
|
||||||
reblog: post.reblog && {
|
|
||||||
...post.reblog,
|
|
||||||
author: transformOutputToUserWithRelations(post.reblog.author),
|
|
||||||
mentions: post.reblog.mentions.map((mention) => ({
|
|
||||||
...mention.user,
|
|
||||||
endpoints: mention.user.endpoints,
|
|
||||||
})),
|
|
||||||
attachments: post.reblog.attachments.map(
|
|
||||||
(attachment) => attachment.media,
|
|
||||||
),
|
|
||||||
emojis: (post.reblog.emojis ?? []).map((emoji) => emoji.emoji),
|
|
||||||
pinned: Boolean(post.reblog.pinned),
|
|
||||||
reblogged: Boolean(post.reblog.reblogged),
|
|
||||||
muted: Boolean(post.reblog.muted),
|
|
||||||
liked: Boolean(post.reblog.liked),
|
|
||||||
},
|
|
||||||
pinned: Boolean(post.pinned),
|
|
||||||
reblogged: Boolean(post.reblogged),
|
|
||||||
muted: Boolean(post.muted),
|
|
||||||
liked: Boolean(post.liked),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get people mentioned in the content (match @username or @username@domain.com mentions)
|
|
||||||
* @param text The text to parse mentions from.
|
|
||||||
* @returns An array of users mentioned in the text.
|
|
||||||
*/
|
|
||||||
export const parseTextMentions = async (text: string): Promise<User[]> => {
|
|
||||||
const mentionedPeople = [...text.matchAll(mentionValidator)];
|
|
||||||
if (mentionedPeople.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseUrlHost = config.http.base_url.host;
|
|
||||||
const isLocal = (host?: string): boolean => host === baseUrlHost || !host;
|
|
||||||
|
|
||||||
// Find local and matching users
|
|
||||||
const foundUsers = await db
|
|
||||||
.select({
|
|
||||||
id: Users.id,
|
|
||||||
username: Users.username,
|
|
||||||
baseUrl: Instances.baseUrl,
|
|
||||||
})
|
|
||||||
.from(Users)
|
|
||||||
.leftJoin(Instances, eq(Users.instanceId, Instances.id))
|
|
||||||
.where(
|
|
||||||
or(
|
|
||||||
...mentionedPeople.map((person) =>
|
|
||||||
and(
|
|
||||||
eq(Users.username, person[1] ?? ""),
|
|
||||||
isLocal(person[2])
|
|
||||||
? isNull(Users.instanceId)
|
|
||||||
: eq(Instances.baseUrl, person[2] ?? ""),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Separate found and unresolved users
|
|
||||||
const finalList = await User.manyFromSql(
|
|
||||||
inArray(
|
|
||||||
Users.id,
|
|
||||||
foundUsers.map((u) => u.id),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Every remote user that isn't in database
|
|
||||||
const notFoundRemoteUsers = mentionedPeople.filter(
|
|
||||||
(p) =>
|
|
||||||
!(
|
|
||||||
foundUsers.some(
|
|
||||||
(user) => user.username === p[1] && user.baseUrl === p[2],
|
|
||||||
) || isLocal(p[2])
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Resolve remote mentions not in database
|
|
||||||
for (const person of notFoundRemoteUsers) {
|
|
||||||
const url = await FederationRequester.resolveWebFinger(
|
|
||||||
person[1] ?? "",
|
|
||||||
person[2] ?? "",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (url) {
|
|
||||||
const user = await User.resolve(url);
|
|
||||||
|
|
||||||
if (user) {
|
|
||||||
finalList.push(user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return finalList;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const replaceTextMentions = (text: string, mentions: User[]): string => {
|
|
||||||
return mentions.reduce((finalText, mention) => {
|
|
||||||
const { username, instance } = mention.data;
|
|
||||||
const { uri } = mention;
|
|
||||||
const baseHost = config.http.base_url.host;
|
|
||||||
const linkTemplate = (displayText: string): string =>
|
|
||||||
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${uri}">${displayText}</a>`;
|
|
||||||
|
|
||||||
if (mention.remote) {
|
|
||||||
return finalText.replaceAll(
|
|
||||||
`@${username}@${instance?.baseUrl}`,
|
|
||||||
linkTemplate(`@${username}@${instance?.baseUrl}`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return finalText.replace(
|
|
||||||
createRegExp(
|
|
||||||
exactly(
|
|
||||||
exactly(`@${username}`)
|
|
||||||
.notBefore(anyOf(letter, digit, charIn("@")))
|
|
||||||
.notAfter(anyOf(letter, digit, charIn("@"))),
|
|
||||||
).or(exactly(`@${username}@${baseHost}`)),
|
|
||||||
[global],
|
|
||||||
),
|
|
||||||
linkTemplate(`@${username}@${baseHost}`),
|
|
||||||
);
|
|
||||||
}, text);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const contentToHtml = async (
|
|
||||||
content: VersiaEntities.TextContentFormat,
|
|
||||||
mentions: User[] = [],
|
|
||||||
inline = false,
|
|
||||||
): Promise<string> => {
|
|
||||||
const sanitizer = inline ? sanitizeHtmlInline : sanitizeHtml;
|
|
||||||
let htmlContent = "";
|
|
||||||
|
|
||||||
if (content.data["text/html"]) {
|
|
||||||
htmlContent = await sanitizer(content.data["text/html"].content);
|
|
||||||
} else if (content.data["text/markdown"]) {
|
|
||||||
htmlContent = await sanitizer(
|
|
||||||
await markdownParse(content.data["text/markdown"].content),
|
|
||||||
);
|
|
||||||
} else if (content.data["text/plain"]?.content) {
|
|
||||||
htmlContent = (await sanitizer(content.data["text/plain"].content))
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => `<p>${line}</p>`)
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
htmlContent = replaceTextMentions(htmlContent, mentions);
|
|
||||||
|
|
||||||
return linkifyHtml(htmlContent, {
|
|
||||||
defaultProtocol: "https",
|
|
||||||
validate: { email: (): false => false },
|
|
||||||
target: "_blank",
|
|
||||||
rel: "nofollow noopener noreferrer",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const markdownParse = async (content: string): Promise<string> => {
|
|
||||||
return (await getMarkdownRenderer()).render(content);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getMarkdownRenderer = (): MarkdownIt => {
|
|
||||||
const renderer = MarkdownIt({
|
|
||||||
html: true,
|
|
||||||
linkify: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderer.use(markdownItTocDoneRight, {
|
|
||||||
containerClass: "toc",
|
|
||||||
level: [1, 2, 3, 4],
|
|
||||||
listType: "ul",
|
|
||||||
listClass: "toc-list",
|
|
||||||
itemClass: "toc-item",
|
|
||||||
linkClass: "toc-link",
|
|
||||||
});
|
|
||||||
|
|
||||||
renderer.use(markdownItTaskLists);
|
|
||||||
|
|
||||||
renderer.use(markdownItContainer);
|
|
||||||
|
|
||||||
return renderer;
|
|
||||||
};
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
import {
|
|
||||||
type Application,
|
|
||||||
db,
|
|
||||||
type Emoji,
|
|
||||||
type Instance,
|
|
||||||
type Media,
|
|
||||||
type Role,
|
|
||||||
type Token,
|
|
||||||
type User,
|
|
||||||
} from "@versia/kit/db";
|
|
||||||
import type { Users } from "@versia/kit/tables";
|
|
||||||
import type { InferSelectModel } from "drizzle-orm";
|
|
||||||
|
|
||||||
export const userRelations = {
|
|
||||||
instance: true,
|
|
||||||
emojis: {
|
|
||||||
with: {
|
|
||||||
emoji: {
|
|
||||||
with: {
|
|
||||||
instance: true,
|
|
||||||
media: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
avatar: true,
|
|
||||||
header: true,
|
|
||||||
roles: {
|
|
||||||
with: {
|
|
||||||
role: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export interface AuthData {
|
|
||||||
user: User | null;
|
|
||||||
token: Token | null;
|
|
||||||
application: Application | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const transformOutputToUserWithRelations = (
|
|
||||||
user: Omit<InferSelectModel<typeof Users>, "endpoints"> & {
|
|
||||||
followerCount: unknown;
|
|
||||||
followingCount: unknown;
|
|
||||||
statusCount: unknown;
|
|
||||||
avatar: typeof Media.$type | null;
|
|
||||||
header: typeof Media.$type | null;
|
|
||||||
emojis: {
|
|
||||||
userId: string;
|
|
||||||
emojiId: string;
|
|
||||||
emoji?: typeof Emoji.$type;
|
|
||||||
}[];
|
|
||||||
instance: typeof Instance.$type | null;
|
|
||||||
roles: {
|
|
||||||
userId: string;
|
|
||||||
roleId: string;
|
|
||||||
role?: typeof Role.$type;
|
|
||||||
}[];
|
|
||||||
endpoints: unknown;
|
|
||||||
},
|
|
||||||
): typeof User.$type => {
|
|
||||||
return {
|
|
||||||
...user,
|
|
||||||
followerCount: Number(user.followerCount),
|
|
||||||
followingCount: Number(user.followingCount),
|
|
||||||
statusCount: Number(user.statusCount),
|
|
||||||
endpoints:
|
|
||||||
user.endpoints ??
|
|
||||||
({} as Partial<{
|
|
||||||
dislikes: string;
|
|
||||||
featured: string;
|
|
||||||
likes: string;
|
|
||||||
followers: string;
|
|
||||||
following: string;
|
|
||||||
inbox: string;
|
|
||||||
outbox: string;
|
|
||||||
}>),
|
|
||||||
emojis: user.emojis.map(
|
|
||||||
(emoji) =>
|
|
||||||
(emoji as unknown as Record<string, object>)
|
|
||||||
.emoji as typeof Emoji.$type,
|
|
||||||
),
|
|
||||||
roles: user.roles
|
|
||||||
.map((role) => role.role)
|
|
||||||
.filter(Boolean) as (typeof Role.$type)[],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findManyUsers = async (
|
|
||||||
query: Parameters<typeof db.query.Users.findMany>[0],
|
|
||||||
): Promise<(typeof User.$type)[]> => {
|
|
||||||
const output = await db.query.Users.findMany({
|
|
||||||
...query,
|
|
||||||
with: {
|
|
||||||
...userRelations,
|
|
||||||
...query?.with,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return output.map((user) => transformOutputToUserWithRelations(user));
|
|
||||||
};
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, expect, it } from "bun:test";
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { mockModule } from "@versia-server/tests";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { mockModule } from "~/tests/utils.ts";
|
|
||||||
import { calculateBlurhash } from "./blurhash.ts";
|
import { calculateBlurhash } from "./blurhash.ts";
|
||||||
|
|
||||||
describe("BlurhashPreprocessor", () => {
|
describe("BlurhashPreprocessor", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import { config } from "~/config.ts";
|
|
||||||
|
|
||||||
export class ProxiableUrl extends URL {
|
|
||||||
private isAllowedOrigin(): boolean {
|
|
||||||
const allowedOrigins: URL[] = [config.http.base_url].concat(
|
|
||||||
config.s3?.public_url ?? [],
|
|
||||||
);
|
|
||||||
|
|
||||||
return allowedOrigins.some((origin) =>
|
|
||||||
this.hostname.endsWith(origin.hostname),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public get proxied(): string {
|
|
||||||
// Don't proxy from CDN and self, since those sources are trusted
|
|
||||||
if (this.isAllowedOrigin()) {
|
|
||||||
return this.href;
|
|
||||||
}
|
|
||||||
|
|
||||||
const urlAsBase64Url = Buffer.from(this.href).toString("base64url");
|
|
||||||
|
|
||||||
return new URL(`/media/proxy/${urlAsBase64Url}`, config.http.base_url)
|
|
||||||
.href;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,221 +0,0 @@
|
||||||
/* import {
|
|
||||||
afterEach,
|
|
||||||
beforeEach,
|
|
||||||
describe,
|
|
||||||
expect,
|
|
||||||
jest,
|
|
||||||
mock,
|
|
||||||
test,
|
|
||||||
} from "bun:test";
|
|
||||||
import { ZodError, type ZodTypeAny, z } from "zod";
|
|
||||||
import { Plugin } from "~/packages/plugin-kit";
|
|
||||||
import { type Manifest, manifestSchema } from "~/packages/plugin-kit/schema";
|
|
||||||
import { PluginLoader } from "./loader.ts";
|
|
||||||
|
|
||||||
const mockReaddir = jest.fn();
|
|
||||||
const mockGetLogger = jest.fn(() => ({
|
|
||||||
fatal: jest.fn(),
|
|
||||||
}));
|
|
||||||
const mockParseJSON5 = jest.fn();
|
|
||||||
const mockParseJSONC = jest.fn();
|
|
||||||
const mockFromZodError = jest.fn();
|
|
||||||
|
|
||||||
mock.module("node:fs/promises", () => ({
|
|
||||||
readdir: mockReaddir,
|
|
||||||
}));
|
|
||||||
|
|
||||||
mock.module("@logtape/logtape", () => ({
|
|
||||||
getLogger: mockGetLogger,
|
|
||||||
}));
|
|
||||||
|
|
||||||
mock.module("confbox", () => ({
|
|
||||||
parseJSON5: mockParseJSON5,
|
|
||||||
parseJSONC: mockParseJSONC,
|
|
||||||
}));
|
|
||||||
|
|
||||||
mock.module("zod-validation-error", () => ({
|
|
||||||
fromZodError: mockFromZodError,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("PluginLoader", () => {
|
|
||||||
let pluginLoader: PluginLoader;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
pluginLoader = new PluginLoader();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("getDirectories should return directories", async () => {
|
|
||||||
mockReaddir.mockResolvedValue([
|
|
||||||
{ name: "dir1", isDirectory: (): true => true },
|
|
||||||
{ name: "file1", isDirectory: (): false => false },
|
|
||||||
{ name: "dir2", isDirectory: (): true => true },
|
|
||||||
]);
|
|
||||||
|
|
||||||
// biome-ignore lint/complexity/useLiteralKeys: Private method
|
|
||||||
const directories = await PluginLoader["getDirectories"]("/some/path");
|
|
||||||
expect(directories).toEqual(["dir1", "dir2"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("findManifestFile should return manifest file if found", async () => {
|
|
||||||
mockReaddir.mockResolvedValue(["manifest.json", "otherfile.txt"]);
|
|
||||||
|
|
||||||
const manifestFile =
|
|
||||||
// biome-ignore lint/complexity/useLiteralKeys: Private method
|
|
||||||
await PluginLoader["findManifestFile"]("/some/path");
|
|
||||||
expect(manifestFile).toBe("manifest.json");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("hasEntrypoint should return true if entrypoint file is found", async () => {
|
|
||||||
mockReaddir.mockResolvedValue(["index.ts", "otherfile.txt"]);
|
|
||||||
|
|
||||||
// biome-ignore lint/complexity/useLiteralKeys: Private method
|
|
||||||
const hasEntrypoint = await PluginLoader["hasEntrypoint"]("/some/path");
|
|
||||||
expect(hasEntrypoint).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parseManifestFile should parse JSON manifest", async () => {
|
|
||||||
const manifestContent = { name: "test-plugin" };
|
|
||||||
Bun.file = jest.fn().mockReturnValue({
|
|
||||||
text: (): Promise<string> =>
|
|
||||||
Promise.resolve(JSON.stringify(manifestContent)),
|
|
||||||
});
|
|
||||||
|
|
||||||
// biome-ignore lint/complexity/useLiteralKeys: Private method
|
|
||||||
const manifest = await pluginLoader["parseManifestFile"](
|
|
||||||
"/some/path/manifest.json",
|
|
||||||
"manifest.json",
|
|
||||||
);
|
|
||||||
expect(manifest).toEqual(manifestContent);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("findPlugins should return plugin directories with valid manifest and entrypoint", async () => {
|
|
||||||
mockReaddir
|
|
||||||
.mockResolvedValueOnce([
|
|
||||||
{ name: "plugin1", isDirectory: (): true => true },
|
|
||||||
{ name: "plugin2", isDirectory: (): true => true },
|
|
||||||
])
|
|
||||||
.mockResolvedValue(["manifest.json", "index.ts"]);
|
|
||||||
|
|
||||||
const plugins = await PluginLoader.findPlugins("/some/path");
|
|
||||||
expect(plugins).toEqual(["plugin1", "plugin2"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parseManifest should parse and validate manifest", async () => {
|
|
||||||
const manifestContent: Manifest = {
|
|
||||||
name: "test-plugin",
|
|
||||||
version: "1.1.0",
|
|
||||||
description: "Doobaee",
|
|
||||||
};
|
|
||||||
mockReaddir.mockResolvedValue(["manifest.json"]);
|
|
||||||
Bun.file = jest.fn().mockReturnValue({
|
|
||||||
text: (): Promise<string> =>
|
|
||||||
Promise.resolve(JSON.stringify(manifestContent)),
|
|
||||||
});
|
|
||||||
manifestSchema.safeParseAsync = jest.fn().mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
data: manifestContent,
|
|
||||||
});
|
|
||||||
|
|
||||||
const manifest = await pluginLoader.parseManifest(
|
|
||||||
"/some/path",
|
|
||||||
"plugin1",
|
|
||||||
);
|
|
||||||
expect(manifest).toEqual(manifestContent);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parseManifest should throw error if manifest is missing", async () => {
|
|
||||||
mockReaddir.mockResolvedValue([]);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
pluginLoader.parseManifest("/some/path", "plugin1"),
|
|
||||||
).rejects.toThrow("Plugin plugin1 is missing a manifest file");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parseManifest should throw error if manifest is invalid", async () => {
|
|
||||||
// @ts-expect-error trying to cause a type error here
|
|
||||||
const manifestContent: Manifest = {
|
|
||||||
name: "test-plugin",
|
|
||||||
version: "1.1.0",
|
|
||||||
};
|
|
||||||
mockReaddir.mockResolvedValue(["manifest.json"]);
|
|
||||||
Bun.file = jest.fn().mockReturnValue({
|
|
||||||
text: (): Promise<string> =>
|
|
||||||
Promise.resolve(JSON.stringify(manifestContent)),
|
|
||||||
});
|
|
||||||
manifestSchema.safeParseAsync = jest.fn().mockResolvedValue({
|
|
||||||
success: false,
|
|
||||||
error: new ZodError([]),
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
pluginLoader.parseManifest("/some/path", "plugin1"),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("loadPlugin should load and return a Plugin instance", async () => {
|
|
||||||
const mockPlugin = new Plugin(z.object({}));
|
|
||||||
mock.module("/some/path/index.ts", () => ({
|
|
||||||
default: mockPlugin,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const plugin = await pluginLoader.loadPlugin("/some/path", "index.ts");
|
|
||||||
expect(plugin).toBeInstanceOf(Plugin);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("loadPlugin should throw error if default export is not a Plugin", async () => {
|
|
||||||
mock.module("/some/path/index.ts", () => ({
|
|
||||||
default: "cheese",
|
|
||||||
}));
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
pluginLoader.loadPlugin("/some/path", "index.ts"),
|
|
||||||
).rejects.toThrow("Entrypoint is not a Plugin");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("loadPlugins should load all plugins in a directory", async () => {
|
|
||||||
const manifestContent: Manifest = {
|
|
||||||
name: "test-plugin",
|
|
||||||
version: "1.1.0",
|
|
||||||
description: "Doobaee",
|
|
||||||
};
|
|
||||||
const mockPlugin = new Plugin(z.object({}));
|
|
||||||
|
|
||||||
mockReaddir
|
|
||||||
.mockResolvedValueOnce([
|
|
||||||
{ name: "plugin1", isDirectory: (): true => true },
|
|
||||||
{ name: "plugin2", isDirectory: (): true => true },
|
|
||||||
])
|
|
||||||
.mockResolvedValue(["manifest.json", "index.ts"]);
|
|
||||||
Bun.file = jest.fn().mockReturnValue({
|
|
||||||
text: (): Promise<string> =>
|
|
||||||
Promise.resolve(JSON.stringify(manifestContent)),
|
|
||||||
});
|
|
||||||
manifestSchema.safeParseAsync = jest.fn().mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
data: manifestContent,
|
|
||||||
});
|
|
||||||
mock.module("/some/path/plugin1/index", () => ({
|
|
||||||
default: mockPlugin,
|
|
||||||
}));
|
|
||||||
mock.module("/some/path/plugin2/index", () => ({
|
|
||||||
default: mockPlugin,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const plugins = await pluginLoader.loadPlugins("/some/path", true);
|
|
||||||
expect(plugins).toEqual([
|
|
||||||
{
|
|
||||||
manifest: manifestContent,
|
|
||||||
plugin: mockPlugin as unknown as Plugin<ZodTypeAny>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
manifest: manifestContent,
|
|
||||||
plugin: mockPlugin as unknown as Plugin<ZodTypeAny>,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
*/
|
|
||||||
|
|
@ -1,261 +0,0 @@
|
||||||
import { readdir } from "node:fs/promises";
|
|
||||||
import { getLogger, type Logger } from "@logtape/logtape";
|
|
||||||
import { file, sleep } from "bun";
|
|
||||||
import chalk from "chalk";
|
|
||||||
import { parseJSON5, parseJSONC } from "confbox";
|
|
||||||
import type { Hono } from "hono";
|
|
||||||
import type { ZodTypeAny } from "zod";
|
|
||||||
import { fromZodError, type ValidationError } from "zod-validation-error";
|
|
||||||
import { config } from "~/config.ts";
|
|
||||||
import { Plugin } from "~/packages/plugin-kit/plugin";
|
|
||||||
import { type Manifest, manifestSchema } from "~/packages/plugin-kit/schema";
|
|
||||||
import type { HonoEnv } from "~/types/api";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Class to manage plugins.
|
|
||||||
*/
|
|
||||||
export class PluginLoader {
|
|
||||||
private logger = getLogger("plugin");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all directories in a given directory.
|
|
||||||
* @param {string} dir - The directory to search.
|
|
||||||
* @returns {Promise<string[]>} - An array of directory names.
|
|
||||||
*/
|
|
||||||
private static async getDirectories(dir: string): Promise<string[]> {
|
|
||||||
const files = await readdir(dir, { withFileTypes: true });
|
|
||||||
return files.filter((f) => f.isDirectory()).map((f) => f.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the manifest file in a given directory.
|
|
||||||
* @param {string} dir - The directory to search.
|
|
||||||
* @returns {Promise<string | undefined>} - The manifest file name if found, otherwise undefined.
|
|
||||||
*/
|
|
||||||
private static async findManifestFile(
|
|
||||||
dir: string,
|
|
||||||
): Promise<string | undefined> {
|
|
||||||
const files = await readdir(dir);
|
|
||||||
return files.find((f) => f.match(/^manifest\.(json|json5|jsonc)$/));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a directory has an entrypoint file (index.{ts,js}).
|
|
||||||
* @param {string} dir - The directory to search.
|
|
||||||
* @returns {Promise<boolean>} - True if the entrypoint file is found, otherwise false.
|
|
||||||
*/
|
|
||||||
private static async hasEntrypoint(dir: string): Promise<boolean> {
|
|
||||||
const files = await readdir(dir);
|
|
||||||
return files.includes("index.ts") || files.includes("index.js");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse the manifest file based on its type.
|
|
||||||
* @param {string} manifestPath - The path to the manifest file.
|
|
||||||
* @param {string} manifestFile - The manifest file name.
|
|
||||||
* @returns {Promise<unknown>} - The parsed manifest content.
|
|
||||||
* @throws Will throw an error if the manifest file cannot be parsed.
|
|
||||||
*/
|
|
||||||
private async parseManifestFile(
|
|
||||||
manifestPath: string,
|
|
||||||
manifestFile: string,
|
|
||||||
): Promise<unknown> {
|
|
||||||
const manifestText = await file(manifestPath).text();
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (manifestFile.endsWith(".json")) {
|
|
||||||
return JSON.parse(manifestText);
|
|
||||||
}
|
|
||||||
if (manifestFile.endsWith(".json5")) {
|
|
||||||
return parseJSON5(manifestText);
|
|
||||||
}
|
|
||||||
if (manifestFile.endsWith(".jsonc")) {
|
|
||||||
return parseJSONC(manifestText);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Unsupported manifest file type: ${manifestFile}`);
|
|
||||||
} catch (e) {
|
|
||||||
this.logger
|
|
||||||
.fatal`Could not parse plugin manifest ${chalk.blue(manifestPath)} as ${manifestFile.split(".").pop()?.toUpperCase()}.`;
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find all direct subdirectories with a valid manifest file and entrypoint (index.{ts,js}).
|
|
||||||
* @param {string} dir - The directory to search.
|
|
||||||
* @returns {Promise<string[]>} - An array of plugin directories.
|
|
||||||
*/
|
|
||||||
public static async findPlugins(dir: string): Promise<string[]> {
|
|
||||||
const directories = await PluginLoader.getDirectories(dir);
|
|
||||||
const plugins: string[] = [];
|
|
||||||
|
|
||||||
for (const directory of directories) {
|
|
||||||
const manifestFile = await PluginLoader.findManifestFile(
|
|
||||||
`${dir}/${directory}`,
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
manifestFile &&
|
|
||||||
(await PluginLoader.hasEntrypoint(`${dir}/${directory}`))
|
|
||||||
) {
|
|
||||||
plugins.push(directory);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return plugins;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse the manifest file of a plugin.
|
|
||||||
* @param {string} dir - The directory containing the plugin.
|
|
||||||
* @param {string} plugin - The plugin directory name.
|
|
||||||
* @returns {Promise<Manifest>} - The parsed manifest object.
|
|
||||||
* @throws Will throw an error if the manifest file is missing or invalid.
|
|
||||||
*/
|
|
||||||
public async parseManifest(dir: string, plugin: string): Promise<Manifest> {
|
|
||||||
const manifestFile = await PluginLoader.findManifestFile(
|
|
||||||
`${dir}/${plugin}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!manifestFile) {
|
|
||||||
throw new Error(`Plugin ${plugin} is missing a manifest file`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const manifestPath = `${dir}/${plugin}/${manifestFile}`;
|
|
||||||
const manifest = await this.parseManifestFile(
|
|
||||||
manifestPath,
|
|
||||||
manifestFile,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await manifestSchema.safeParseAsync(manifest);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
this.logger
|
|
||||||
.fatal`Plugin manifest ${chalk.blue(manifestPath)} is invalid.`;
|
|
||||||
throw fromZodError(result.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads an entrypoint's default export and check if it's a Plugin.
|
|
||||||
* @param {string} dir - The directory containing the entrypoint.
|
|
||||||
* @param {string} entrypoint - The entrypoint file name.
|
|
||||||
* @returns {Promise<Plugin<ZodTypeAny>>} - The loaded Plugin instance.
|
|
||||||
* @throws Will throw an error if the entrypoint's default export is not a Plugin.
|
|
||||||
*/
|
|
||||||
public async loadPlugin(
|
|
||||||
dir: string,
|
|
||||||
entrypoint: string,
|
|
||||||
): Promise<Plugin<ZodTypeAny>> {
|
|
||||||
const plugin = (await import(`${dir}/${entrypoint}`)).default;
|
|
||||||
|
|
||||||
if (plugin instanceof Plugin) {
|
|
||||||
return plugin;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger
|
|
||||||
.fatal`Default export of entrypoint ${chalk.blue(entrypoint)} at ${chalk.blue(dir)} is not a Plugin.`;
|
|
||||||
|
|
||||||
throw new Error("Entrypoint is not a Plugin");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load all plugins in a given directory.
|
|
||||||
* @param {string} dir - The directory to search.
|
|
||||||
* @returns An array of objects containing the manifest and plugin instance.
|
|
||||||
*/
|
|
||||||
public async loadPlugins(
|
|
||||||
dir: string,
|
|
||||||
autoload: boolean,
|
|
||||||
enabled?: string[],
|
|
||||||
disabled?: string[],
|
|
||||||
): Promise<{ manifest: Manifest; plugin: Plugin<ZodTypeAny> }[]> {
|
|
||||||
const plugins = await PluginLoader.findPlugins(dir);
|
|
||||||
|
|
||||||
const enabledOn = (enabled?.length ?? 0) > 0;
|
|
||||||
const disabledOn = (disabled?.length ?? 0) > 0;
|
|
||||||
|
|
||||||
if (enabledOn && disabledOn) {
|
|
||||||
this.logger
|
|
||||||
.fatal`Both enabled and disabled lists are specified. Only one of them can be used.`;
|
|
||||||
throw new Error("Invalid configuration");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.all(
|
|
||||||
plugins.map(async (plugin) => {
|
|
||||||
const manifest = await this.parseManifest(dir, plugin);
|
|
||||||
|
|
||||||
// If autoload is disabled, only load plugins explicitly enabled
|
|
||||||
if (
|
|
||||||
!(autoload || enabledOn || enabled?.includes(manifest.name))
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If enabled is specified, only load plugins in the enabled list
|
|
||||||
// If disabled is specified, only load plugins not in the disabled list
|
|
||||||
if (enabledOn && !enabled?.includes(manifest.name)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (disabled?.includes(manifest.name)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pluginInstance = await this.loadPlugin(
|
|
||||||
dir,
|
|
||||||
`${plugin}/index`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return { manifest, plugin: pluginInstance };
|
|
||||||
}),
|
|
||||||
).then((data) => data.filter((d) => d !== null));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async addToApp(
|
|
||||||
plugins: {
|
|
||||||
manifest: Manifest;
|
|
||||||
plugin: Plugin<ZodTypeAny>;
|
|
||||||
}[],
|
|
||||||
app: Hono<HonoEnv>,
|
|
||||||
logger: Logger,
|
|
||||||
): Promise<void> {
|
|
||||||
for (const data of plugins) {
|
|
||||||
logger.info`Loading plugin ${chalk.blueBright(data.manifest.name)} ${chalk.blueBright(data.manifest.version)} ${chalk.gray(`[${plugins.indexOf(data) + 1}/${plugins.length}]`)}`;
|
|
||||||
|
|
||||||
const time1 = performance.now();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// biome-ignore lint/complexity/useLiteralKeys: loadConfig is a private method
|
|
||||||
await data.plugin["_loadConfig"](
|
|
||||||
config.plugins?.config?.[data.manifest.name],
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
logger.fatal`Error encountered while loading plugin ${chalk.blueBright(data.manifest.name)} ${chalk.blueBright(data.manifest.version)} configuration.`;
|
|
||||||
logger.fatal`This is due to invalid, missing or incomplete configuration.`;
|
|
||||||
logger.fatal`Put your configuration at ${chalk.blueBright(
|
|
||||||
"plugins.config.<plugin-name>",
|
|
||||||
)}`;
|
|
||||||
logger.fatal`Here is the error message, please fix the configuration file accordingly:`;
|
|
||||||
logger.fatal`${(e as ValidationError).message}`;
|
|
||||||
|
|
||||||
await sleep(Number.POSITIVE_INFINITY);
|
|
||||||
}
|
|
||||||
|
|
||||||
const time2 = performance.now();
|
|
||||||
|
|
||||||
// biome-ignore lint/complexity/useLiteralKeys: AddToApp is a private method
|
|
||||||
await data.plugin["_addToApp"](app);
|
|
||||||
|
|
||||||
const time3 = performance.now();
|
|
||||||
|
|
||||||
logger.info`Plugin ${chalk.blueBright(data.manifest.name)} ${chalk.blueBright(
|
|
||||||
data.manifest.version,
|
|
||||||
)} loaded in ${chalk.gray(
|
|
||||||
`${(time2 - time1).toFixed(2)}ms`,
|
|
||||||
)} and added to app in ${chalk.gray(`${(time3 - time2).toFixed(2)}ms`)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,317 +0,0 @@
|
||||||
/**
|
|
||||||
* @file search-manager.ts
|
|
||||||
* @description Sonic search integration for indexing and searching accounts and statuses
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getLogger } from "@logtape/logtape";
|
|
||||||
import { db, Note, User } from "@versia/kit/db";
|
|
||||||
import type { SQL, ValueOrArray } from "drizzle-orm";
|
|
||||||
import {
|
|
||||||
Ingest as SonicChannelIngest,
|
|
||||||
Search as SonicChannelSearch,
|
|
||||||
} from "sonic-channel";
|
|
||||||
import { config } from "~/config.ts";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enum for Sonic index types
|
|
||||||
*/
|
|
||||||
export enum SonicIndexType {
|
|
||||||
Accounts = "accounts",
|
|
||||||
Statuses = "statuses",
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Class for managing Sonic search operations
|
|
||||||
*/
|
|
||||||
export class SonicSearchManager {
|
|
||||||
private searchChannel: SonicChannelSearch;
|
|
||||||
private ingestChannel: SonicChannelIngest;
|
|
||||||
private connected = false;
|
|
||||||
private logger = getLogger("sonic");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param config Configuration for Sonic
|
|
||||||
*/
|
|
||||||
public constructor() {
|
|
||||||
if (!config.search.sonic) {
|
|
||||||
throw new Error("Sonic configuration is missing");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.searchChannel = new SonicChannelSearch({
|
|
||||||
host: config.search.sonic.host,
|
|
||||||
port: config.search.sonic.port,
|
|
||||||
auth: config.search.sonic.password,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.ingestChannel = new SonicChannelIngest({
|
|
||||||
host: config.search.sonic.host,
|
|
||||||
port: config.search.sonic.port,
|
|
||||||
auth: config.search.sonic.password,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect to Sonic
|
|
||||||
*/
|
|
||||||
public async connect(silent = false): Promise<void> {
|
|
||||||
if (!config.search.enabled) {
|
|
||||||
!silent && this.logger.info`Sonic search is disabled`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.connected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
!silent && this.logger.info`Connecting to Sonic...`;
|
|
||||||
|
|
||||||
// Connect to Sonic
|
|
||||||
await new Promise<boolean>((resolve, reject) => {
|
|
||||||
this.searchChannel.connect({
|
|
||||||
connected: (): void => {
|
|
||||||
!silent &&
|
|
||||||
this.logger.info`Connected to Sonic Search Channel`;
|
|
||||||
resolve(true);
|
|
||||||
},
|
|
||||||
disconnected: (): void =>
|
|
||||||
this.logger
|
|
||||||
.error`Disconnected from Sonic Search Channel. You might be using an incorrect password.`,
|
|
||||||
timeout: (): void =>
|
|
||||||
this.logger
|
|
||||||
.error`Sonic Search Channel connection timed out`,
|
|
||||||
retrying: (): void =>
|
|
||||||
this.logger
|
|
||||||
.warn`Retrying connection to Sonic Search Channel`,
|
|
||||||
error: (error): void => {
|
|
||||||
this.logger
|
|
||||||
.error`Failed to connect to Sonic Search Channel: ${error}`;
|
|
||||||
reject(error);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<boolean>((resolve, reject) => {
|
|
||||||
this.ingestChannel.connect({
|
|
||||||
connected: (): void => {
|
|
||||||
!silent &&
|
|
||||||
this.logger.info`Connected to Sonic Ingest Channel`;
|
|
||||||
resolve(true);
|
|
||||||
},
|
|
||||||
disconnected: (): void =>
|
|
||||||
this.logger.error`Disconnected from Sonic Ingest Channel`,
|
|
||||||
timeout: (): void =>
|
|
||||||
this.logger
|
|
||||||
.error`Sonic Ingest Channel connection timed out`,
|
|
||||||
retrying: (): void =>
|
|
||||||
this.logger
|
|
||||||
.warn`Retrying connection to Sonic Ingest Channel`,
|
|
||||||
error: (error): void => {
|
|
||||||
this.logger
|
|
||||||
.error`Failed to connect to Sonic Ingest Channel: ${error}`;
|
|
||||||
reject(error);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Promise.all([
|
|
||||||
this.searchChannel.ping(),
|
|
||||||
this.ingestChannel.ping(),
|
|
||||||
]);
|
|
||||||
this.connected = true;
|
|
||||||
!silent && this.logger.info`Connected to Sonic`;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.fatal`Error while connecting to Sonic: ${error}`;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a user to Sonic
|
|
||||||
* @param user User to add
|
|
||||||
*/
|
|
||||||
public async addUser(user: User): Promise<void> {
|
|
||||||
if (!config.search.enabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.ingestChannel.push(
|
|
||||||
SonicIndexType.Accounts,
|
|
||||||
"users",
|
|
||||||
user.id,
|
|
||||||
`${user.data.username} ${user.data.displayName} ${user.data.note}`,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error`Failed to add user to Sonic: ${error}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a batch of accounts from the database
|
|
||||||
* @param n Batch number
|
|
||||||
* @param batchSize Size of the batch
|
|
||||||
*/
|
|
||||||
private static getNthDatabaseAccountBatch(
|
|
||||||
n: number,
|
|
||||||
batchSize = 1000,
|
|
||||||
): Promise<Record<string, string | null | Date>[]> {
|
|
||||||
return db.query.Users.findMany({
|
|
||||||
offset: n * batchSize,
|
|
||||||
limit: batchSize,
|
|
||||||
columns: {
|
|
||||||
id: true,
|
|
||||||
username: true,
|
|
||||||
displayName: true,
|
|
||||||
note: true,
|
|
||||||
createdAt: true,
|
|
||||||
},
|
|
||||||
orderBy: (user, { asc }): ValueOrArray<SQL> => asc(user.createdAt),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a batch of statuses from the database
|
|
||||||
* @param n Batch number
|
|
||||||
* @param batchSize Size of the batch
|
|
||||||
*/
|
|
||||||
private static getNthDatabaseStatusBatch(
|
|
||||||
n: number,
|
|
||||||
batchSize = 1000,
|
|
||||||
): Promise<Record<string, string | Date>[]> {
|
|
||||||
return db.query.Notes.findMany({
|
|
||||||
offset: n * batchSize,
|
|
||||||
limit: batchSize,
|
|
||||||
columns: {
|
|
||||||
id: true,
|
|
||||||
content: true,
|
|
||||||
createdAt: true,
|
|
||||||
},
|
|
||||||
orderBy: (status, { asc }): ValueOrArray<SQL> =>
|
|
||||||
asc(status.createdAt),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rebuild search indexes
|
|
||||||
* @param indexes Indexes to rebuild
|
|
||||||
* @param batchSize Size of each batch
|
|
||||||
* @param progressCallback Callback for progress updates
|
|
||||||
*/
|
|
||||||
public async rebuildSearchIndexes(
|
|
||||||
indexes: SonicIndexType[],
|
|
||||||
batchSize = 100,
|
|
||||||
progressCallback?: (progress: number) => void,
|
|
||||||
): Promise<void> {
|
|
||||||
for (const index of indexes) {
|
|
||||||
if (index === SonicIndexType.Accounts) {
|
|
||||||
await this.rebuildAccountsIndex(batchSize, progressCallback);
|
|
||||||
} else if (index === SonicIndexType.Statuses) {
|
|
||||||
await this.rebuildStatusesIndex(batchSize, progressCallback);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rebuild accounts index
|
|
||||||
* @param batchSize Size of each batch
|
|
||||||
* @param progressCallback Callback for progress updates
|
|
||||||
*/
|
|
||||||
private async rebuildAccountsIndex(
|
|
||||||
batchSize: number,
|
|
||||||
progressCallback?: (progress: number) => void,
|
|
||||||
): Promise<void> {
|
|
||||||
const accountCount = await User.getCount();
|
|
||||||
const batchCount = Math.ceil(accountCount / batchSize);
|
|
||||||
|
|
||||||
for (let i = 0; i < batchCount; i++) {
|
|
||||||
const accounts =
|
|
||||||
await SonicSearchManager.getNthDatabaseAccountBatch(
|
|
||||||
i,
|
|
||||||
batchSize,
|
|
||||||
);
|
|
||||||
await Promise.all(
|
|
||||||
accounts.map((account) =>
|
|
||||||
this.ingestChannel.push(
|
|
||||||
SonicIndexType.Accounts,
|
|
||||||
"users",
|
|
||||||
account.id as string,
|
|
||||||
`${account.username} ${account.displayName} ${account.note}`,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
progressCallback?.((i + 1) / batchCount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rebuild statuses index
|
|
||||||
* @param batchSize Size of each batch
|
|
||||||
* @param progressCallback Callback for progress updates
|
|
||||||
*/
|
|
||||||
private async rebuildStatusesIndex(
|
|
||||||
batchSize: number,
|
|
||||||
progressCallback?: (progress: number) => void,
|
|
||||||
): Promise<void> {
|
|
||||||
const statusCount = await Note.getCount();
|
|
||||||
const batchCount = Math.ceil(statusCount / batchSize);
|
|
||||||
|
|
||||||
for (let i = 0; i < batchCount; i++) {
|
|
||||||
const statuses = await SonicSearchManager.getNthDatabaseStatusBatch(
|
|
||||||
i,
|
|
||||||
batchSize,
|
|
||||||
);
|
|
||||||
await Promise.all(
|
|
||||||
statuses.map((status) =>
|
|
||||||
this.ingestChannel.push(
|
|
||||||
SonicIndexType.Statuses,
|
|
||||||
"notes",
|
|
||||||
status.id as string,
|
|
||||||
status.content as string,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
progressCallback?.((i + 1) / batchCount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search for accounts
|
|
||||||
* @param query Search query
|
|
||||||
* @param limit Maximum number of results
|
|
||||||
* @param offset Offset for pagination
|
|
||||||
*/
|
|
||||||
public searchAccounts(
|
|
||||||
query: string,
|
|
||||||
limit = 10,
|
|
||||||
offset = 0,
|
|
||||||
): Promise<string[]> {
|
|
||||||
return this.searchChannel.query(
|
|
||||||
SonicIndexType.Accounts,
|
|
||||||
"users",
|
|
||||||
query,
|
|
||||||
{ limit, offset },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search for statuses
|
|
||||||
* @param query Search query
|
|
||||||
* @param limit Maximum number of results
|
|
||||||
* @param offset Offset for pagination
|
|
||||||
*/
|
|
||||||
public searchStatuses(
|
|
||||||
query: string,
|
|
||||||
limit = 10,
|
|
||||||
offset = 0,
|
|
||||||
): Promise<string[]> {
|
|
||||||
return this.searchChannel.query(
|
|
||||||
SonicIndexType.Statuses,
|
|
||||||
"notes",
|
|
||||||
query,
|
|
||||||
{ limit, offset },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const searchManager = new SonicSearchManager();
|
|
||||||
|
|
@ -3,10 +3,10 @@ import { friendlyErrorPlugin } from "@clerc/plugin-friendly-error";
|
||||||
import { helpPlugin } from "@clerc/plugin-help";
|
import { helpPlugin } from "@clerc/plugin-help";
|
||||||
import { notFoundPlugin } from "@clerc/plugin-not-found";
|
import { notFoundPlugin } from "@clerc/plugin-not-found";
|
||||||
import { versionPlugin } from "@clerc/plugin-version";
|
import { versionPlugin } from "@clerc/plugin-version";
|
||||||
|
import { setupDatabase } from "@versia-server/kit/db";
|
||||||
|
import { searchManager } from "@versia-server/kit/search";
|
||||||
import { Clerc } from "clerc";
|
import { Clerc } from "clerc";
|
||||||
import { searchManager } from "~/classes/search/search-manager.ts";
|
import pkg from "../package.json" with { type: "json" };
|
||||||
import { setupDatabase } from "~/drizzle/db.ts";
|
|
||||||
import pkg from "~/package.json" with { type: "json" };
|
|
||||||
import { rebuildIndexCommand } from "./index/rebuild.ts";
|
import { rebuildIndexCommand } from "./index/rebuild.ts";
|
||||||
import { refetchInstanceCommand } from "./instance/refetch.ts";
|
import { refetchInstanceCommand } from "./instance/refetch.ts";
|
||||||
import { createUserCommand } from "./user/create.ts";
|
import { createUserCommand } from "./user/create.ts";
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
|
import { config } from "@versia-server/config";
|
||||||
|
import { SonicIndexType, searchManager } from "@versia-server/kit/search";
|
||||||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||||
import { defineCommand, type Root } from "clerc";
|
import { defineCommand, type Root } from "clerc";
|
||||||
import ora from "ora";
|
import ora from "ora";
|
||||||
import {
|
|
||||||
SonicIndexType,
|
|
||||||
searchManager,
|
|
||||||
} from "~/classes/search/search-manager.ts";
|
|
||||||
import { config } from "~/config.ts";
|
|
||||||
|
|
||||||
export const rebuildIndexCommand = defineCommand(
|
export const rebuildIndexCommand = defineCommand(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
|
import { Instance } from "@versia-server/kit/db";
|
||||||
|
import { FetchJobType, fetchQueue } from "@versia-server/kit/queues/fetch";
|
||||||
|
import { Instances } from "@versia-server/kit/tables";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||||
import { defineCommand, type Root } from "clerc";
|
import { defineCommand, type Root } from "clerc";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { Instance } from "~/classes/database/instance.ts";
|
|
||||||
import { FetchJobType, fetchQueue } from "~/classes/queues/fetch.ts";
|
|
||||||
import { Instances } from "~/drizzle/schema.ts";
|
|
||||||
|
|
||||||
export const refetchInstanceCommand = defineCommand(
|
export const refetchInstanceCommand = defineCommand(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
|
import { config } from "@versia-server/config";
|
||||||
|
import { User } from "@versia-server/kit/db";
|
||||||
|
import { searchManager } from "@versia-server/kit/search";
|
||||||
|
import { Users } from "@versia-server/kit/tables";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||||
import { defineCommand, type Root } from "clerc";
|
import { defineCommand, type Root } from "clerc";
|
||||||
import { and, eq, isNull } from "drizzle-orm";
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
import { renderUnicodeCompact } from "uqr";
|
import { renderUnicodeCompact } from "uqr";
|
||||||
import { User } from "~/classes/database/user";
|
|
||||||
import { config } from "~/config";
|
|
||||||
import { Users } from "~/drizzle/schema";
|
|
||||||
|
|
||||||
export const createUserCommand = defineCommand(
|
export const createUserCommand = defineCommand(
|
||||||
{
|
{
|
||||||
|
|
@ -54,6 +55,9 @@ export const createUserCommand = defineCommand(
|
||||||
isAdmin: admin,
|
isAdmin: admin,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add to search index
|
||||||
|
await searchManager.addUser(user);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error("Failed to create user.");
|
throw new Error("Failed to create user.");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
|
import { User } from "@versia-server/kit/db";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||||
import { defineCommand, type Root } from "clerc";
|
import { defineCommand, type Root } from "clerc";
|
||||||
import ora from "ora";
|
import ora from "ora";
|
||||||
import { User } from "~/classes/database/user.ts";
|
|
||||||
import { retrieveUser } from "../utils.ts";
|
import { retrieveUser } from "../utils.ts";
|
||||||
|
|
||||||
export const refetchUserCommand = defineCommand(
|
export const refetchUserCommand = defineCommand(
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
|
import { Token } from "@versia-server/kit/db";
|
||||||
import { randomUUIDv7 } from "bun";
|
import { randomUUIDv7 } from "bun";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||||
import { defineCommand, type Root } from "clerc";
|
import { defineCommand, type Root } from "clerc";
|
||||||
import { randomString } from "@/math.ts";
|
import { randomString } from "@/math.ts";
|
||||||
import { Token } from "~/classes/database/token.ts";
|
|
||||||
import { retrieveUser } from "../utils.ts";
|
import { retrieveUser } from "../utils.ts";
|
||||||
|
|
||||||
export const generateTokenCommand = defineCommand(
|
export const generateTokenCommand = defineCommand(
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
|
import { Instance, User } from "@versia-server/kit/db";
|
||||||
|
import { parseUserAddress } from "@versia-server/kit/parsers";
|
||||||
|
import { Users } from "@versia-server/kit/tables";
|
||||||
import { and, eq, isNull } from "drizzle-orm";
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
import { parseUserAddress } from "@/api";
|
|
||||||
import { Instance } from "~/classes/database/instance";
|
|
||||||
import { User } from "~/classes/database/user";
|
|
||||||
import { Users } from "~/drizzle/schema";
|
|
||||||
|
|
||||||
export const retrieveUser = async (
|
export const retrieveUser = async (
|
||||||
usernameOrHandle: string,
|
usernameOrHandle: string,
|
||||||
|
|
|
||||||
48
config.ts
48
config.ts
|
|
@ -1,48 +0,0 @@
|
||||||
/**
|
|
||||||
* @file config.ts
|
|
||||||
* @summary Config 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 { env, file } from "bun";
|
|
||||||
import chalk from "chalk";
|
|
||||||
import { parseTOML } from "confbox";
|
|
||||||
import type { z } from "zod";
|
|
||||||
import { fromZodError } from "zod-validation-error";
|
|
||||||
import { ConfigSchema } from "./classes/config/schema.ts";
|
|
||||||
|
|
||||||
const CONFIG_LOCATION = env.CONFIG_LOCATION ?? "./config/config.toml";
|
|
||||||
const configFile = file(CONFIG_LOCATION);
|
|
||||||
|
|
||||||
if (!(await configFile.exists())) {
|
|
||||||
throw new Error(
|
|
||||||
`config file at "${CONFIG_LOCATION}" does not exist or is not accessible.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const configText = await configFile.text();
|
|
||||||
const config = await parseTOML<z.infer<typeof ConfigSchema>>(configText);
|
|
||||||
|
|
||||||
const parsed = await ConfigSchema.safeParseAsync(config);
|
|
||||||
|
|
||||||
if (!parsed.success) {
|
|
||||||
console.error(
|
|
||||||
`⚠ Error encountered while loading ${chalk.gray(CONFIG_LOCATION)}.`,
|
|
||||||
);
|
|
||||||
console.error(
|
|
||||||
"⚠ This is due to invalid, missing or incorrect values in the configuration file.",
|
|
||||||
);
|
|
||||||
console.error(
|
|
||||||
"⚠ Here is the error message, please fix the configuration file accordingly:",
|
|
||||||
);
|
|
||||||
const errorMessage = fromZodError(parsed.error).message;
|
|
||||||
|
|
||||||
console.info(errorMessage);
|
|
||||||
|
|
||||||
throw new Error("Configuration file is invalid.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const exportedConfig = parsed.data;
|
|
||||||
|
|
||||||
export { exportedConfig as config };
|
|
||||||
1
config/config
Symbolic link
1
config/config
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
../config
|
||||||
|
|
@ -435,50 +435,38 @@ text = "No spam"
|
||||||
|
|
||||||
[logging]
|
[logging]
|
||||||
|
|
||||||
# Available levels: debug, info, warning, error, fatal
|
# Available levels: trace, debug, info, warning, error, fatal
|
||||||
log_level = "debug"
|
log_level = "info" # For console output
|
||||||
|
|
||||||
log_file_path = "logs/versia.log"
|
|
||||||
|
|
||||||
[logging.types]
|
|
||||||
# Either pass a boolean
|
|
||||||
# requests = true
|
|
||||||
# Or a table with the following keys:
|
|
||||||
# requests_content = { level = "debug", log_file_path = "logs/requests.log" }
|
|
||||||
# Available types are: requests, responses, requests_content, filters
|
|
||||||
|
|
||||||
|
# [logging.file]
|
||||||
|
# path = "logs/versia.log"
|
||||||
|
# log_level = "info"
|
||||||
|
#
|
||||||
|
# [logging.file.rotation]
|
||||||
|
# max_size = 10_000_000 # 10 MB
|
||||||
|
# max_files = 10 # Keep 10 rotated files
|
||||||
|
#
|
||||||
# https://sentry.io support
|
# https://sentry.io support
|
||||||
# Uncomment to enable
|
|
||||||
# [logging.sentry]
|
# [logging.sentry]
|
||||||
# Sentry DSN for error logging
|
|
||||||
# dsn = "https://example.com"
|
# dsn = "https://example.com"
|
||||||
# debug = false
|
# debug = false
|
||||||
|
|
||||||
# sample_rate = 1.0
|
# sample_rate = 1.0
|
||||||
# traces_sample_rate = 1.0
|
# traces_sample_rate = 1.0
|
||||||
# Can also be regex
|
# Can also be regex
|
||||||
# trace_propagation_targets = []
|
# trace_propagation_targets = []
|
||||||
# max_breadcrumbs = 100
|
# max_breadcrumbs = 100
|
||||||
# environment = "production"
|
# environment = "production"
|
||||||
|
# log_level = "info"
|
||||||
|
|
||||||
[plugins]
|
[authentication]
|
||||||
# Whether to automatically load all plugins in the plugins directory
|
|
||||||
autoload = true
|
|
||||||
|
|
||||||
# Override for autoload
|
|
||||||
[plugins.overrides]
|
|
||||||
enabled = []
|
|
||||||
disabled = []
|
|
||||||
|
|
||||||
[plugins.config."@versia/openid"]
|
|
||||||
# If enabled, Versia will require users to log in with an OpenID provider
|
# If enabled, Versia will require users to log in with an OpenID provider
|
||||||
forced = false
|
forced_openid = false
|
||||||
|
|
||||||
# Allow registration with OpenID providers
|
# Allow registration with OpenID providers
|
||||||
# If signups.registration is false, it will only be possible to register with OpenID
|
# If signups.registration is false, it will only be possible to register with OpenID
|
||||||
allow_registration = true
|
openid_registration = true
|
||||||
|
|
||||||
# [plugins.config."@versia/openid".keys]
|
# [authentication.keys]
|
||||||
# Run Versia Server with those values missing to generate a new key
|
# Run Versia Server with those values missing to generate a new key
|
||||||
# public = ""
|
# public = ""
|
||||||
# private = ""
|
# private = ""
|
||||||
|
|
@ -490,7 +478,7 @@ allow_registration = true
|
||||||
# The asterisk is important, as it allows for any query parameters to be passed
|
# The asterisk is important, as it allows for any query parameters to be passed
|
||||||
# Authentik for example uses regex so it can be set to (regex):
|
# Authentik for example uses regex so it can be set to (regex):
|
||||||
# <base_url>/oauth/sso/<provider_id>/callback.*
|
# <base_url>/oauth/sso/<provider_id>/callback.*
|
||||||
# [[plugins.config."@versia/openid".providers]]
|
# [[authentication.openid_providers]]
|
||||||
# name = "CPlusPatch ID"
|
# name = "CPlusPatch ID"
|
||||||
# id = "cpluspatch-id"
|
# id = "cpluspatch-id"
|
||||||
# This MUST match the provider's issuer URI, including the trailing slash (or lack thereof)
|
# This MUST match the provider's issuer URI, including the trailing slash (or lack thereof)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
|
import { config } from "@versia-server/config";
|
||||||
import type { Config } from "drizzle-kit";
|
import type { Config } from "drizzle-kit";
|
||||||
import { config } from "~/config.ts";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drizzle can't properly resolve imports with top-level await, so uncomment
|
* Drizzle can't properly resolve imports with top-level await, so uncomment
|
||||||
|
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
import process from "node:process";
|
|
||||||
import { getLogger } from "@logtape/logtape";
|
|
||||||
import chalk from "chalk";
|
|
||||||
import { sentry } from "@/sentry";
|
|
||||||
import { getDeliveryWorker } from "~/classes/queues/delivery";
|
|
||||||
import { getFetchWorker } from "~/classes/queues/fetch";
|
|
||||||
import { getInboxWorker } from "~/classes/queues/inbox";
|
|
||||||
import { getMediaWorker } from "~/classes/queues/media";
|
|
||||||
import { getPushWorker } from "~/classes/queues/push";
|
|
||||||
import { getRelationshipWorker } from "~/classes/queues/relationships";
|
|
||||||
|
|
||||||
process.on("SIGINT", () => {
|
|
||||||
process.exit();
|
|
||||||
});
|
|
||||||
|
|
||||||
await import("~/entrypoints/worker/setup.ts");
|
|
||||||
sentry?.captureMessage("Server started", "info");
|
|
||||||
|
|
||||||
const serverLogger = getLogger("server");
|
|
||||||
|
|
||||||
serverLogger.info`Starting Fetch Worker...`;
|
|
||||||
getFetchWorker();
|
|
||||||
serverLogger.info`${chalk.green("✔")} Fetch Worker started`;
|
|
||||||
|
|
||||||
serverLogger.info`Starting Delivery Worker...`;
|
|
||||||
getDeliveryWorker();
|
|
||||||
serverLogger.info`${chalk.green("✔")} Delivery Worker started`;
|
|
||||||
|
|
||||||
serverLogger.info`Starting Inbox Worker...`;
|
|
||||||
getInboxWorker();
|
|
||||||
serverLogger.info`${chalk.green("✔")} Inbox Worker started`;
|
|
||||||
|
|
||||||
serverLogger.info`Starting Push Worker...`;
|
|
||||||
getPushWorker();
|
|
||||||
serverLogger.info`${chalk.green("✔")} Push Worker started`;
|
|
||||||
|
|
||||||
serverLogger.info`Starting Media Worker...`;
|
|
||||||
getMediaWorker();
|
|
||||||
serverLogger.info`${chalk.green("✔")} Media Worker started`;
|
|
||||||
|
|
||||||
serverLogger.info`Starting Relationship Worker...`;
|
|
||||||
getRelationshipWorker();
|
|
||||||
serverLogger.info`${chalk.green("✔")} Relationship Worker started`;
|
|
||||||
|
|
||||||
serverLogger.info`${chalk.green("✔✔✔✔✔✔")} All workers started`;
|
|
||||||
12
flake.lock
12
flake.lock
|
|
@ -20,16 +20,16 @@
|
||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1744536153,
|
"lastModified": 1751637120,
|
||||||
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
|
"narHash": "sha256-xVNy/XopSfIG9c46nRmPaKfH1Gn/56vQ8++xWA8itO4=",
|
||||||
"owner": "nixos",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
|
"rev": "5c724ed1388e53cc231ed98330a60eb2f7be4be3",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "nixos",
|
"owner": "NixOS",
|
||||||
"ref": "nixpkgs-unstable",
|
"ref": "nixos-unstable",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
description = "Versia Server";
|
description = "Versia Server";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
};
|
};
|
||||||
|
|
@ -15,7 +15,9 @@
|
||||||
}:
|
}:
|
||||||
{
|
{
|
||||||
overlays.default = final: prev: rec {
|
overlays.default = final: prev: rec {
|
||||||
versia-server = final.callPackage ./nix/package.nix {};
|
versia-server =
|
||||||
|
final.callPackage ./nix/package.nix {
|
||||||
|
};
|
||||||
versia-server-worker = final.callPackage ./nix/package-worker.nix {
|
versia-server-worker = final.callPackage ./nix/package-worker.nix {
|
||||||
inherit versia-server;
|
inherit versia-server;
|
||||||
};
|
};
|
||||||
|
|
@ -54,7 +56,6 @@
|
||||||
buildInputs = with pkgs; [
|
buildInputs = with pkgs; [
|
||||||
bun
|
bun
|
||||||
vips
|
vips
|
||||||
pnpm
|
|
||||||
nodePackages.typescript
|
nodePackages.typescript
|
||||||
nodePackages.typescript-language-server
|
nodePackages.typescript-language-server
|
||||||
nix-ld
|
nix-ld
|
||||||
|
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import { getLogger } from "@logtape/logtape";
|
|
||||||
import { SHA256 } from "bun";
|
|
||||||
import chalk from "chalk";
|
|
||||||
import { createMiddleware } from "hono/factory";
|
|
||||||
import { config } from "~/config.ts";
|
|
||||||
|
|
||||||
export const logger = createMiddleware(async (context, next) => {
|
|
||||||
if (config.logging.types.requests) {
|
|
||||||
const serverLogger = getLogger("server");
|
|
||||||
const body = await context.req.raw.clone().text();
|
|
||||||
|
|
||||||
const urlAndMethod = `${chalk.green(context.req.method)} ${chalk.blue(context.req.url)}`;
|
|
||||||
|
|
||||||
const hash = `${chalk.bold("Hash")}: ${chalk.yellow(
|
|
||||||
new SHA256().update(body).digest("hex"),
|
|
||||||
)}`;
|
|
||||||
|
|
||||||
const headers = `${chalk.bold("Headers")}:\n${Array.from(
|
|
||||||
context.req.raw.headers.entries(),
|
|
||||||
)
|
|
||||||
.map(
|
|
||||||
([key, value]) =>
|
|
||||||
` - ${chalk.cyan(key)}: ${chalk.white(value)}`,
|
|
||||||
)
|
|
||||||
.join("\n")}`;
|
|
||||||
|
|
||||||
const bodyLog = `${chalk.bold("Body")}: ${chalk.gray(body)}`;
|
|
||||||
|
|
||||||
if (config.logging.types.requests_content) {
|
|
||||||
serverLogger.debug`${urlAndMethod}\n${hash}\n${headers}\n${bodyLog}`;
|
|
||||||
} else {
|
|
||||||
serverLogger.debug`${urlAndMethod}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await next();
|
|
||||||
});
|
|
||||||
|
|
@ -2,14 +2,8 @@
|
||||||
{versia-server, ...}:
|
{versia-server, ...}:
|
||||||
versia-server.overrideAttrs (oldAttrs: {
|
versia-server.overrideAttrs (oldAttrs: {
|
||||||
pname = "${oldAttrs.pname}-worker";
|
pname = "${oldAttrs.pname}-worker";
|
||||||
buildPhase = ''
|
|
||||||
runHook preBuild
|
|
||||||
|
|
||||||
bun run build:worker
|
buildType = "worker";
|
||||||
|
|
||||||
runHook postBuild
|
|
||||||
'';
|
|
||||||
entrypointPath = "worker.js";
|
|
||||||
|
|
||||||
meta =
|
meta =
|
||||||
oldAttrs.meta
|
oldAttrs.meta
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
{
|
{
|
||||||
lib,
|
lib,
|
||||||
stdenv,
|
stdenv,
|
||||||
pnpm,
|
|
||||||
bun,
|
bun,
|
||||||
nodejs,
|
nodejs,
|
||||||
vips,
|
vips,
|
||||||
makeWrapper,
|
makeWrapper,
|
||||||
|
stdenvNoCC,
|
||||||
|
writableTmpDirAsHomeHook,
|
||||||
...
|
...
|
||||||
}: let
|
}: let
|
||||||
packageJson = builtins.fromJSON (builtins.readFile ../package.json);
|
packageJson = builtins.fromJSON (builtins.readFile ../package.json);
|
||||||
|
|
@ -16,35 +17,70 @@ in
|
||||||
|
|
||||||
src = ../.;
|
src = ../.;
|
||||||
|
|
||||||
# Fixes the build script mv usage
|
node_modules = stdenvNoCC.mkDerivation {
|
||||||
pnpmInstallFlags = ["--shamefully-hoist"];
|
pname = "${finalAttrs.pname}-node_modules";
|
||||||
|
inherit (finalAttrs) version src;
|
||||||
|
|
||||||
pnpmDeps = pnpm.fetchDeps {
|
nativeBuildInputs = [
|
||||||
inherit (finalAttrs) pname version src pnpmInstallFlags;
|
bun
|
||||||
hash = "sha256-/VCzDp8EfvQkaz/5W3rcoEyOlSB4zeW97qqOTJf6WvA=";
|
nodejs
|
||||||
|
writableTmpDirAsHomeHook
|
||||||
|
];
|
||||||
|
|
||||||
|
dontConfigure = true;
|
||||||
|
|
||||||
|
buildPhase = ''
|
||||||
|
runHook preBuild
|
||||||
|
|
||||||
|
export BUN_INSTALL_CACHE_DIR=$(mktemp -d)
|
||||||
|
|
||||||
|
bun install \
|
||||||
|
--force \
|
||||||
|
--frozen-lockfile \
|
||||||
|
--no-progress
|
||||||
|
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
|
||||||
|
mkdir -p $out/node_modules
|
||||||
|
cp -R ./node_modules $out
|
||||||
|
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Required else we get errors that our fixed-output derivation references store paths
|
||||||
|
dontFixup = true;
|
||||||
|
|
||||||
|
outputHash = "sha256-aG54v3luuJTmb/eonoILv3KBKW6mulk3xOpxLA6V5L8=";
|
||||||
|
outputHashAlgo = "sha256";
|
||||||
|
outputHashMode = "recursive";
|
||||||
};
|
};
|
||||||
|
|
||||||
nativeBuildInputs = [
|
nativeBuildInputs = [
|
||||||
pnpm
|
|
||||||
pnpm.configHook
|
|
||||||
bun
|
bun
|
||||||
nodejs
|
|
||||||
makeWrapper
|
makeWrapper
|
||||||
];
|
];
|
||||||
|
|
||||||
buildInputs = [
|
configurePhase = ''
|
||||||
vips
|
runHook preConfigure
|
||||||
];
|
|
||||||
|
cp -R ${finalAttrs.node_modules}/node_modules .
|
||||||
|
|
||||||
|
runHook postConfigure
|
||||||
|
'';
|
||||||
|
|
||||||
buildPhase = ''
|
buildPhase = ''
|
||||||
runHook preBuild
|
runHook preBuild
|
||||||
|
|
||||||
bun run build
|
bun run build ${finalAttrs.buildType}
|
||||||
|
|
||||||
runHook postBuild
|
runHook postBuild
|
||||||
'';
|
'';
|
||||||
|
|
||||||
entrypointPath = "index.js";
|
buildType = "api";
|
||||||
|
|
||||||
installPhase = let
|
installPhase = let
|
||||||
libPath = lib.makeLibraryPath [
|
libPath = lib.makeLibraryPath [
|
||||||
|
|
@ -62,7 +98,7 @@ in
|
||||||
cp -r dist $out/${finalAttrs.pname}
|
cp -r dist $out/${finalAttrs.pname}
|
||||||
|
|
||||||
makeWrapper ${lib.getExe bun} $out/bin/${finalAttrs.pname} \
|
makeWrapper ${lib.getExe bun} $out/bin/${finalAttrs.pname} \
|
||||||
--add-flags "run $out/${finalAttrs.pname}/${finalAttrs.entrypointPath}" \
|
--add-flags "run $out/${finalAttrs.pname}/${finalAttrs.buildType}.js" \
|
||||||
--set NODE_PATH $out/${finalAttrs.pname}/node_modules \
|
--set NODE_PATH $out/${finalAttrs.pname}/node_modules \
|
||||||
--set MSGPACKR_NATIVE_ACCELERATION_DISABLED true \
|
--set MSGPACKR_NATIVE_ACCELERATION_DISABLED true \
|
||||||
--prefix PATH : ${binPath} \
|
--prefix PATH : ${binPath} \
|
||||||
|
|
|
||||||
256
package.json
256
package.json
|
|
@ -20,9 +20,88 @@
|
||||||
"activitypub",
|
"activitypub",
|
||||||
"bun"
|
"bun"
|
||||||
],
|
],
|
||||||
"workspaces": [
|
"workspaces": {
|
||||||
"packages/*"
|
"packages": [
|
||||||
],
|
"packages/*"
|
||||||
|
],
|
||||||
|
"catalog": {
|
||||||
|
"@biomejs/biome": "^2.0.6",
|
||||||
|
"@types/bun": "^1.2.18",
|
||||||
|
"@types/html-to-text": "^9.0.4",
|
||||||
|
"@types/markdown-it-container": "^2.0.10",
|
||||||
|
"@types/mime-types": "^3.0.1",
|
||||||
|
"@types/qs": "^6.14.0",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
|
"bun-bagel": "^1.2.0",
|
||||||
|
"drizzle-kit": "^0.31.4",
|
||||||
|
"mitt": "^3.0.1",
|
||||||
|
"markdown-it-image-figures": "^2.1.1",
|
||||||
|
"ts-prune": "^0.10.3",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"vitepress": "^1.6.3",
|
||||||
|
"vitepress-plugin-tabs": "^0.7.1",
|
||||||
|
"vitepress-sidebar": "^1.32.1",
|
||||||
|
"vue": "^3.5.17",
|
||||||
|
"@bull-board/api": "^6.11.0",
|
||||||
|
"@bull-board/hono": "^6.11.0",
|
||||||
|
"@clerc/plugin-completions": "^0.44.0",
|
||||||
|
"@clerc/plugin-friendly-error": "^0.44.0",
|
||||||
|
"@clerc/plugin-help": "^0.44.0",
|
||||||
|
"@clerc/plugin-not-found": "^0.44.0",
|
||||||
|
"@clerc/plugin-version": "^0.44.0",
|
||||||
|
"@hackmd/markdown-it-task-lists": "^2.1.4",
|
||||||
|
"@hono/standard-validator": "^0.1.2",
|
||||||
|
"@inquirer/confirm": "^5.1.13",
|
||||||
|
"@logtape/file": "^1.0.0",
|
||||||
|
"@logtape/logtape": "^1.0.0",
|
||||||
|
"@logtape/sentry": "^1.0.0",
|
||||||
|
"@logtape/otel": "^1.0.0",
|
||||||
|
"@scalar/hono-api-reference": "^0.9.7",
|
||||||
|
"@sentry/bun": "^9.35.0",
|
||||||
|
"altcha-lib": "^1.3.0",
|
||||||
|
"blurhash": "^2.0.5",
|
||||||
|
"bullmq": "^5.56.1",
|
||||||
|
"chalk": "^5.4.1",
|
||||||
|
"clerc": "^0.44.0",
|
||||||
|
"confbox": "^0.2.2",
|
||||||
|
"drizzle-orm": "^0.44.2",
|
||||||
|
"feed": "^5.1.0",
|
||||||
|
"hono": "^4.8.4",
|
||||||
|
"hono-openapi": "npm:@cpluspatch/hono-openapi@0.5.1",
|
||||||
|
"hono-rate-limiter": "^0.4.2",
|
||||||
|
"html-to-text": "^9.0.5",
|
||||||
|
"ioredis": "^5.6.1",
|
||||||
|
"ip-matching": "^2.1.2",
|
||||||
|
"iso-639-1": "^3.1.5",
|
||||||
|
"jose": "^6.0.11",
|
||||||
|
"linkify-html": "^4.3.1",
|
||||||
|
"linkify-string": "^4.3.1",
|
||||||
|
"linkifyjs": "^4.3.1",
|
||||||
|
"magic-regexp": "^0.10.0",
|
||||||
|
"markdown-it": "^14.1.0",
|
||||||
|
"markdown-it-anchor": "^9.2.0",
|
||||||
|
"markdown-it-container": "^4.0.0",
|
||||||
|
"markdown-it-mathjax3": "^4.3.2",
|
||||||
|
"markdown-it-toc-done-right": "^4.2.0",
|
||||||
|
"mime-types": "^3.0.1",
|
||||||
|
"mitata": "^1.0.34",
|
||||||
|
"oauth4webapi": "^3.5.5",
|
||||||
|
"ora": "^8.2.0",
|
||||||
|
"qs": "^6.14.0",
|
||||||
|
"sharp": "^0.34.2",
|
||||||
|
"sonic-channel": "^1.3.1",
|
||||||
|
"string-comparison": "^1.3.0",
|
||||||
|
"stringify-entities": "^4.0.4",
|
||||||
|
"unicode-emoji-json": "^0.8.0",
|
||||||
|
"uqr": "^0.1.2",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
|
"xss": "^1.0.15",
|
||||||
|
"youch": "^4.1.0-beta.7",
|
||||||
|
"zod": "^3.25.74",
|
||||||
|
"zod-openapi": "^5.0.0",
|
||||||
|
"zod-validation-error": "^4.0.0-beta.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"maintainers": [
|
"maintainers": [
|
||||||
{
|
{
|
||||||
"email": "contact@cpluspatch.com",
|
"email": "contact@cpluspatch.com",
|
||||||
|
|
@ -36,107 +115,108 @@
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --hot index.ts",
|
|
||||||
"start": "NODE_ENV=production bun run dist/index.js --prod",
|
|
||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
"build": "bun run build.ts",
|
|
||||||
"build:worker": "bun run build-worker.ts",
|
|
||||||
"cloc": "cloc . --exclude-dir node_modules,dist,.output,.nuxt,meta,logs --exclude-ext sql,log,pem",
|
"cloc": "cloc . --exclude-dir node_modules,dist,.output,.nuxt,meta,logs --exclude-ext sql,log,pem",
|
||||||
"wc": "find server database *.ts docs packages types utils drizzle tests -type f -print0 | wc -m --files0-from=-",
|
|
||||||
"cli": "bun run cli/index.ts",
|
"cli": "bun run cli/index.ts",
|
||||||
"prune": "ts-prune | grep -v server/ | grep -v dist/ | grep -v '(used in module)'",
|
"typecheck": "bunx tsc -p .",
|
||||||
"schema:generate": "bun run classes/config/to-json-schema.ts > config/config.schema.json && bun run packages/plugin-kit/json-schema.ts > packages/plugin-kit/manifest.schema.json",
|
|
||||||
"check": "bunx tsc -p .",
|
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"docs:dev": "vitepress dev docs",
|
"build": "bun run --filter \"*\" build && bun run build.ts",
|
||||||
"docs:build": "vitepress build docs",
|
"detect-circular": "bunx madge --circular --extensions ts ./",
|
||||||
"docs:preview": "vitepress preview docs"
|
"update-nix-hashes": "bash scripts/update-nix.sh",
|
||||||
|
"schema:generate": "bun run packages/config/to-json-schema.ts > config/config.schema.json",
|
||||||
|
"run-api": "bun run build api && cd dist && ln -s ../config config && bun run api.js",
|
||||||
|
"run-worker": "bun run build worker && cd dist && ln -s ../config config && bun run worker.js",
|
||||||
|
"dev": "bun run --hot api.ts",
|
||||||
|
"worker:dev": "bun run --hot worker.ts"
|
||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"@biomejs/biome",
|
"@biomejs/biome",
|
||||||
"es5-ext",
|
"es5-ext",
|
||||||
"esbuild",
|
"esbuild",
|
||||||
"msgpackr-extract",
|
"msgpackr-extract",
|
||||||
|
"protobufjs",
|
||||||
"sharp"
|
"sharp"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.0.0-beta.5",
|
"@biomejs/biome": "catalog:",
|
||||||
"@types/bun": "^1.2.16",
|
"@types/bun": "catalog:",
|
||||||
"@types/html-to-text": "^9.0.4",
|
"@types/html-to-text": "catalog:",
|
||||||
"@types/markdown-it-container": "^2.0.10",
|
"@types/markdown-it-container": "catalog:",
|
||||||
"@types/mime-types": "^3.0.1",
|
"@types/mime-types": "catalog:",
|
||||||
"@types/qs": "^6.14.0",
|
"@types/qs": "catalog:",
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "catalog:",
|
||||||
"bun-bagel": "^1.2.0",
|
"bun-bagel": "catalog:",
|
||||||
"drizzle-kit": "^0.31.1",
|
"drizzle-kit": "catalog:",
|
||||||
"markdown-it-image-figures": "^2.1.1",
|
"markdown-it-image-figures": "catalog:",
|
||||||
"ts-prune": "^0.10.3",
|
"ts-prune": "catalog:",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "catalog:",
|
||||||
"vitepress": "^1.6.3",
|
"vitepress": "catalog:",
|
||||||
"vitepress-plugin-tabs": "^0.7.1",
|
"vitepress-plugin-tabs": "catalog:",
|
||||||
"vitepress-sidebar": "^1.31.1",
|
"vitepress-sidebar": "catalog:",
|
||||||
"vue": "^3.5.16",
|
"vue": "catalog:"
|
||||||
"zod-to-json-schema": "^3.24.5"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bull-board/api": "^6.10.1",
|
"@bull-board/api": "catalog:",
|
||||||
"@bull-board/hono": "^6.10.1",
|
"@bull-board/hono": "catalog:",
|
||||||
"@clerc/plugin-completions": "^0.44.0",
|
"@clerc/plugin-completions": "catalog:",
|
||||||
"@clerc/plugin-friendly-error": "^0.44.0",
|
"@clerc/plugin-friendly-error": "catalog:",
|
||||||
"@clerc/plugin-help": "^0.44.0",
|
"@clerc/plugin-help": "catalog:",
|
||||||
"@clerc/plugin-not-found": "^0.44.0",
|
"@clerc/plugin-not-found": "catalog:",
|
||||||
"@clerc/plugin-version": "^0.44.0",
|
"@clerc/plugin-version": "catalog:",
|
||||||
"@hackmd/markdown-it-task-lists": "^2.1.4",
|
"@hackmd/markdown-it-task-lists": "catalog:",
|
||||||
"@hono/zod-validator": "^0.7.0",
|
"@hono/standard-validator": "catalog:",
|
||||||
"@inquirer/confirm": "^5.1.12",
|
"@inquirer/confirm": "catalog:",
|
||||||
"@logtape/file": "^0.12.0",
|
"@scalar/hono-api-reference": "catalog:",
|
||||||
"@logtape/logtape": "^0.12.0",
|
"@sentry/bun": "catalog:",
|
||||||
"@scalar/hono-api-reference": "^0.9.4",
|
"@versia-server/api": "workspace:*",
|
||||||
"@sentry/bun": "^9.29.0",
|
"@versia-server/config": "workspace:*",
|
||||||
|
"@versia-server/kit": "workspace:*",
|
||||||
|
"@versia-server/logging": "workspace:*",
|
||||||
|
"@versia-server/tests": "workspace:*",
|
||||||
|
"@versia-server/worker": "workspace:*",
|
||||||
"@versia/client": "workspace:*",
|
"@versia/client": "workspace:*",
|
||||||
"@versia/kit": "workspace:*",
|
|
||||||
"@versia/sdk": "workspace:*",
|
"@versia/sdk": "workspace:*",
|
||||||
"altcha-lib": "^1.3.0",
|
"altcha-lib": "catalog:",
|
||||||
"blurhash": "^2.0.5",
|
"blurhash": "catalog:",
|
||||||
"bullmq": "^5.53.3",
|
"bullmq": "catalog:",
|
||||||
"chalk": "^5.4.1",
|
"chalk": "catalog:",
|
||||||
"clerc": "^0.44.0",
|
"clerc": "catalog:",
|
||||||
"confbox": "^0.2.2",
|
"confbox": "catalog:",
|
||||||
"drizzle-orm": "^0.44.2",
|
"drizzle-orm": "catalog:",
|
||||||
"feed": "^5.1.0",
|
"feed": "catalog:",
|
||||||
"hono": "^4.7.11",
|
"hono": "catalog:",
|
||||||
"hono-openapi": "^0.4.8",
|
"hono-openapi": "catalog:",
|
||||||
"hono-rate-limiter": "^0.4.2",
|
"hono-rate-limiter": "catalog:",
|
||||||
"html-to-text": "^9.0.5",
|
"html-to-text": "catalog:",
|
||||||
"ioredis": "^5.6.1",
|
"ioredis": "catalog:",
|
||||||
"ip-matching": "^2.1.2",
|
"ip-matching": "catalog:",
|
||||||
"iso-639-1": "^3.1.5",
|
"iso-639-1": "catalog:",
|
||||||
"jose": "^6.0.11",
|
"jose": "catalog:",
|
||||||
"linkify-html": "^4.3.1",
|
"linkify-html": "catalog:",
|
||||||
"linkify-string": "^4.3.1",
|
"linkify-string": "catalog:",
|
||||||
"linkifyjs": "^4.3.1",
|
"linkifyjs": "catalog:",
|
||||||
"magic-regexp": "^0.10.0",
|
"magic-regexp": "catalog:",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "catalog:",
|
||||||
"markdown-it-anchor": "^9.2.0",
|
"markdown-it-anchor": "catalog:",
|
||||||
"markdown-it-container": "^4.0.0",
|
"markdown-it-container": "catalog:",
|
||||||
"markdown-it-mathjax3": "^4.3.2",
|
"markdown-it-mathjax3": "catalog:",
|
||||||
"markdown-it-toc-done-right": "^4.2.0",
|
"markdown-it-toc-done-right": "catalog:",
|
||||||
"mime-types": "^3.0.1",
|
"mime-types": "catalog:",
|
||||||
"mitata": "^1.0.34",
|
"mitata": "catalog:",
|
||||||
"oauth4webapi": "^3.5.2",
|
"oauth4webapi": "catalog:",
|
||||||
"ora": "^8.2.0",
|
"ora": "catalog:",
|
||||||
"qs": "^6.14.0",
|
"qs": "catalog:",
|
||||||
"sharp": "^0.34.2",
|
"sharp": "catalog:",
|
||||||
"sonic-channel": "^1.3.1",
|
"sonic-channel": "catalog:",
|
||||||
"string-comparison": "^1.3.0",
|
"string-comparison": "catalog:",
|
||||||
"stringify-entities": "^4.0.4",
|
"stringify-entities": "catalog:",
|
||||||
"unicode-emoji-json": "^0.8.0",
|
"unicode-emoji-json": "catalog:",
|
||||||
"uqr": "^0.1.2",
|
"uqr": "catalog:",
|
||||||
"web-push": "^3.6.7",
|
"web-push": "catalog:",
|
||||||
"xss": "^1.0.15",
|
"xss": "catalog:",
|
||||||
"youch": "^4.1.0-beta.7",
|
"youch": "catalog:",
|
||||||
"zod": "^3.25.64",
|
"zod": "catalog:",
|
||||||
"zod-openapi": "^4.2.4",
|
"zod-openapi": "catalog:",
|
||||||
"zod-validation-error": "^3.5.0"
|
"zod-validation-error": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,25 @@
|
||||||
import { resolve } from "node:path";
|
|
||||||
import { getLogger } from "@logtape/logtape";
|
|
||||||
import { Scalar } from "@scalar/hono-api-reference";
|
import { Scalar } from "@scalar/hono-api-reference";
|
||||||
import chalk from "chalk";
|
import { config } from "@versia-server/config";
|
||||||
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { serveStatic } from "hono/bun";
|
import { serveStatic } from "hono/bun";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
import { createMiddleware } from "hono/factory";
|
import { createMiddleware } from "hono/factory";
|
||||||
import { prettyJSON } from "hono/pretty-json";
|
import { prettyJSON } from "hono/pretty-json";
|
||||||
import { secureHeaders } from "hono/secure-headers";
|
import { secureHeaders } from "hono/secure-headers";
|
||||||
import { openAPISpecs } from "hono-openapi";
|
import { generateSpecs } from "hono-openapi";
|
||||||
import { Youch } from "youch";
|
import { Youch } from "youch";
|
||||||
import { applyToHono } from "@/bull-board.ts";
|
import { applyToHono } from "@/bull-board.ts";
|
||||||
import { configureLoggers } from "@/loggers";
|
import pkg from "../../package.json" with { type: "application/json" };
|
||||||
import { sentry } from "@/sentry";
|
import type { ApiRouteExports, HonoEnv } from "../../types/api.ts";
|
||||||
import { config } from "~/config.ts";
|
|
||||||
import pkg from "~/package.json" with { type: "application/json" };
|
|
||||||
import { ApiError } from "./classes/errors/api-error.ts";
|
|
||||||
import { PluginLoader } from "./classes/plugin/loader.ts";
|
|
||||||
import { agentBans } from "./middlewares/agent-bans.ts";
|
import { agentBans } from "./middlewares/agent-bans.ts";
|
||||||
import { boundaryCheck } from "./middlewares/boundary-check.ts";
|
import { boundaryCheck } from "./middlewares/boundary-check.ts";
|
||||||
import { ipBans } from "./middlewares/ip-bans.ts";
|
import { ipBans } from "./middlewares/ip-bans.ts";
|
||||||
import { logger } from "./middlewares/logger.ts";
|
import { logger } from "./middlewares/logger.ts";
|
||||||
import { rateLimit } from "./middlewares/rate-limit.ts";
|
import { rateLimit } from "./middlewares/rate-limit.ts";
|
||||||
import { routes } from "./routes.ts";
|
import { routes } from "./routes.ts";
|
||||||
import type { ApiRouteExports, HonoEnv } from "./types/api.ts";
|
|
||||||
// Extends Zod with OpenAPI schema generation
|
|
||||||
import "zod-openapi/extend";
|
|
||||||
|
|
||||||
export const appFactory = async (): Promise<Hono<HonoEnv>> => {
|
export const appFactory = async (): Promise<Hono<HonoEnv>> => {
|
||||||
await configureLoggers();
|
|
||||||
const serverLogger = getLogger("server");
|
|
||||||
|
|
||||||
const app = new Hono<HonoEnv>({
|
const app = new Hono<HonoEnv>({
|
||||||
strict: false,
|
strict: false,
|
||||||
});
|
});
|
||||||
|
|
@ -111,43 +100,23 @@ export const appFactory = async (): Promise<Hono<HonoEnv>> => {
|
||||||
route.default(app);
|
route.default(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
serverLogger.info`Loading plugins`;
|
const openApiSpecs = await generateSpecs(app, {
|
||||||
|
documentation: {
|
||||||
const time1 = performance.now();
|
info: {
|
||||||
|
title: "Versia Server API",
|
||||||
const loader = new PluginLoader();
|
version: pkg.version,
|
||||||
|
license: {
|
||||||
const plugins = await loader.loadPlugins(
|
name: "AGPL-3.0",
|
||||||
resolve("./plugins"),
|
url: "https://www.gnu.org/licenses/agpl-3.0.html",
|
||||||
config.plugins?.autoload ?? true,
|
|
||||||
config.plugins?.overrides.enabled,
|
|
||||||
config.plugins?.overrides.disabled,
|
|
||||||
);
|
|
||||||
|
|
||||||
await PluginLoader.addToApp(plugins, app, serverLogger);
|
|
||||||
|
|
||||||
const time2 = performance.now();
|
|
||||||
|
|
||||||
serverLogger.info`Plugins loaded in ${`${chalk.gray(
|
|
||||||
(time2 - time1).toFixed(2),
|
|
||||||
)}ms`}`;
|
|
||||||
|
|
||||||
app.get(
|
|
||||||
"/openapi.json",
|
|
||||||
openAPISpecs(app, {
|
|
||||||
documentation: {
|
|
||||||
info: {
|
|
||||||
title: "Versia Server API",
|
|
||||||
version: pkg.version,
|
|
||||||
license: {
|
|
||||||
name: "AGPL-3.0",
|
|
||||||
url: "https://www.gnu.org/licenses/agpl-3.0.html",
|
|
||||||
},
|
|
||||||
contact: pkg.author,
|
|
||||||
},
|
},
|
||||||
|
contact: pkg.author,
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
|
app.get("/openapi.json", (context) => {
|
||||||
|
return context.json(openApiSpecs, 200);
|
||||||
|
});
|
||||||
|
|
||||||
app.get(
|
app.get(
|
||||||
"/docs",
|
"/docs",
|
||||||
|
|
@ -193,7 +162,6 @@ export const appFactory = async (): Promise<Hono<HonoEnv>> => {
|
||||||
const youch = new Youch();
|
const youch = new Youch();
|
||||||
console.error(await youch.toANSI(error));
|
console.error(await youch.toANSI(error));
|
||||||
|
|
||||||
sentry?.captureException(error);
|
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
error: "A server error occured",
|
error: "A server error occured",
|
||||||
38
packages/api/build.ts
Normal file
38
packages/api/build.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { $, build } from "bun";
|
||||||
|
import manifest from "./package.json" with { type: "json" };
|
||||||
|
import { routes } from "./routes.ts";
|
||||||
|
|
||||||
|
console.log("Building...");
|
||||||
|
|
||||||
|
await $`rm -rf dist && mkdir dist`;
|
||||||
|
|
||||||
|
await build({
|
||||||
|
entrypoints: [
|
||||||
|
...Object.values(manifest.exports).map((entry) => entry.import),
|
||||||
|
// Force Bun to include endpoints
|
||||||
|
...Object.values(routes),
|
||||||
|
],
|
||||||
|
outdir: "dist",
|
||||||
|
target: "bun",
|
||||||
|
splitting: true,
|
||||||
|
minify: true,
|
||||||
|
external: [
|
||||||
|
...Object.keys(manifest.dependencies).filter((dep) =>
|
||||||
|
dep.startsWith("@versia"),
|
||||||
|
),
|
||||||
|
"@bull-board/ui",
|
||||||
|
// Excluded because Standard Schema imports those, but the code is never executed
|
||||||
|
"@valibot/to-json-schema",
|
||||||
|
"effect",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Copying files...");
|
||||||
|
|
||||||
|
await $`mkdir -p dist/node_modules`;
|
||||||
|
|
||||||
|
// Copy bull-board to dist
|
||||||
|
await $`mkdir -p dist/node_modules/@bull-board`;
|
||||||
|
await $`cp -rL ../../node_modules/@bull-board/ui dist/node_modules/@bull-board/ui`;
|
||||||
|
|
||||||
|
console.log("Build complete!");
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
|
import { config } from "@versia-server/config";
|
||||||
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { createMiddleware } from "hono/factory";
|
import { createMiddleware } from "hono/factory";
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
|
||||||
import { config } from "~/config.ts";
|
|
||||||
|
|
||||||
export const agentBans = createMiddleware(async (context, next) => {
|
export const agentBans = createMiddleware(async (context, next) => {
|
||||||
// Check for banned user agents (regex)
|
// Check for banned user agents (regex)
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { createMiddleware } from "hono/factory";
|
import { createMiddleware } from "hono/factory";
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
|
||||||
|
|
||||||
export const boundaryCheck = createMiddleware(async (context, next) => {
|
export const boundaryCheck = createMiddleware(async (context, next) => {
|
||||||
// Checks that FormData boundary is present
|
// Checks that FormData boundary is present
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import { getLogger } from "@logtape/logtape";
|
import { config } from "@versia-server/config";
|
||||||
|
import { ApiError } from "@versia-server/kit";
|
||||||
|
import { serverLogger } from "@versia-server/logging";
|
||||||
import type { SocketAddress } from "bun";
|
import type { SocketAddress } from "bun";
|
||||||
import { createMiddleware } from "hono/factory";
|
import { createMiddleware } from "hono/factory";
|
||||||
import { matches } from "ip-matching";
|
import { matches } from "ip-matching";
|
||||||
import { sentry } from "@/sentry";
|
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
|
||||||
import { config } from "~/config.ts";
|
|
||||||
|
|
||||||
export const ipBans = createMiddleware(async (context, next) => {
|
export const ipBans = createMiddleware(async (context, next) => {
|
||||||
// Check for banned IPs
|
// Check for banned IPs
|
||||||
|
|
@ -22,11 +21,8 @@ export const ipBans = createMiddleware(async (context, next) => {
|
||||||
throw new ApiError(403, "Forbidden");
|
throw new ApiError(403, "Forbidden");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const logger = getLogger("server");
|
serverLogger.error`Error while parsing banned IP "${ip}" `;
|
||||||
|
serverLogger.error`${e}`;
|
||||||
logger.error`Error while parsing banned IP "${ip}" `;
|
|
||||||
logger.error`${e}`;
|
|
||||||
sentry?.captureException(e);
|
|
||||||
|
|
||||||
return context.json(
|
return context.json(
|
||||||
{ error: `A server error occured: ${(e as Error).message}` },
|
{ error: `A server error occured: ${(e as Error).message}` },
|
||||||
26
packages/api/middlewares/logger.ts
Normal file
26
packages/api/middlewares/logger.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { serverLogger } from "@versia-server/logging";
|
||||||
|
import { SHA256 } from "bun";
|
||||||
|
import chalk from "chalk";
|
||||||
|
import { createMiddleware } from "hono/factory";
|
||||||
|
|
||||||
|
export const logger = createMiddleware(async (context, next) => {
|
||||||
|
const body = await context.req.raw.clone().text();
|
||||||
|
|
||||||
|
const urlAndMethod = `${chalk.green(context.req.method)} ${chalk.blue(context.req.url)}`;
|
||||||
|
|
||||||
|
const hash = `${chalk.bold("Hash")}: ${chalk.yellow(
|
||||||
|
new SHA256().update(body).digest("hex"),
|
||||||
|
)}`;
|
||||||
|
|
||||||
|
const headers = `${chalk.bold("Headers")}:\n${Array.from(
|
||||||
|
context.req.raw.headers.entries(),
|
||||||
|
)
|
||||||
|
.map(([key, value]) => ` - ${chalk.cyan(key)}: ${chalk.white(value)}`)
|
||||||
|
.join("\n")}`;
|
||||||
|
|
||||||
|
const bodyLog = `${chalk.bold("Body")}: ${chalk.gray(body)}`;
|
||||||
|
|
||||||
|
serverLogger.debug`${urlAndMethod}\n${hash}\n${headers}\n${bodyLog}`;
|
||||||
|
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
|
import type { ApiError } from "@versia-server/kit";
|
||||||
import { env } from "bun";
|
import { env } from "bun";
|
||||||
import type { MiddlewareHandler } from "hono";
|
import type { MiddlewareHandler } from "hono";
|
||||||
import { rateLimiter } from "hono-rate-limiter";
|
import { rateLimiter } from "hono-rate-limiter";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod/v4";
|
||||||
import type { ApiError } from "~/classes/errors/api-error";
|
|
||||||
import type { HonoEnv } from "~/types/api";
|
import type { HonoEnv } from "~/types/api";
|
||||||
|
|
||||||
// Not exported by hono-rate-limiter
|
// Not exported by hono-rate-limiter
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
|
import { config } from "@versia-server/config";
|
||||||
import { createMiddleware } from "hono/factory";
|
import { createMiddleware } from "hono/factory";
|
||||||
import { config } from "~/config.ts";
|
|
||||||
|
|
||||||
export const urlCheck = createMiddleware(async (context, next) => {
|
export const urlCheck = createMiddleware(async (context, next) => {
|
||||||
// Check that request URL matches base_url
|
// Check that request URL matches base_url
|
||||||
81
packages/api/package.json
Normal file
81
packages/api/package.json
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
{
|
||||||
|
"name": "@versia-server/api",
|
||||||
|
"module": "index.ts",
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.9.0-alpha.0",
|
||||||
|
"description": "Powerful, configurable and modular federated server using the Versia Protocol.",
|
||||||
|
"homepage": "https://versia.pub",
|
||||||
|
"author": {
|
||||||
|
"email": "contact@cpluspatch.com",
|
||||||
|
"name": "Jesse Wierzbinski",
|
||||||
|
"url": "https://cpluspatch.com"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/versia-pub/server/issues"
|
||||||
|
},
|
||||||
|
"icon": "https://cdn.versia.pub/branding/icon.svg",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"keywords": [
|
||||||
|
"federated",
|
||||||
|
"activitypub",
|
||||||
|
"bun"
|
||||||
|
],
|
||||||
|
"maintainers": [
|
||||||
|
{
|
||||||
|
"email": "contact@cpluspatch.com",
|
||||||
|
"name": "Jesse Wierzbinski",
|
||||||
|
"url": "https://cpluspatch.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/versia-pub/server.git",
|
||||||
|
"directory": "packages/api"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun run --hot index.ts",
|
||||||
|
"build": "bun run build.ts",
|
||||||
|
"docs:dev": "vitepress dev docs",
|
||||||
|
"docs:build": "vitepress build docs",
|
||||||
|
"docs:preview": "vitepress preview docs"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./app.ts"
|
||||||
|
},
|
||||||
|
"./setup": {
|
||||||
|
"import": "./setup.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@versia-server/config": "workspace:*",
|
||||||
|
"@versia-server/tests": "workspace:*",
|
||||||
|
"@versia-server/kit": "workspace:*",
|
||||||
|
"@versia-server/logging": "workspace:*",
|
||||||
|
"@versia/client": "workspace:*",
|
||||||
|
"@versia/sdk": "workspace:*",
|
||||||
|
"youch": "catalog:",
|
||||||
|
"hono": "catalog:",
|
||||||
|
"hono-openapi": "catalog:",
|
||||||
|
"zod": "catalog:",
|
||||||
|
"drizzle-orm": "catalog:",
|
||||||
|
"string-comparison": "catalog:",
|
||||||
|
"bun-bagel": "catalog:",
|
||||||
|
"chalk": "catalog:",
|
||||||
|
"unicode-emoji-json": "catalog:",
|
||||||
|
"sharp": "catalog:",
|
||||||
|
"iso-639-1": "catalog:",
|
||||||
|
"jose": "catalog:",
|
||||||
|
"zod-openapi": "catalog:",
|
||||||
|
"@scalar/hono-api-reference": "catalog:",
|
||||||
|
"hono-rate-limiter": "catalog:",
|
||||||
|
"ip-matching": "catalog:",
|
||||||
|
"qs": "catalog:",
|
||||||
|
"altcha-lib": "catalog:",
|
||||||
|
"@hono/standard-validator": "catalog:",
|
||||||
|
"zod-validation-error": "catalog:",
|
||||||
|
"confbox": "catalog:",
|
||||||
|
"oauth4webapi": "catalog:"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { type Application, db } from "@versia/kit/db";
|
import { type Application, db } from "@versia-server/kit/db";
|
||||||
import { eq, type InferSelectModel, type SQL } from "@versia/kit/drizzle";
|
import type { OpenIdLoginFlows } from "@versia-server/kit/tables";
|
||||||
import type { OpenIdLoginFlows } from "@versia/kit/tables";
|
import { eq, type InferSelectModel, type SQL } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
type AuthorizationResponseError,
|
type AuthorizationResponseError,
|
||||||
type AuthorizationServer,
|
type AuthorizationServer,
|
||||||
|
|
@ -165,7 +165,7 @@ export const automaticOidcFlow = async (
|
||||||
|
|
||||||
const authServer = await getAuthServer(issuerUrl);
|
const authServer = await getAuthServer(issuerUrl);
|
||||||
|
|
||||||
const parameters = await getParameters(
|
const parameters = getParameters(
|
||||||
authServer,
|
authServer,
|
||||||
issuer.client_id,
|
issuer.client_id,
|
||||||
currentUrl,
|
currentUrl,
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
import { join } from "node:path";
|
||||||
import { FileSystemRouter } from "bun";
|
import { FileSystemRouter } from "bun";
|
||||||
|
|
||||||
// Returns the route filesystem path when given a URL
|
// Returns the route filesystem path when given a URL
|
||||||
export const routeMatcher = new FileSystemRouter({
|
export const routeMatcher = new FileSystemRouter({
|
||||||
style: "nextjs",
|
style: "nextjs",
|
||||||
dir: "api",
|
dir: join(import.meta.dir, "routes"),
|
||||||
fileExtensions: [".ts", ".js"],
|
fileExtensions: [".ts", ".js"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { afterAll, describe, expect, test } from "bun:test";
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
import { Application } from "@versia/kit/db";
|
import { config } from "@versia-server/config";
|
||||||
|
import { Application } from "@versia-server/kit/db";
|
||||||
|
import { fakeRequest, getTestUsers } from "@versia-server/tests";
|
||||||
import { randomUUIDv7 } from "bun";
|
import { randomUUIDv7 } from "bun";
|
||||||
import { randomString } from "@/math";
|
import { randomString } from "@/math";
|
||||||
import { config } from "~/config.ts";
|
|
||||||
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
|
||||||
|
|
||||||
const { users, deleteUsers, passwords } = await getTestUsers(1);
|
const { users, deleteUsers, passwords } = await getTestUsers(1);
|
||||||
|
|
||||||
|
|
@ -1,16 +1,15 @@
|
||||||
import { Application, User } from "@versia/kit/db";
|
import { config } from "@versia-server/config";
|
||||||
import { Users } from "@versia/kit/tables";
|
import { ApiError } from "@versia-server/kit";
|
||||||
|
import { apiRoute, handleZodError } from "@versia-server/kit/api";
|
||||||
|
import { Application, User } from "@versia-server/kit/db";
|
||||||
|
import { Users } from "@versia-server/kit/tables";
|
||||||
import { password as bunPassword } from "bun";
|
import { password as bunPassword } from "bun";
|
||||||
import { eq, or } from "drizzle-orm";
|
import { eq, or } from "drizzle-orm";
|
||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import { setCookie } from "hono/cookie";
|
import { setCookie } from "hono/cookie";
|
||||||
import { describeRoute } from "hono-openapi";
|
import { describeRoute, validator } from "hono-openapi";
|
||||||
import { validator } from "hono-openapi/zod";
|
|
||||||
import { SignJWT } from "jose";
|
import { SignJWT } from "jose";
|
||||||
import { z } from "zod";
|
import { z } from "zod/v4";
|
||||||
import { apiRoute, handleZodError } from "@/api";
|
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
|
||||||
import { config } from "~/config.ts";
|
|
||||||
|
|
||||||
const returnError = (
|
const returnError = (
|
||||||
context: Context,
|
context: Context,
|
||||||
|
|
@ -59,7 +58,7 @@ export default apiRoute((app) =>
|
||||||
"query",
|
"query",
|
||||||
z.object({
|
z.object({
|
||||||
scope: z.string().optional(),
|
scope: z.string().optional(),
|
||||||
redirect_uri: z.string().url().optional(),
|
redirect_uri: z.url().optional(),
|
||||||
response_type: z.enum([
|
response_type: z.enum([
|
||||||
"code",
|
"code",
|
||||||
"token",
|
"token",
|
||||||
|
|
@ -90,7 +89,6 @@ export default apiRoute((app) =>
|
||||||
"form",
|
"form",
|
||||||
z.object({
|
z.object({
|
||||||
identifier: z
|
identifier: z
|
||||||
.string()
|
|
||||||
.email()
|
.email()
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.or(z.string().toLowerCase()),
|
.or(z.string().toLowerCase()),
|
||||||
|
|
@ -99,30 +97,7 @@ export default apiRoute((app) =>
|
||||||
handleZodError,
|
handleZodError,
|
||||||
),
|
),
|
||||||
async (context) => {
|
async (context) => {
|
||||||
const oidcConfig = config.plugins?.config?.["@versia/openid"] as
|
if (config.authentication.forced_openid) {
|
||||||
| {
|
|
||||||
forced: boolean;
|
|
||||||
providers: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
icon: string;
|
|
||||||
}[];
|
|
||||||
keys: {
|
|
||||||
private: string;
|
|
||||||
public: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
if (!oidcConfig) {
|
|
||||||
return returnError(
|
|
||||||
context,
|
|
||||||
"invalid_request",
|
|
||||||
"The OpenID Connect plugin is not enabled on this instance. Cannot process login request.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (oidcConfig?.forced) {
|
|
||||||
return returnError(
|
return returnError(
|
||||||
context,
|
context,
|
||||||
"invalid_request",
|
"invalid_request",
|
||||||
|
|
@ -168,15 +143,6 @@ export default apiRoute((app) =>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try and import the key
|
|
||||||
const privateKey = await crypto.subtle.importKey(
|
|
||||||
"pkcs8",
|
|
||||||
Buffer.from(oidcConfig?.keys?.private ?? "", "base64"),
|
|
||||||
"Ed25519",
|
|
||||||
false,
|
|
||||||
["sign"],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Generate JWT
|
// Generate JWT
|
||||||
const jwt = await new SignJWT({
|
const jwt = await new SignJWT({
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
|
|
@ -187,7 +153,7 @@ export default apiRoute((app) =>
|
||||||
nbf: Math.floor(Date.now() / 1000),
|
nbf: Math.floor(Date.now() / 1000),
|
||||||
})
|
})
|
||||||
.setProtectedHeader({ alg: "EdDSA" })
|
.setProtectedHeader({ alg: "EdDSA" })
|
||||||
.sign(privateKey);
|
.sign(config.authentication.keys.private);
|
||||||
|
|
||||||
const application = await Application.fromClientId(client_id);
|
const application = await Application.fromClientId(client_id);
|
||||||
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import { db } from "@versia/kit/db";
|
import { config } from "@versia-server/config";
|
||||||
import { Applications, Tokens } from "@versia/kit/tables";
|
import { apiRoute, handleZodError } from "@versia-server/kit/api";
|
||||||
|
import { db } from "@versia-server/kit/db";
|
||||||
|
import { Applications, Tokens } from "@versia-server/kit/tables";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { describeRoute } from "hono-openapi";
|
import { describeRoute, validator } from "hono-openapi";
|
||||||
import { validator } from "hono-openapi/zod";
|
import { z } from "zod/v4";
|
||||||
import { z } from "zod";
|
|
||||||
import { apiRoute, handleZodError } from "@/api";
|
|
||||||
import { config } from "~/config.ts";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OAuth Code flow
|
* OAuth Code flow
|
||||||
|
|
@ -28,7 +27,7 @@ export default apiRoute((app) =>
|
||||||
validator(
|
validator(
|
||||||
"query",
|
"query",
|
||||||
z.object({
|
z.object({
|
||||||
redirect_uri: z.string().url(),
|
redirect_uri: z.url(),
|
||||||
client_id: z.string(),
|
client_id: z.string(),
|
||||||
code: z.string(),
|
code: z.string(),
|
||||||
}),
|
}),
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { afterAll, describe, expect, test } from "bun:test";
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
import { Application } from "@versia/kit/db";
|
import { config } from "@versia-server/config";
|
||||||
|
import { Application } from "@versia-server/kit/db";
|
||||||
|
import { fakeRequest, getTestUsers } from "@versia-server/tests";
|
||||||
import { randomUUIDv7 } from "bun";
|
import { randomUUIDv7 } from "bun";
|
||||||
import { randomString } from "@/math";
|
import { randomString } from "@/math";
|
||||||
import { config } from "~/config.ts";
|
|
||||||
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
|
||||||
|
|
||||||
const { users, deleteUsers, passwords } = await getTestUsers(1);
|
const { users, deleteUsers, passwords } = await getTestUsers(1);
|
||||||
const token = randomString(32, "hex");
|
const token = randomString(32, "hex");
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
import { User } from "@versia/kit/db";
|
import { config } from "@versia-server/config";
|
||||||
import { Users } from "@versia/kit/tables";
|
import { apiRoute, handleZodError } from "@versia-server/kit/api";
|
||||||
|
import { User } from "@versia-server/kit/db";
|
||||||
|
import { Users } from "@versia-server/kit/tables";
|
||||||
import { password as bunPassword } from "bun";
|
import { password as bunPassword } from "bun";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import { describeRoute } from "hono-openapi";
|
import { describeRoute, validator } from "hono-openapi";
|
||||||
import { validator } from "hono-openapi/zod";
|
import { z } from "zod/v4";
|
||||||
import { z } from "zod";
|
|
||||||
import { apiRoute, handleZodError } from "@/api";
|
|
||||||
import { config } from "~/config.ts";
|
|
||||||
|
|
||||||
const returnError = (
|
const returnError = (
|
||||||
context: Context,
|
context: Context,
|
||||||
|
|
@ -1,23 +1,13 @@
|
||||||
import { afterAll, describe, expect, test } from "bun:test";
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
import { RolePermission } from "@versia/client/schemas";
|
import { RolePermission } from "@versia/client/schemas";
|
||||||
import { Application } from "@versia/kit/db";
|
import { config } from "@versia-server/config";
|
||||||
|
import { Application } from "@versia-server/kit/db";
|
||||||
|
import { fakeRequest, getTestUsers } from "@versia-server/tests";
|
||||||
import { randomUUIDv7 } from "bun";
|
import { randomUUIDv7 } from "bun";
|
||||||
import { SignJWT } from "jose";
|
import { SignJWT } from "jose";
|
||||||
import { randomString } from "@/math";
|
import { randomString } from "@/math";
|
||||||
import { config } from "~/config.ts";
|
|
||||||
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
|
||||||
|
|
||||||
const { deleteUsers, tokens, users } = await getTestUsers(1);
|
const { deleteUsers, tokens, users } = await getTestUsers(1);
|
||||||
const privateKey = await crypto.subtle.importKey(
|
|
||||||
"pkcs8",
|
|
||||||
Buffer.from(
|
|
||||||
config.plugins?.config?.["@versia/openid"].keys.private,
|
|
||||||
"base64",
|
|
||||||
),
|
|
||||||
"Ed25519",
|
|
||||||
false,
|
|
||||||
["sign"],
|
|
||||||
);
|
|
||||||
|
|
||||||
const application = await Application.insert({
|
const application = await Application.insert({
|
||||||
id: randomUUIDv7(),
|
id: randomUUIDv7(),
|
||||||
|
|
@ -44,7 +34,7 @@ describe("/oauth/authorize", () => {
|
||||||
nbf: Math.floor(Date.now() / 1000),
|
nbf: Math.floor(Date.now() / 1000),
|
||||||
})
|
})
|
||||||
.setProtectedHeader({ alg: "EdDSA" })
|
.setProtectedHeader({ alg: "EdDSA" })
|
||||||
.sign(privateKey);
|
.sign(config.authentication.keys.private);
|
||||||
|
|
||||||
const response = await fakeRequest("/oauth/authorize", {
|
const response = await fakeRequest("/oauth/authorize", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -115,7 +105,7 @@ describe("/oauth/authorize", () => {
|
||||||
aud: application.data.clientId,
|
aud: application.data.clientId,
|
||||||
})
|
})
|
||||||
.setProtectedHeader({ alg: "EdDSA" })
|
.setProtectedHeader({ alg: "EdDSA" })
|
||||||
.sign(privateKey);
|
.sign(config.authentication.keys.private);
|
||||||
|
|
||||||
const response = await fakeRequest("/oauth/authorize", {
|
const response = await fakeRequest("/oauth/authorize", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -157,7 +147,7 @@ describe("/oauth/authorize", () => {
|
||||||
nbf: Math.floor(Date.now() / 1000),
|
nbf: Math.floor(Date.now() / 1000),
|
||||||
})
|
})
|
||||||
.setProtectedHeader({ alg: "EdDSA" })
|
.setProtectedHeader({ alg: "EdDSA" })
|
||||||
.sign(privateKey);
|
.sign(config.authentication.keys.private);
|
||||||
|
|
||||||
const response = await fakeRequest("/oauth/authorize", {
|
const response = await fakeRequest("/oauth/authorize", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -197,7 +187,7 @@ describe("/oauth/authorize", () => {
|
||||||
nbf: Math.floor(Date.now() / 1000),
|
nbf: Math.floor(Date.now() / 1000),
|
||||||
})
|
})
|
||||||
.setProtectedHeader({ alg: "EdDSA" })
|
.setProtectedHeader({ alg: "EdDSA" })
|
||||||
.sign(privateKey);
|
.sign(config.authentication.keys.private);
|
||||||
|
|
||||||
const response2 = await fakeRequest("/oauth/authorize", {
|
const response2 = await fakeRequest("/oauth/authorize", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -242,7 +232,7 @@ describe("/oauth/authorize", () => {
|
||||||
nbf: Math.floor(Date.now() / 1000),
|
nbf: Math.floor(Date.now() / 1000),
|
||||||
})
|
})
|
||||||
.setProtectedHeader({ alg: "EdDSA" })
|
.setProtectedHeader({ alg: "EdDSA" })
|
||||||
.sign(privateKey);
|
.sign(config.authentication.keys.private);
|
||||||
|
|
||||||
const response = await fakeRequest("/oauth/authorize", {
|
const response = await fakeRequest("/oauth/authorize", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -286,7 +276,7 @@ describe("/oauth/authorize", () => {
|
||||||
nbf: Math.floor(Date.now() / 1000),
|
nbf: Math.floor(Date.now() / 1000),
|
||||||
})
|
})
|
||||||
.setProtectedHeader({ alg: "EdDSA" })
|
.setProtectedHeader({ alg: "EdDSA" })
|
||||||
.sign(privateKey);
|
.sign(config.authentication.keys.private);
|
||||||
|
|
||||||
const response = await fakeRequest("/oauth/authorize", {
|
const response = await fakeRequest("/oauth/authorize", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -328,7 +318,7 @@ describe("/oauth/authorize", () => {
|
||||||
nbf: Math.floor(Date.now() / 1000),
|
nbf: Math.floor(Date.now() / 1000),
|
||||||
})
|
})
|
||||||
.setProtectedHeader({ alg: "EdDSA" })
|
.setProtectedHeader({ alg: "EdDSA" })
|
||||||
.sign(privateKey);
|
.sign(config.authentication.keys.private);
|
||||||
|
|
||||||
const response = await fakeRequest("/oauth/authorize", {
|
const response = await fakeRequest("/oauth/authorize", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -370,7 +360,7 @@ describe("/oauth/authorize", () => {
|
||||||
nbf: Math.floor(Date.now() / 1000),
|
nbf: Math.floor(Date.now() / 1000),
|
||||||
})
|
})
|
||||||
.setProtectedHeader({ alg: "EdDSA" })
|
.setProtectedHeader({ alg: "EdDSA" })
|
||||||
.sign(privateKey);
|
.sign(config.authentication.keys.private);
|
||||||
|
|
||||||
const response = await fakeRequest("/oauth/authorize", {
|
const response = await fakeRequest("/oauth/authorize", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
277
packages/api/routes/api/oauth/authorize.ts
Normal file
277
packages/api/routes/api/oauth/authorize.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
import { RolePermission } from "@versia/client/schemas";
|
||||||
|
import { config } from "@versia-server/config";
|
||||||
|
import {
|
||||||
|
apiRoute,
|
||||||
|
auth,
|
||||||
|
handleZodError,
|
||||||
|
jsonOrForm,
|
||||||
|
} from "@versia-server/kit/api";
|
||||||
|
import { Application, Token, User } from "@versia-server/kit/db";
|
||||||
|
import { randomUUIDv7 } from "bun";
|
||||||
|
import { describeRoute, validator } from "hono-openapi";
|
||||||
|
import { type JWTPayload, jwtVerify, SignJWT } from "jose";
|
||||||
|
import { JOSEError } from "jose/errors";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
import { randomString } from "@/math";
|
||||||
|
import { errorRedirect, errors } from "../../../plugins/openid/errors.ts";
|
||||||
|
|
||||||
|
export default apiRoute((app) =>
|
||||||
|
app.post(
|
||||||
|
"/oauth/authorize",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Main OpenID authorization endpoint",
|
||||||
|
tags: ["OpenID"],
|
||||||
|
responses: {
|
||||||
|
302: {
|
||||||
|
description: "Redirect to the application",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
auth({
|
||||||
|
auth: false,
|
||||||
|
}),
|
||||||
|
jsonOrForm(),
|
||||||
|
validator(
|
||||||
|
"query",
|
||||||
|
z.object({
|
||||||
|
prompt: z
|
||||||
|
.enum(["none", "login", "consent", "select_account"])
|
||||||
|
.optional()
|
||||||
|
.default("none"),
|
||||||
|
max_age: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.optional()
|
||||||
|
.default(60 * 60 * 24 * 7),
|
||||||
|
}),
|
||||||
|
handleZodError,
|
||||||
|
),
|
||||||
|
validator(
|
||||||
|
"json",
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
scope: z.string().optional(),
|
||||||
|
redirect_uri: z
|
||||||
|
.url()
|
||||||
|
.optional()
|
||||||
|
.or(z.literal("urn:ietf:wg:oauth:2.0:oob")),
|
||||||
|
response_type: z.enum([
|
||||||
|
"code",
|
||||||
|
"token",
|
||||||
|
"none",
|
||||||
|
"id_token",
|
||||||
|
"code id_token",
|
||||||
|
"code token",
|
||||||
|
"token id_token",
|
||||||
|
"code token id_token",
|
||||||
|
]),
|
||||||
|
client_id: z.string(),
|
||||||
|
state: z.string().optional(),
|
||||||
|
code_challenge: z.string().optional(),
|
||||||
|
code_challenge_method: z.enum(["plain", "S256"]).optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
// Check if redirect_uri is valid for code flow
|
||||||
|
(data) =>
|
||||||
|
data.response_type.includes("code")
|
||||||
|
? data.redirect_uri
|
||||||
|
: true,
|
||||||
|
"redirect_uri is required for code flow",
|
||||||
|
),
|
||||||
|
// Disable for Mastodon API compatibility
|
||||||
|
/* .refine(
|
||||||
|
// Check if code_challenge is valid for code flow
|
||||||
|
(data) =>
|
||||||
|
data.response_type.includes("code")
|
||||||
|
? data.code_challenge
|
||||||
|
: true,
|
||||||
|
"code_challenge is required for code flow",
|
||||||
|
), */
|
||||||
|
handleZodError,
|
||||||
|
),
|
||||||
|
validator(
|
||||||
|
"cookie",
|
||||||
|
z.object({
|
||||||
|
jwt: z.string(),
|
||||||
|
}),
|
||||||
|
handleZodError,
|
||||||
|
),
|
||||||
|
async (context) => {
|
||||||
|
const { scope, redirect_uri, client_id, state } =
|
||||||
|
context.req.valid("json");
|
||||||
|
|
||||||
|
const { jwt } = context.req.valid("cookie");
|
||||||
|
|
||||||
|
const errorSearchParams = new URLSearchParams(
|
||||||
|
context.req.valid("json"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await jwtVerify(
|
||||||
|
jwt,
|
||||||
|
config.authentication.keys.public,
|
||||||
|
{
|
||||||
|
algorithms: ["EdDSA"],
|
||||||
|
audience: client_id,
|
||||||
|
issuer: new URL(context.get("config").http.base_url).origin,
|
||||||
|
},
|
||||||
|
).catch((error) => {
|
||||||
|
if (error instanceof JOSEError) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return errorRedirect(
|
||||||
|
context,
|
||||||
|
errors.InvalidJWT,
|
||||||
|
errorSearchParams,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
payload: { aud, sub, exp },
|
||||||
|
} = result;
|
||||||
|
|
||||||
|
if (!(aud && sub && exp)) {
|
||||||
|
return errorRedirect(
|
||||||
|
context,
|
||||||
|
errors.MissingJWTFields,
|
||||||
|
errorSearchParams,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!z.uuid().safeParse(sub).success) {
|
||||||
|
return errorRedirect(
|
||||||
|
context,
|
||||||
|
errors.InvalidSub,
|
||||||
|
errorSearchParams,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.fromId(sub);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return errorRedirect(
|
||||||
|
context,
|
||||||
|
errors.UserNotFound,
|
||||||
|
errorSearchParams,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.hasPermission(RolePermission.OAuth)) {
|
||||||
|
return errorRedirect(
|
||||||
|
context,
|
||||||
|
errors.MissingOauthPermission,
|
||||||
|
errorSearchParams,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const application = await Application.fromClientId(client_id);
|
||||||
|
|
||||||
|
if (!application) {
|
||||||
|
return errorRedirect(
|
||||||
|
context,
|
||||||
|
errors.MissingApplication,
|
||||||
|
errorSearchParams,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (application.data.redirectUri !== redirect_uri) {
|
||||||
|
return errorRedirect(
|
||||||
|
context,
|
||||||
|
errors.InvalidRedirectUri,
|
||||||
|
errorSearchParams,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that scopes are a subset of the application's scopes
|
||||||
|
if (
|
||||||
|
scope &&
|
||||||
|
!scope
|
||||||
|
.split(" ")
|
||||||
|
.every((s) => application.data.scopes.includes(s))
|
||||||
|
) {
|
||||||
|
return errorRedirect(
|
||||||
|
context,
|
||||||
|
errors.InvalidScope,
|
||||||
|
errorSearchParams,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = randomString(256, "base64url");
|
||||||
|
|
||||||
|
let payload: JWTPayload = {};
|
||||||
|
|
||||||
|
if (scope) {
|
||||||
|
if (scope.split(" ").includes("openid")) {
|
||||||
|
payload = {
|
||||||
|
...payload,
|
||||||
|
sub: user.id,
|
||||||
|
iss: new URL(context.get("config").http.base_url)
|
||||||
|
.origin,
|
||||||
|
aud: client_id,
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 60 * 60,
|
||||||
|
iat: Math.floor(Date.now() / 1000),
|
||||||
|
nbf: Math.floor(Date.now() / 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (scope.split(" ").includes("profile")) {
|
||||||
|
payload = {
|
||||||
|
...payload,
|
||||||
|
name: user.data.displayName,
|
||||||
|
preferred_username: user.data.username,
|
||||||
|
picture: user.getAvatarUrl().href,
|
||||||
|
updated_at: new Date(user.data.updatedAt).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (scope.split(" ").includes("email")) {
|
||||||
|
payload = {
|
||||||
|
...payload,
|
||||||
|
email: user.data.email,
|
||||||
|
// TODO: Add verification system
|
||||||
|
email_verified: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const idToken = await new SignJWT(payload)
|
||||||
|
.setProtectedHeader({ alg: "EdDSA" })
|
||||||
|
.sign(config.authentication.keys.private);
|
||||||
|
|
||||||
|
await Token.insert({
|
||||||
|
id: randomUUIDv7(),
|
||||||
|
accessToken: randomString(64, "base64url"),
|
||||||
|
code,
|
||||||
|
scope: scope ?? application.data.scopes,
|
||||||
|
tokenType: "Bearer",
|
||||||
|
applicationId: application.id,
|
||||||
|
redirectUri: redirect_uri ?? application.data.redirectUri,
|
||||||
|
expiresAt: new Date(
|
||||||
|
Date.now() + 60 * 60 * 24 * 14,
|
||||||
|
).toISOString(),
|
||||||
|
idToken: ["profile", "email", "openid"].some((s) =>
|
||||||
|
scope?.split(" ").includes(s),
|
||||||
|
)
|
||||||
|
? idToken
|
||||||
|
: null,
|
||||||
|
clientId: client_id,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const redirectUri =
|
||||||
|
redirect_uri === "urn:ietf:wg:oauth:2.0:oob"
|
||||||
|
? new URL(
|
||||||
|
"/oauth/code",
|
||||||
|
context.get("config").http.base_url,
|
||||||
|
)
|
||||||
|
: new URL(redirect_uri ?? application.data.redirectUri);
|
||||||
|
|
||||||
|
redirectUri.searchParams.append("code", code);
|
||||||
|
state && redirectUri.searchParams.append("state", state);
|
||||||
|
|
||||||
|
return context.redirect(redirectUri.toString());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { afterAll, describe, expect, test } from "bun:test";
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
import { Application, Token } from "@versia/kit/db";
|
import { Application, Token } from "@versia-server/kit/db";
|
||||||
|
import { fakeRequest, getTestUsers } from "@versia-server/tests";
|
||||||
import { randomUUIDv7 } from "bun";
|
import { randomUUIDv7 } from "bun";
|
||||||
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
|
||||||
|
|
||||||
const { deleteUsers, users } = await getTestUsers(1);
|
const { deleteUsers, users } = await getTestUsers(1);
|
||||||
|
|
||||||
87
packages/api/routes/api/oauth/revoke.ts
Normal file
87
packages/api/routes/api/oauth/revoke.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { apiRoute, handleZodError, jsonOrForm } from "@versia-server/kit/api";
|
||||||
|
import { db, Token } from "@versia-server/kit/db";
|
||||||
|
import { Tokens } from "@versia-server/kit/tables";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
export default apiRoute((app) => {
|
||||||
|
app.post(
|
||||||
|
"/oauth/revoke",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Revoke token",
|
||||||
|
tags: ["OpenID"],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Token deleted",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(z.object({})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
description: "Authorization error",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(
|
||||||
|
z.object({
|
||||||
|
error: z.string(),
|
||||||
|
error_description: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
jsonOrForm(),
|
||||||
|
validator(
|
||||||
|
"json",
|
||||||
|
z.object({
|
||||||
|
client_id: z.string(),
|
||||||
|
client_secret: z.string(),
|
||||||
|
token: z.string().optional(),
|
||||||
|
}),
|
||||||
|
handleZodError,
|
||||||
|
),
|
||||||
|
async (context) => {
|
||||||
|
const { client_id, client_secret, token } =
|
||||||
|
context.req.valid("json");
|
||||||
|
|
||||||
|
const foundToken = await Token.fromSql(
|
||||||
|
and(
|
||||||
|
eq(Tokens.accessToken, token ?? ""),
|
||||||
|
eq(Tokens.clientId, client_id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!(foundToken && token)) {
|
||||||
|
return context.json(
|
||||||
|
{
|
||||||
|
error: "unauthorized_client",
|
||||||
|
error_description:
|
||||||
|
"You are not authorized to revoke this token",
|
||||||
|
},
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the client secret is correct
|
||||||
|
if (foundToken.data.application?.secret !== client_secret) {
|
||||||
|
return context.json(
|
||||||
|
{
|
||||||
|
error: "unauthorized_client",
|
||||||
|
error_description:
|
||||||
|
"You are not authorized to revoke this token",
|
||||||
|
},
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(Tokens).where(eq(Tokens.accessToken, token));
|
||||||
|
|
||||||
|
return context.json({}, 200);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
130
packages/api/routes/api/oauth/sso.ts
Normal file
130
packages/api/routes/api/oauth/sso.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { config } from "@versia-server/config";
|
||||||
|
import { apiRoute, handleZodError } from "@versia-server/kit/api";
|
||||||
|
import { Application, db } from "@versia-server/kit/db";
|
||||||
|
import { OpenIdLoginFlows } from "@versia-server/kit/tables";
|
||||||
|
import { randomUUIDv7 } from "bun";
|
||||||
|
import { describeRoute, validator } from "hono-openapi";
|
||||||
|
import {
|
||||||
|
calculatePKCECodeChallenge,
|
||||||
|
discoveryRequest,
|
||||||
|
generateRandomCodeVerifier,
|
||||||
|
processDiscoveryResponse,
|
||||||
|
} from "oauth4webapi";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
import { oauthRedirectUri } from "../../../plugins/openid/utils.ts";
|
||||||
|
|
||||||
|
export default apiRoute((app) => {
|
||||||
|
app.get(
|
||||||
|
"/oauth/sso",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Initiate SSO login flow",
|
||||||
|
tags: ["OpenID"],
|
||||||
|
responses: {
|
||||||
|
302: {
|
||||||
|
description:
|
||||||
|
"Redirect to SSO login, or redirect to login page with error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
"query",
|
||||||
|
z.object({
|
||||||
|
issuer: z.string(),
|
||||||
|
client_id: z.string().optional(),
|
||||||
|
redirect_uri: z.url().optional(),
|
||||||
|
scope: z.string().optional(),
|
||||||
|
response_type: z.enum(["code"]).optional(),
|
||||||
|
}),
|
||||||
|
handleZodError,
|
||||||
|
),
|
||||||
|
async (context) => {
|
||||||
|
// This is the Versia client's client_id, not the external OAuth provider's client_id
|
||||||
|
const { issuer: issuerId, client_id } = context.req.valid("query");
|
||||||
|
|
||||||
|
const errorSearchParams = new URLSearchParams(
|
||||||
|
context.req.valid("query"),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!client_id || client_id === "undefined") {
|
||||||
|
errorSearchParams.append("error", "invalid_request");
|
||||||
|
errorSearchParams.append(
|
||||||
|
"error_description",
|
||||||
|
"client_id is required",
|
||||||
|
);
|
||||||
|
|
||||||
|
return context.redirect(
|
||||||
|
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const issuer = config.authentication.openid_providers.find(
|
||||||
|
(provider) => provider.id === issuerId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!issuer) {
|
||||||
|
errorSearchParams.append("error", "invalid_request");
|
||||||
|
errorSearchParams.append(
|
||||||
|
"error_description",
|
||||||
|
"issuer is invalid",
|
||||||
|
);
|
||||||
|
|
||||||
|
return context.redirect(
|
||||||
|
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const issuerUrl = new URL(issuer.url);
|
||||||
|
|
||||||
|
const authServer = await discoveryRequest(issuerUrl, {
|
||||||
|
algorithm: "oidc",
|
||||||
|
}).then((res) => processDiscoveryResponse(issuerUrl, res));
|
||||||
|
|
||||||
|
const codeVerifier = generateRandomCodeVerifier();
|
||||||
|
|
||||||
|
const application = await Application.fromClientId(client_id);
|
||||||
|
|
||||||
|
if (!application) {
|
||||||
|
errorSearchParams.append("error", "invalid_request");
|
||||||
|
errorSearchParams.append(
|
||||||
|
"error_description",
|
||||||
|
"client_id is invalid",
|
||||||
|
);
|
||||||
|
|
||||||
|
return context.redirect(
|
||||||
|
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store into database
|
||||||
|
const newFlow = (
|
||||||
|
await db
|
||||||
|
.insert(OpenIdLoginFlows)
|
||||||
|
.values({
|
||||||
|
id: randomUUIDv7(),
|
||||||
|
codeVerifier,
|
||||||
|
applicationId: application.id,
|
||||||
|
issuerId,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
const codeChallenge =
|
||||||
|
await calculatePKCECodeChallenge(codeVerifier);
|
||||||
|
|
||||||
|
return context.redirect(
|
||||||
|
`${authServer.authorization_endpoint}?${new URLSearchParams({
|
||||||
|
client_id: issuer.client_id,
|
||||||
|
redirect_uri: `${oauthRedirectUri(
|
||||||
|
context.get("config").http.base_url,
|
||||||
|
issuerId,
|
||||||
|
)}?flow=${newFlow.id}`,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "openid profile email",
|
||||||
|
// PKCE
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
}).toString()}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
341
packages/api/routes/api/oauth/sso/[issuer]/callback.ts
Normal file
341
packages/api/routes/api/oauth/sso/[issuer]/callback.ts
Normal file
|
|
@ -0,0 +1,341 @@
|
||||||
|
import {
|
||||||
|
Account as AccountSchema,
|
||||||
|
RolePermission,
|
||||||
|
zBoolean,
|
||||||
|
} from "@versia/client/schemas";
|
||||||
|
import { config } from "@versia-server/config";
|
||||||
|
import { ApiError } from "@versia-server/kit";
|
||||||
|
import { apiRoute, handleZodError } from "@versia-server/kit/api";
|
||||||
|
import { db, Media, Token, User } from "@versia-server/kit/db";
|
||||||
|
import { searchManager } from "@versia-server/kit/search";
|
||||||
|
import { OpenIdAccounts, Users } from "@versia-server/kit/tables";
|
||||||
|
import { randomUUIDv7 } from "bun";
|
||||||
|
import { and, eq, isNull, type SQL } from "drizzle-orm";
|
||||||
|
import { setCookie } from "hono/cookie";
|
||||||
|
import { describeRoute, validator } from "hono-openapi";
|
||||||
|
import { SignJWT } from "jose";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
import { randomString } from "@/math.ts";
|
||||||
|
import { automaticOidcFlow } from "../../../../../plugins/openid/utils.ts";
|
||||||
|
|
||||||
|
export default apiRoute((app) => {
|
||||||
|
app.get(
|
||||||
|
"/oauth/sso/:issuer/callback",
|
||||||
|
describeRoute({
|
||||||
|
summary: "SSO callback",
|
||||||
|
tags: ["OpenID"],
|
||||||
|
description:
|
||||||
|
"After the user has authenticated to an external OpenID provider, they are redirected here to complete the OAuth flow and get a code",
|
||||||
|
responses: {
|
||||||
|
302: {
|
||||||
|
description:
|
||||||
|
"Redirect to frontend's consent route, or redirect to login page with error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
"param",
|
||||||
|
z.object({
|
||||||
|
issuer: z.string(),
|
||||||
|
}),
|
||||||
|
handleZodError,
|
||||||
|
),
|
||||||
|
validator(
|
||||||
|
"query",
|
||||||
|
z.object({
|
||||||
|
client_id: z.string().optional(),
|
||||||
|
flow: z.string(),
|
||||||
|
link: zBoolean.optional(),
|
||||||
|
user_id: z.uuid().optional(),
|
||||||
|
}),
|
||||||
|
handleZodError,
|
||||||
|
),
|
||||||
|
async (context) => {
|
||||||
|
const currentUrl = new URL(context.req.url);
|
||||||
|
const redirectUrl = new URL(context.req.url);
|
||||||
|
|
||||||
|
// Correct some reverse proxies incorrectly setting the protocol as http, even if the original request was https
|
||||||
|
// Looking at you, Traefik
|
||||||
|
if (
|
||||||
|
new URL(context.get("config").http.base_url).protocol ===
|
||||||
|
"https:" &&
|
||||||
|
currentUrl.protocol === "http:"
|
||||||
|
) {
|
||||||
|
currentUrl.protocol = "https:";
|
||||||
|
redirectUrl.protocol = "https:";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove state query parameter from URL
|
||||||
|
currentUrl.searchParams.delete("state");
|
||||||
|
redirectUrl.searchParams.delete("state");
|
||||||
|
// Remove issuer query parameter from URL (can cause redirect URI mismatches)
|
||||||
|
redirectUrl.searchParams.delete("iss");
|
||||||
|
redirectUrl.searchParams.delete("code");
|
||||||
|
const { issuer: issuerParam } = context.req.valid("param");
|
||||||
|
const { flow: flowId, user_id, link } = context.req.valid("query");
|
||||||
|
|
||||||
|
const issuer = config.authentication.openid_providers.find(
|
||||||
|
(provider) => provider.id === issuerParam,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!issuer) {
|
||||||
|
throw new ApiError(404, "Issuer not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const userInfo = await automaticOidcFlow(
|
||||||
|
issuer,
|
||||||
|
flowId,
|
||||||
|
currentUrl,
|
||||||
|
redirectUrl,
|
||||||
|
(error, message, flow) => {
|
||||||
|
const errorSearchParams = new URLSearchParams(
|
||||||
|
Object.entries({
|
||||||
|
redirect_uri: flow?.application?.redirectUri,
|
||||||
|
client_id: flow?.application?.clientId,
|
||||||
|
response_type: "code",
|
||||||
|
scope: flow?.application?.scopes,
|
||||||
|
}).filter(([_, value]) => value !== undefined) as [
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
][],
|
||||||
|
);
|
||||||
|
|
||||||
|
errorSearchParams.append("error", error);
|
||||||
|
errorSearchParams.append("error_description", message);
|
||||||
|
|
||||||
|
return context.redirect(
|
||||||
|
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userInfo instanceof Response) {
|
||||||
|
return userInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sub, email, preferred_username, picture } =
|
||||||
|
userInfo.userInfo;
|
||||||
|
const flow = userInfo.flow;
|
||||||
|
|
||||||
|
const errorSearchParams = new URLSearchParams(
|
||||||
|
Object.entries({
|
||||||
|
redirect_uri: flow.application?.redirectUri,
|
||||||
|
client_id: flow.application?.clientId,
|
||||||
|
response_type: "code",
|
||||||
|
scope: flow.application?.scopes,
|
||||||
|
}).filter(([_, value]) => value !== undefined) as [
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
][],
|
||||||
|
);
|
||||||
|
|
||||||
|
// If linking account
|
||||||
|
if (link && user_id) {
|
||||||
|
// Check if userId is equal to application.clientId
|
||||||
|
if (!flow.application?.clientId.startsWith(user_id)) {
|
||||||
|
return context.redirect(
|
||||||
|
`${context.get("config").http.base_url}${
|
||||||
|
context.get("config").frontend.routes.home
|
||||||
|
}?${new URLSearchParams({
|
||||||
|
oidc_account_linking_error: "Account linking error",
|
||||||
|
oidc_account_linking_error_message: `User ID does not match application client ID (${user_id} != ${flow.application?.clientId})`,
|
||||||
|
})}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if account is already linked
|
||||||
|
const account = await db.query.OpenIdAccounts.findFirst({
|
||||||
|
where: (account): SQL | undefined =>
|
||||||
|
and(
|
||||||
|
eq(account.serverId, sub),
|
||||||
|
eq(account.issuerId, issuer.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (account) {
|
||||||
|
return context.redirect(
|
||||||
|
`${context.get("config").http.base_url}${
|
||||||
|
context.get("config").frontend.routes.home
|
||||||
|
}?${new URLSearchParams({
|
||||||
|
oidc_account_linking_error:
|
||||||
|
"Account already linked",
|
||||||
|
oidc_account_linking_error_message:
|
||||||
|
"This account has already been linked to this OpenID Connect provider.",
|
||||||
|
})}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link the account
|
||||||
|
await db.insert(OpenIdAccounts).values({
|
||||||
|
id: randomUUIDv7(),
|
||||||
|
serverId: sub,
|
||||||
|
issuerId: issuer.id,
|
||||||
|
userId: user_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return context.redirect(
|
||||||
|
`${context.get("config").http.base_url}${
|
||||||
|
context.get("config").frontend.routes.home
|
||||||
|
}?${new URLSearchParams({
|
||||||
|
oidc_account_linked: "true",
|
||||||
|
})}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let userId = (
|
||||||
|
await db.query.OpenIdAccounts.findFirst({
|
||||||
|
where: (account): SQL | undefined =>
|
||||||
|
and(
|
||||||
|
eq(account.serverId, sub),
|
||||||
|
eq(account.issuerId, issuer.id),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
)?.userId;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
// Register new user
|
||||||
|
if (config.authentication.openid_registration) {
|
||||||
|
let username =
|
||||||
|
preferred_username ??
|
||||||
|
email?.split("@")[0] ??
|
||||||
|
randomString(8, "hex");
|
||||||
|
|
||||||
|
const usernameValidator =
|
||||||
|
AccountSchema.shape.username.refine(
|
||||||
|
async (value) =>
|
||||||
|
!(await User.fromSql(
|
||||||
|
and(
|
||||||
|
eq(Users.username, value),
|
||||||
|
isNull(Users.instanceId),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await usernameValidator.parseAsync(username);
|
||||||
|
} catch {
|
||||||
|
username = randomString(8, "hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
const doesEmailExist = email
|
||||||
|
? !!(await User.fromSql(eq(Users.email, email)))
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const avatar = picture
|
||||||
|
? await Media.fromUrl(new URL(picture))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Create new user
|
||||||
|
const user = await User.register(username, {
|
||||||
|
email: doesEmailExist ? undefined : email,
|
||||||
|
avatar: avatar ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add to search index
|
||||||
|
await searchManager.addUser(user);
|
||||||
|
|
||||||
|
// Link account
|
||||||
|
await db.insert(OpenIdAccounts).values({
|
||||||
|
id: randomUUIDv7(),
|
||||||
|
serverId: sub,
|
||||||
|
issuerId: issuer.id,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
userId = user.id;
|
||||||
|
} else {
|
||||||
|
errorSearchParams.append("error", "invalid_request");
|
||||||
|
errorSearchParams.append(
|
||||||
|
"error_description",
|
||||||
|
"No user found with that account",
|
||||||
|
);
|
||||||
|
|
||||||
|
return context.redirect(
|
||||||
|
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.fromId(userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
errorSearchParams.append("error", "invalid_request");
|
||||||
|
errorSearchParams.append(
|
||||||
|
"error_description",
|
||||||
|
"No user found with that account",
|
||||||
|
);
|
||||||
|
|
||||||
|
return context.redirect(
|
||||||
|
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.hasPermission(RolePermission.OAuth)) {
|
||||||
|
errorSearchParams.append("error", "invalid_request");
|
||||||
|
errorSearchParams.append(
|
||||||
|
"error_description",
|
||||||
|
`User does not have the '${RolePermission.OAuth}' permission`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return context.redirect(
|
||||||
|
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!flow.application) {
|
||||||
|
throw new ApiError(500, "Application not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = randomString(32, "hex");
|
||||||
|
|
||||||
|
await Token.insert({
|
||||||
|
id: randomUUIDv7(),
|
||||||
|
accessToken: randomString(64, "base64url"),
|
||||||
|
code,
|
||||||
|
scope: flow.application.scopes,
|
||||||
|
tokenType: "Bearer",
|
||||||
|
userId: user.id,
|
||||||
|
applicationId: flow.application.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate JWT
|
||||||
|
const jwt = await new SignJWT({
|
||||||
|
sub: user.id,
|
||||||
|
iss: new URL(context.get("config").http.base_url).origin,
|
||||||
|
aud: flow.application.clientId,
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 60 * 60,
|
||||||
|
iat: Math.floor(Date.now() / 1000),
|
||||||
|
nbf: Math.floor(Date.now() / 1000),
|
||||||
|
})
|
||||||
|
.setProtectedHeader({ alg: "EdDSA" })
|
||||||
|
.sign(config.authentication.keys.private);
|
||||||
|
|
||||||
|
// Redirect back to application
|
||||||
|
setCookie(context, "jwt", jwt, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: "strict",
|
||||||
|
path: "/",
|
||||||
|
// 2 weeks
|
||||||
|
maxAge: 60 * 60 * 24 * 14,
|
||||||
|
});
|
||||||
|
|
||||||
|
return context.redirect(
|
||||||
|
new URL(
|
||||||
|
`${context.get("config").frontend.routes.consent}?${new URLSearchParams(
|
||||||
|
{
|
||||||
|
redirect_uri: flow.application.redirectUri,
|
||||||
|
code,
|
||||||
|
client_id: flow.application.clientId,
|
||||||
|
application: flow.application.name,
|
||||||
|
website: flow.application.website ?? "",
|
||||||
|
scope: flow.application.scopes,
|
||||||
|
response_type: "code",
|
||||||
|
},
|
||||||
|
).toString()}`,
|
||||||
|
context.get("config").http.base_url,
|
||||||
|
).toString(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { afterAll, describe, expect, test } from "bun:test";
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
import { Application, Token } from "@versia/kit/db";
|
import { Application, Token } from "@versia-server/kit/db";
|
||||||
|
import { fakeRequest, getTestUsers } from "@versia-server/tests";
|
||||||
import { randomUUIDv7 } from "bun";
|
import { randomUUIDv7 } from "bun";
|
||||||
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
|
||||||
|
|
||||||
const { deleteUsers, users } = await getTestUsers(1);
|
const { deleteUsers, users } = await getTestUsers(1);
|
||||||
|
|
||||||
190
packages/api/routes/api/oauth/token.ts
Normal file
190
packages/api/routes/api/oauth/token.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
import { apiRoute, handleZodError, jsonOrForm } from "@versia-server/kit/api";
|
||||||
|
import { Application, Token } from "@versia-server/kit/db";
|
||||||
|
import { Tokens } from "@versia-server/kit/tables";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
export default apiRoute((app) => {
|
||||||
|
app.post(
|
||||||
|
"/oauth/token",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Get token",
|
||||||
|
tags: ["OpenID"],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Token",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(
|
||||||
|
z.object({
|
||||||
|
access_token: z.string(),
|
||||||
|
token_type: z.string(),
|
||||||
|
expires_in: z
|
||||||
|
.number()
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
id_token: z.string().optional().nullable(),
|
||||||
|
refresh_token: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
scope: z.string().optional(),
|
||||||
|
created_at: z.number(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
description: "Authorization error",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(
|
||||||
|
z.object({
|
||||||
|
error: z.string(),
|
||||||
|
error_description: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
jsonOrForm(),
|
||||||
|
validator(
|
||||||
|
"json",
|
||||||
|
z.object({
|
||||||
|
code: z.string().optional(),
|
||||||
|
code_verifier: z.string().optional(),
|
||||||
|
grant_type: z
|
||||||
|
.enum([
|
||||||
|
"authorization_code",
|
||||||
|
"refresh_token",
|
||||||
|
"client_credentials",
|
||||||
|
"password",
|
||||||
|
"urn:ietf:params:oauth:grant-type:device_code",
|
||||||
|
"urn:ietf:params:oauth:grant-type:token-exchange",
|
||||||
|
"urn:ietf:params:oauth:grant-type:saml2-bearer",
|
||||||
|
"urn:openid:params:grant-type:ciba",
|
||||||
|
])
|
||||||
|
.default("authorization_code"),
|
||||||
|
client_id: z.string().optional(),
|
||||||
|
client_secret: z.string().optional(),
|
||||||
|
username: z.string().trim().optional(),
|
||||||
|
password: z.string().trim().optional(),
|
||||||
|
redirect_uri: z.url().optional(),
|
||||||
|
refresh_token: z.string().optional(),
|
||||||
|
scope: z.string().optional(),
|
||||||
|
assertion: z.string().optional(),
|
||||||
|
audience: z.string().optional(),
|
||||||
|
subject_token_type: z.string().optional(),
|
||||||
|
subject_token: z.string().optional(),
|
||||||
|
actor_token_type: z.string().optional(),
|
||||||
|
actor_token: z.string().optional(),
|
||||||
|
auth_req_id: z.string().optional(),
|
||||||
|
}),
|
||||||
|
handleZodError,
|
||||||
|
),
|
||||||
|
async (context) => {
|
||||||
|
const { grant_type, code, redirect_uri, client_id, client_secret } =
|
||||||
|
context.req.valid("json");
|
||||||
|
|
||||||
|
switch (grant_type) {
|
||||||
|
case "authorization_code": {
|
||||||
|
if (!code) {
|
||||||
|
return context.json(
|
||||||
|
{
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description: "Code is required",
|
||||||
|
},
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!redirect_uri) {
|
||||||
|
return context.json(
|
||||||
|
{
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description: "Redirect URI is required",
|
||||||
|
},
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client_id) {
|
||||||
|
return context.json(
|
||||||
|
{
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description: "Client ID is required",
|
||||||
|
},
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the client_secret
|
||||||
|
const client = await Application.fromClientId(client_id);
|
||||||
|
|
||||||
|
if (!client || client.data.secret !== client_secret) {
|
||||||
|
return context.json(
|
||||||
|
{
|
||||||
|
error: "invalid_client",
|
||||||
|
error_description: "Invalid client credentials",
|
||||||
|
},
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await Token.fromSql(
|
||||||
|
and(
|
||||||
|
eq(Tokens.code, code),
|
||||||
|
eq(Tokens.redirectUri, decodeURI(redirect_uri)),
|
||||||
|
eq(Tokens.clientId, client_id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return context.json(
|
||||||
|
{
|
||||||
|
error: "invalid_grant",
|
||||||
|
error_description: "Code not found",
|
||||||
|
},
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate the code
|
||||||
|
await token.update({ code: null });
|
||||||
|
|
||||||
|
return context.json(
|
||||||
|
{
|
||||||
|
...token.toApi(),
|
||||||
|
expires_in: token.data.expiresAt
|
||||||
|
? Math.floor(
|
||||||
|
(new Date(
|
||||||
|
token.data.expiresAt,
|
||||||
|
).getTime() -
|
||||||
|
Date.now()) /
|
||||||
|
1000,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
id_token: token.data.idToken,
|
||||||
|
refresh_token: null,
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.json(
|
||||||
|
{
|
||||||
|
error: "unsupported_grant_type",
|
||||||
|
error_description: "Unsupported grant type",
|
||||||
|
},
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { afterAll, describe, expect, test } from "bun:test";
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
import { generateClient, getTestUsers } from "@versia-server/tests";
|
||||||
|
|
||||||
const { users, deleteUsers } = await getTestUsers(2);
|
const { users, deleteUsers } = await getTestUsers(2);
|
||||||
|
|
||||||
|
|
@ -2,11 +2,10 @@ import {
|
||||||
Relationship as RelationshipSchema,
|
Relationship as RelationshipSchema,
|
||||||
RolePermission,
|
RolePermission,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
import { Relationship } from "@versia/kit/db";
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { describeRoute } from "hono-openapi";
|
import { apiRoute, auth, withUserParam } from "@versia-server/kit/api";
|
||||||
import { resolver } from "hono-openapi/zod";
|
import { Relationship } from "@versia-server/kit/db";
|
||||||
import { apiRoute, auth, withUserParam } from "@/api";
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.post(
|
app.post(
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
import { RolePermission } from "@versia/client/schemas";
|
import { RolePermission } from "@versia/client/schemas";
|
||||||
import { describeRoute } from "hono-openapi";
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { resolver, validator } from "hono-openapi/zod";
|
import {
|
||||||
import { z } from "zod";
|
apiRoute,
|
||||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
auth,
|
||||||
|
handleZodError,
|
||||||
|
withUserParam,
|
||||||
|
} from "@versia-server/kit/api";
|
||||||
|
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||||
|
import { z } from "zod/v4";
|
||||||
import { getFeed } from "@/rss";
|
import { getFeed } from "@/rss";
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.get(
|
app.get(
|
||||||
|
|
@ -34,12 +38,13 @@ export default apiRoute((app) =>
|
||||||
RolePermission.ViewNotes,
|
RolePermission.ViewNotes,
|
||||||
RolePermission.ViewAccounts,
|
RolePermission.ViewAccounts,
|
||||||
],
|
],
|
||||||
|
|
||||||
scopes: ["read:statuses"],
|
scopes: ["read:statuses"],
|
||||||
}),
|
}),
|
||||||
validator(
|
validator(
|
||||||
"query",
|
"query",
|
||||||
z.object({
|
z.object({
|
||||||
page: z.coerce.number().default(0).openapi({
|
page: z.coerce.number().default(0).meta({
|
||||||
description: "Page number to fetch. Defaults to 0.",
|
description: "Page number to fetch. Defaults to 0.",
|
||||||
example: 2,
|
example: 2,
|
||||||
}),
|
}),
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
import { RolePermission } from "@versia/client/schemas";
|
import { RolePermission } from "@versia/client/schemas";
|
||||||
import { describeRoute } from "hono-openapi";
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { resolver, validator } from "hono-openapi/zod";
|
import {
|
||||||
import { z } from "zod";
|
apiRoute,
|
||||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
auth,
|
||||||
|
handleZodError,
|
||||||
|
withUserParam,
|
||||||
|
} from "@versia-server/kit/api";
|
||||||
|
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||||
|
import { z } from "zod/v4";
|
||||||
import { getFeed } from "@/rss";
|
import { getFeed } from "@/rss";
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.get(
|
app.get(
|
||||||
|
|
@ -33,12 +37,13 @@ export default apiRoute((app) =>
|
||||||
RolePermission.ViewNotes,
|
RolePermission.ViewNotes,
|
||||||
RolePermission.ViewAccounts,
|
RolePermission.ViewAccounts,
|
||||||
],
|
],
|
||||||
|
|
||||||
scopes: ["read:statuses"],
|
scopes: ["read:statuses"],
|
||||||
}),
|
}),
|
||||||
validator(
|
validator(
|
||||||
"query",
|
"query",
|
||||||
z.object({
|
z.object({
|
||||||
page: z.coerce.number().default(0).openapi({
|
page: z.coerce.number().default(0).meta({
|
||||||
description: "Page number to fetch. Defaults to 0.",
|
description: "Page number to fetch. Defaults to 0.",
|
||||||
example: 2,
|
example: 2,
|
||||||
}),
|
}),
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { afterAll, describe, expect, test } from "bun:test";
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
import { generateClient, getTestUsers } from "@versia-server/tests";
|
||||||
|
|
||||||
const { users, deleteUsers } = await getTestUsers(3);
|
const { users, deleteUsers } = await getTestUsers(3);
|
||||||
|
|
||||||
|
|
@ -3,12 +3,16 @@ import {
|
||||||
Relationship as RelationshipSchema,
|
Relationship as RelationshipSchema,
|
||||||
RolePermission,
|
RolePermission,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
import { Relationship } from "@versia/kit/db";
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { describeRoute } from "hono-openapi";
|
import {
|
||||||
import { resolver, validator } from "hono-openapi/zod";
|
apiRoute,
|
||||||
import { z } from "zod";
|
auth,
|
||||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
handleZodError,
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
withUserParam,
|
||||||
|
} from "@versia-server/kit/api";
|
||||||
|
import { Relationship } from "@versia-server/kit/db";
|
||||||
|
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.post(
|
app.post(
|
||||||
|
|
@ -57,12 +61,12 @@ export default apiRoute((app) =>
|
||||||
validator(
|
validator(
|
||||||
"json",
|
"json",
|
||||||
z.object({
|
z.object({
|
||||||
reblogs: z.boolean().default(true).openapi({
|
reblogs: z.boolean().default(true).meta({
|
||||||
description:
|
description:
|
||||||
"Receive this account’s reblogs in home timeline?",
|
"Receive this account’s reblogs in home timeline?",
|
||||||
example: true,
|
example: true,
|
||||||
}),
|
}),
|
||||||
notify: z.boolean().default(false).openapi({
|
notify: z.boolean().default(false).meta({
|
||||||
description:
|
description:
|
||||||
"Receive notifications when this account posts a status?",
|
"Receive notifications when this account posts a status?",
|
||||||
example: false,
|
example: false,
|
||||||
|
|
@ -70,7 +74,7 @@ export default apiRoute((app) =>
|
||||||
languages: z
|
languages: z
|
||||||
.array(iso631)
|
.array(iso631)
|
||||||
.default([])
|
.default([])
|
||||||
.openapi({
|
.meta({
|
||||||
description:
|
description:
|
||||||
"Array of String (ISO 639-1 language two-letter code). Filter received statuses for these languages. If not provided, you will receive this account’s posts in all languages.",
|
"Array of String (ISO 639-1 language two-letter code). Filter received statuses for these languages. If not provided, you will receive this account’s posts in all languages.",
|
||||||
example: ["en", "fr"],
|
example: ["en", "fr"],
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
import { generateClient, getTestUsers } from "@versia-server/tests";
|
||||||
|
|
||||||
const { users, deleteUsers } = await getTestUsers(5);
|
const { users, deleteUsers } = await getTestUsers(5);
|
||||||
|
|
||||||
|
|
@ -2,14 +2,18 @@ import {
|
||||||
Account as AccountSchema,
|
Account as AccountSchema,
|
||||||
RolePermission,
|
RolePermission,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
import { Timeline } from "@versia/kit/db";
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { Users } from "@versia/kit/tables";
|
import {
|
||||||
|
apiRoute,
|
||||||
|
auth,
|
||||||
|
handleZodError,
|
||||||
|
withUserParam,
|
||||||
|
} from "@versia-server/kit/api";
|
||||||
|
import { Timeline } from "@versia-server/kit/db";
|
||||||
|
import { Users } from "@versia-server/kit/tables";
|
||||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
||||||
import { describeRoute } from "hono-openapi";
|
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||||
import { resolver, validator } from "hono-openapi/zod";
|
import { z } from "zod/v4";
|
||||||
import { z } from "zod";
|
|
||||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.get(
|
app.get(
|
||||||
|
|
@ -34,7 +38,7 @@ export default apiRoute((app) =>
|
||||||
link: z
|
link: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.openapi({
|
.meta({
|
||||||
description:
|
description:
|
||||||
"Links to the next and previous pages",
|
"Links to the next and previous pages",
|
||||||
example:
|
example:
|
||||||
|
|
@ -61,22 +65,22 @@ export default apiRoute((app) =>
|
||||||
validator(
|
validator(
|
||||||
"query",
|
"query",
|
||||||
z.object({
|
z.object({
|
||||||
max_id: AccountSchema.shape.id.optional().openapi({
|
max_id: AccountSchema.shape.id.optional().meta({
|
||||||
description:
|
description:
|
||||||
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
|
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
|
||||||
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
|
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
|
||||||
}),
|
}),
|
||||||
since_id: AccountSchema.shape.id.optional().openapi({
|
since_id: AccountSchema.shape.id.optional().meta({
|
||||||
description:
|
description:
|
||||||
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
|
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
|
||||||
example: undefined,
|
example: undefined,
|
||||||
}),
|
}),
|
||||||
min_id: AccountSchema.shape.id.optional().openapi({
|
min_id: AccountSchema.shape.id.optional().meta({
|
||||||
description:
|
description:
|
||||||
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
|
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
|
||||||
example: undefined,
|
example: undefined,
|
||||||
}),
|
}),
|
||||||
limit: z.number().int().min(1).max(40).default(20).openapi({
|
limit: z.number().int().min(1).max(40).default(20).meta({
|
||||||
description: "Maximum number of results to return.",
|
description: "Maximum number of results to return.",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
import { generateClient, getTestUsers } from "@versia-server/tests";
|
||||||
|
|
||||||
const { users, deleteUsers } = await getTestUsers(5);
|
const { users, deleteUsers } = await getTestUsers(5);
|
||||||
|
|
||||||
|
|
@ -2,14 +2,18 @@ import {
|
||||||
Account as AccountSchema,
|
Account as AccountSchema,
|
||||||
RolePermission,
|
RolePermission,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
import { Timeline } from "@versia/kit/db";
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { Users } from "@versia/kit/tables";
|
import {
|
||||||
|
apiRoute,
|
||||||
|
auth,
|
||||||
|
handleZodError,
|
||||||
|
withUserParam,
|
||||||
|
} from "@versia-server/kit/api";
|
||||||
|
import { Timeline } from "@versia-server/kit/db";
|
||||||
|
import { Users } from "@versia-server/kit/tables";
|
||||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
||||||
import { describeRoute } from "hono-openapi";
|
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||||
import { resolver, validator } from "hono-openapi/zod";
|
import { z } from "zod/v4";
|
||||||
import { z } from "zod";
|
|
||||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.get(
|
app.get(
|
||||||
|
|
@ -35,7 +39,7 @@ export default apiRoute((app) =>
|
||||||
link: z
|
link: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.openapi({
|
.meta({
|
||||||
description:
|
description:
|
||||||
"Links to the next and previous pages",
|
"Links to the next and previous pages",
|
||||||
example:
|
example:
|
||||||
|
|
@ -62,22 +66,22 @@ export default apiRoute((app) =>
|
||||||
validator(
|
validator(
|
||||||
"query",
|
"query",
|
||||||
z.object({
|
z.object({
|
||||||
max_id: AccountSchema.shape.id.optional().openapi({
|
max_id: AccountSchema.shape.id.optional().meta({
|
||||||
description:
|
description:
|
||||||
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
|
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
|
||||||
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
|
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
|
||||||
}),
|
}),
|
||||||
since_id: AccountSchema.shape.id.optional().openapi({
|
since_id: AccountSchema.shape.id.optional().meta({
|
||||||
description:
|
description:
|
||||||
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
|
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
|
||||||
example: undefined,
|
example: undefined,
|
||||||
}),
|
}),
|
||||||
min_id: AccountSchema.shape.id.optional().openapi({
|
min_id: AccountSchema.shape.id.optional().meta({
|
||||||
description:
|
description:
|
||||||
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
|
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
|
||||||
example: undefined,
|
example: undefined,
|
||||||
}),
|
}),
|
||||||
limit: z.number().int().min(1).max(40).default(20).openapi({
|
limit: z.number().int().min(1).max(40).default(20).meta({
|
||||||
description: "Maximum number of results to return.",
|
description: "Maximum number of results to return.",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { generateClient, getTestStatuses, getTestUsers } from "~/tests/utils";
|
import {
|
||||||
|
generateClient,
|
||||||
|
getTestStatuses,
|
||||||
|
getTestUsers,
|
||||||
|
} from "@versia-server/tests";
|
||||||
|
|
||||||
const { users, deleteUsers } = await getTestUsers(5);
|
const { users, deleteUsers } = await getTestUsers(5);
|
||||||
const timeline = (await getTestStatuses(5, users[0])).toReversed();
|
const timeline = (await getTestStatuses(5, users[0])).toReversed();
|
||||||
|
|
@ -2,10 +2,9 @@ import {
|
||||||
Account as AccountSchema,
|
Account as AccountSchema,
|
||||||
RolePermission,
|
RolePermission,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
import { describeRoute } from "hono-openapi";
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { resolver } from "hono-openapi/zod";
|
import { apiRoute, auth, withUserParam } from "@versia-server/kit/api";
|
||||||
import { apiRoute, auth, withUserParam } from "@/api";
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.get(
|
app.get(
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { afterAll, describe, expect, test } from "bun:test";
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
import { generateClient, getTestUsers } from "@versia-server/tests";
|
||||||
|
|
||||||
const { users, deleteUsers } = await getTestUsers(2);
|
const { users, deleteUsers } = await getTestUsers(2);
|
||||||
|
|
||||||
|
|
@ -2,16 +2,20 @@ import {
|
||||||
Relationship as RelationshipSchema,
|
Relationship as RelationshipSchema,
|
||||||
RolePermission,
|
RolePermission,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
import { Relationship } from "@versia/kit/db";
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { describeRoute } from "hono-openapi";
|
import {
|
||||||
import { resolver, validator } from "hono-openapi/zod";
|
apiRoute,
|
||||||
import { z } from "zod";
|
auth,
|
||||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
handleZodError,
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
withUserParam,
|
||||||
|
} from "@versia-server/kit/api";
|
||||||
|
import { Relationship } from "@versia-server/kit/db";
|
||||||
import {
|
import {
|
||||||
RelationshipJobType,
|
RelationshipJobType,
|
||||||
relationshipQueue,
|
relationshipQueue,
|
||||||
} from "~/classes/queues/relationships";
|
} from "@versia-server/kit/queues/relationships";
|
||||||
|
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.post(
|
app.post(
|
||||||
|
|
@ -51,7 +55,7 @@ export default apiRoute((app) =>
|
||||||
validator(
|
validator(
|
||||||
"json",
|
"json",
|
||||||
z.object({
|
z.object({
|
||||||
notifications: z.boolean().default(true).openapi({
|
notifications: z.boolean().default(true).meta({
|
||||||
description: "Mute notifications in addition to statuses?",
|
description: "Mute notifications in addition to statuses?",
|
||||||
}),
|
}),
|
||||||
duration: z
|
duration: z
|
||||||
|
|
@ -60,7 +64,7 @@ export default apiRoute((app) =>
|
||||||
.min(0)
|
.min(0)
|
||||||
.max(60 * 60 * 24 * 365 * 5)
|
.max(60 * 60 * 24 * 365 * 5)
|
||||||
.default(0)
|
.default(0)
|
||||||
.openapi({
|
.meta({
|
||||||
description:
|
description:
|
||||||
"How long the mute should last, in seconds. 0 means indefinite.",
|
"How long the mute should last, in seconds. 0 means indefinite.",
|
||||||
}),
|
}),
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { afterAll, describe, expect, test } from "bun:test";
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
import { generateClient, getTestUsers } from "@versia-server/tests";
|
||||||
|
|
||||||
const { users, deleteUsers } = await getTestUsers(2);
|
const { users, deleteUsers } = await getTestUsers(2);
|
||||||
|
|
||||||
|
|
@ -2,12 +2,16 @@ import {
|
||||||
Relationship as RelationshipSchema,
|
Relationship as RelationshipSchema,
|
||||||
RolePermission,
|
RolePermission,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
import { Relationship } from "@versia/kit/db";
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { describeRoute } from "hono-openapi";
|
import {
|
||||||
import { resolver, validator } from "hono-openapi/zod";
|
apiRoute,
|
||||||
import { z } from "zod";
|
auth,
|
||||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
handleZodError,
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
withUserParam,
|
||||||
|
} from "@versia-server/kit/api";
|
||||||
|
import { Relationship } from "@versia-server/kit/db";
|
||||||
|
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.post(
|
app.post(
|
||||||
|
|
@ -45,7 +49,7 @@ export default apiRoute((app) =>
|
||||||
validator(
|
validator(
|
||||||
"json",
|
"json",
|
||||||
z.object({
|
z.object({
|
||||||
comment: RelationshipSchema.shape.note.optional().openapi({
|
comment: RelationshipSchema.shape.note.optional().meta({
|
||||||
description:
|
description:
|
||||||
"The comment to be set on that user. Provide an empty string or leave out this parameter to clear the currently set note.",
|
"The comment to be set on that user. Provide an empty string or leave out this parameter to clear the currently set note.",
|
||||||
}),
|
}),
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { afterAll, describe, expect, test } from "bun:test";
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
import { generateClient, getTestUsers } from "@versia-server/tests";
|
||||||
|
|
||||||
const { users, deleteUsers } = await getTestUsers(2);
|
const { users, deleteUsers } = await getTestUsers(2);
|
||||||
|
|
||||||
|
|
@ -2,10 +2,9 @@ import {
|
||||||
Relationship as RelationshipSchema,
|
Relationship as RelationshipSchema,
|
||||||
RolePermission,
|
RolePermission,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
import { Relationship } from "@versia/kit/db";
|
import { apiRoute, auth, withUserParam } from "@versia-server/kit/api";
|
||||||
import { describeRoute } from "hono-openapi";
|
import { Relationship } from "@versia-server/kit/db";
|
||||||
import { resolver } from "hono-openapi/zod";
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
import { apiRoute, auth, withUserParam } from "@/api";
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.post(
|
app.post(
|
||||||
|
|
@ -2,11 +2,10 @@ import {
|
||||||
Account as AccountSchema,
|
Account as AccountSchema,
|
||||||
RolePermission,
|
RolePermission,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
import { describeRoute } from "hono-openapi";
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { resolver } from "hono-openapi/zod";
|
import { apiRoute, auth, withUserParam } from "@versia-server/kit/api";
|
||||||
import { apiRoute, auth, withUserParam } from "@/api";
|
import { User } from "@versia-server/kit/db";
|
||||||
import { User } from "~/classes/database/user";
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.post(
|
app.post(
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
import { generateClient, getTestUsers } from "@versia-server/tests";
|
||||||
|
|
||||||
const { users, deleteUsers } = await getTestUsers(2);
|
const { users, deleteUsers } = await getTestUsers(2);
|
||||||
|
|
||||||
|
|
@ -2,11 +2,10 @@ import {
|
||||||
Relationship as RelationshipSchema,
|
Relationship as RelationshipSchema,
|
||||||
RolePermission,
|
RolePermission,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
import { Relationship } from "@versia/kit/db";
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { describeRoute } from "hono-openapi";
|
import { apiRoute, auth, withUserParam } from "@versia-server/kit/api";
|
||||||
import { resolver } from "hono-openapi/zod";
|
import { Relationship } from "@versia-server/kit/db";
|
||||||
import { apiRoute, auth, withUserParam } from "@/api";
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.post(
|
app.post(
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { RolePermission } from "@versia/client/schemas";
|
import { RolePermission } from "@versia/client/schemas";
|
||||||
import { Role } from "@versia/kit/db";
|
import { Role } from "@versia-server/kit/db";
|
||||||
|
import { generateClient, getTestUsers } from "@versia-server/tests";
|
||||||
import { randomUUIDv7 } from "bun";
|
import { randomUUIDv7 } from "bun";
|
||||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
|
||||||
|
|
||||||
const { users, deleteUsers } = await getTestUsers(2);
|
const { users, deleteUsers } = await getTestUsers(2);
|
||||||
let role: Role;
|
let role: Role;
|
||||||
|
|
@ -3,12 +3,16 @@ import {
|
||||||
RolePermission,
|
RolePermission,
|
||||||
Role as RoleSchema,
|
Role as RoleSchema,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
import { Role } from "@versia/kit/db";
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { describeRoute } from "hono-openapi";
|
import {
|
||||||
import { validator } from "hono-openapi/zod";
|
apiRoute,
|
||||||
import { z } from "zod";
|
auth,
|
||||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
handleZodError,
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
withUserParam,
|
||||||
|
} from "@versia-server/kit/api";
|
||||||
|
import { Role } from "@versia-server/kit/db";
|
||||||
|
import { describeRoute, validator } from "hono-openapi";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
export default apiRoute((app) => {
|
export default apiRoute((app) => {
|
||||||
app.post(
|
app.post(
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { RolePermission } from "@versia/client/schemas";
|
import { RolePermission } from "@versia/client/schemas";
|
||||||
import { Role } from "@versia/kit/db";
|
import { Role } from "@versia-server/kit/db";
|
||||||
|
import { generateClient, getTestUsers } from "@versia-server/tests";
|
||||||
import { randomUUIDv7 } from "bun";
|
import { randomUUIDv7 } from "bun";
|
||||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
|
||||||
|
|
||||||
const { users, deleteUsers } = await getTestUsers(2);
|
const { users, deleteUsers } = await getTestUsers(2);
|
||||||
let role: Role;
|
let role: Role;
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { Role as RoleSchema } from "@versia/client/schemas";
|
import { Role as RoleSchema } from "@versia/client/schemas";
|
||||||
import { Role } from "@versia/kit/db";
|
import { apiRoute, auth, withUserParam } from "@versia-server/kit/api";
|
||||||
import { describeRoute } from "hono-openapi";
|
import { Role } from "@versia-server/kit/db";
|
||||||
import { resolver } from "hono-openapi/zod";
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
import { z } from "zod";
|
import { z } from "zod/v4";
|
||||||
import { apiRoute, auth, withUserParam } from "@/api";
|
|
||||||
|
|
||||||
export default apiRoute((app) => {
|
export default apiRoute((app) => {
|
||||||
app.get(
|
app.get(
|
||||||
|
|
@ -3,7 +3,7 @@ import {
|
||||||
generateClient,
|
generateClient,
|
||||||
getTestStatuses,
|
getTestStatuses,
|
||||||
getTestUsers,
|
getTestUsers,
|
||||||
} from "~/tests/utils.ts";
|
} from "@versia-server/tests";
|
||||||
|
|
||||||
const { users, deleteUsers } = await getTestUsers(5);
|
const { users, deleteUsers } = await getTestUsers(5);
|
||||||
const timeline = (await getTestStatuses(5, users[1])).toReversed();
|
const timeline = (await getTestStatuses(5, users[1])).toReversed();
|
||||||
|
|
@ -3,14 +3,18 @@ import {
|
||||||
Status as StatusSchema,
|
Status as StatusSchema,
|
||||||
zBoolean,
|
zBoolean,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
import { Timeline } from "@versia/kit/db";
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { Notes } from "@versia/kit/tables";
|
import {
|
||||||
|
apiRoute,
|
||||||
|
auth,
|
||||||
|
handleZodError,
|
||||||
|
withUserParam,
|
||||||
|
} from "@versia-server/kit/api";
|
||||||
|
import { Timeline } from "@versia-server/kit/db";
|
||||||
|
import { Notes } from "@versia-server/kit/tables";
|
||||||
import { and, eq, gt, gte, inArray, isNull, lt, or, sql } from "drizzle-orm";
|
import { and, eq, gt, gte, inArray, isNull, lt, or, sql } from "drizzle-orm";
|
||||||
import { describeRoute } from "hono-openapi";
|
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||||
import { resolver, validator } from "hono-openapi/zod";
|
import { z } from "zod/v4";
|
||||||
import { z } from "zod";
|
|
||||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.get(
|
app.get(
|
||||||
|
|
@ -42,50 +46,45 @@ export default apiRoute((app) =>
|
||||||
RolePermission.ViewNotes,
|
RolePermission.ViewNotes,
|
||||||
RolePermission.ViewAccounts,
|
RolePermission.ViewAccounts,
|
||||||
],
|
],
|
||||||
|
|
||||||
scopes: ["read:statuses"],
|
scopes: ["read:statuses"],
|
||||||
}),
|
}),
|
||||||
validator(
|
validator(
|
||||||
"query",
|
"query",
|
||||||
z.object({
|
z.object({
|
||||||
max_id: StatusSchema.shape.id.optional().openapi({
|
max_id: StatusSchema.shape.id.optional().meta({
|
||||||
description:
|
description:
|
||||||
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
|
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
|
||||||
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
|
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
|
||||||
}),
|
}),
|
||||||
since_id: StatusSchema.shape.id.optional().openapi({
|
since_id: StatusSchema.shape.id.optional().meta({
|
||||||
description:
|
description:
|
||||||
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
|
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
|
||||||
example: undefined,
|
example: undefined,
|
||||||
}),
|
}),
|
||||||
min_id: StatusSchema.shape.id.optional().openapi({
|
min_id: StatusSchema.shape.id.optional().meta({
|
||||||
description:
|
description:
|
||||||
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
|
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
|
||||||
example: undefined,
|
example: undefined,
|
||||||
}),
|
}),
|
||||||
limit: z.coerce
|
limit: z.coerce.number().int().min(1).max(40).default(20).meta({
|
||||||
.number()
|
description: "Maximum number of results to return.",
|
||||||
.int()
|
}),
|
||||||
.min(1)
|
only_media: zBoolean.default(false).meta({
|
||||||
.max(40)
|
|
||||||
.default(20)
|
|
||||||
.openapi({
|
|
||||||
description: "Maximum number of results to return.",
|
|
||||||
}),
|
|
||||||
only_media: zBoolean.default(false).openapi({
|
|
||||||
description: "Filter out statuses without attachments.",
|
description: "Filter out statuses without attachments.",
|
||||||
}),
|
}),
|
||||||
exclude_replies: zBoolean.default(false).openapi({
|
exclude_replies: zBoolean.default(false).meta({
|
||||||
description:
|
description:
|
||||||
"Filter out statuses in reply to a different account.",
|
"Filter out statuses in reply to a different account.",
|
||||||
}),
|
}),
|
||||||
exclude_reblogs: zBoolean.default(false).openapi({
|
exclude_reblogs: zBoolean.default(false).meta({
|
||||||
description: "Filter out boosts from the response.",
|
description: "Filter out boosts from the response.",
|
||||||
}),
|
}),
|
||||||
pinned: zBoolean.default(false).openapi({
|
pinned: zBoolean.default(false).meta({
|
||||||
description:
|
description:
|
||||||
"Filter for pinned statuses only. Pinned statuses do not receive special priority in the order of the returned results.",
|
"Filter for pinned statuses only. Pinned statuses do not receive special priority in the order of the returned results.",
|
||||||
}),
|
}),
|
||||||
tagged: z.string().optional().openapi({
|
tagged: z.string().optional().meta({
|
||||||
description:
|
description:
|
||||||
"Filter for statuses using a specific hashtag.",
|
"Filter for statuses using a specific hashtag.",
|
||||||
}),
|
}),
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { afterAll, describe, expect, test } from "bun:test";
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
import { generateClient, getTestUsers } from "@versia-server/tests";
|
||||||
|
|
||||||
const { users, deleteUsers } = await getTestUsers(2);
|
const { users, deleteUsers } = await getTestUsers(2);
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue