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]
|
||||
|
||||
# Available levels: debug, info, warning, error, fatal
|
||||
log_level = "debug"
|
||||
|
||||
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
|
||||
# Available levels: trace, debug, info, warning, error, fatal
|
||||
log_level = "info" # For console output
|
||||
|
||||
# [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
|
||||
# Uncomment to enable
|
||||
# [logging.sentry]
|
||||
# Sentry DSN for error logging
|
||||
# dsn = "https://example.com"
|
||||
# debug = false
|
||||
|
||||
# sample_rate = 1.0
|
||||
# traces_sample_rate = 1.0
|
||||
# Can also be regex
|
||||
# trace_propagation_targets = []
|
||||
# max_breadcrumbs = 100
|
||||
# environment = "production"
|
||||
# log_level = "info"
|
||||
|
||||
[plugins]
|
||||
# Whether to automatically load all plugins in the plugins directory
|
||||
autoload = true
|
||||
|
||||
# Override for autoload
|
||||
[plugins.overrides]
|
||||
enabled = []
|
||||
disabled = []
|
||||
|
||||
[plugins.config."@versia/openid"]
|
||||
[authentication]
|
||||
# If enabled, Versia will require users to log in with an OpenID provider
|
||||
forced = false
|
||||
forced_openid = false
|
||||
|
||||
# Allow registration with OpenID providers
|
||||
# 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
|
||||
public = "MCowBQYDK2VwAyEAfyZx8r98gVHtdH5EF1NYrBeChOXkt50mqiwKO2TX0f8="
|
||||
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
|
||||
# Authentik for example uses regex so it can be set to (regex):
|
||||
# <base_url>/oauth/sso/<provider_id>/callback.*
|
||||
# [[plugins.config."@versia/openid".providers]]
|
||||
# [[authentication.openid_providers]]
|
||||
# name = "CPlusPatch ID"
|
||||
# id = "cpluspatch-id"
|
||||
# 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.
|
||||
|
||||
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
|
||||
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:
|
||||
uses: ./.github/workflows/tests.yml
|
||||
|
||||
detect-circular:
|
||||
uses: ./.github/workflows/circular-imports.yml
|
||||
|
||||
build:
|
||||
if: ${{ success() }}
|
||||
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",
|
||||
"federation",
|
||||
"config",
|
||||
"plugin",
|
||||
"worker",
|
||||
"media",
|
||||
"packages/client",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
### Backend
|
||||
|
||||
- [x] 🚀 Upgraded Bun to `1.2.15`
|
||||
- [x] 🚀 Upgraded Bun to `1.2.18`
|
||||
|
||||
# `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:
|
||||
```sh
|
||||
bun check
|
||||
bun typecheck
|
||||
```
|
||||
|
||||
### Commit messages
|
||||
|
|
|
|||
15
Dockerfile
15
Dockerfile
|
|
@ -1,7 +1,5 @@
|
|||
# Node is required for building the project
|
||||
FROM imbios/bun-node:1-20-alpine AS base
|
||||
|
||||
RUN apk add --no-cache libstdc++
|
||||
FROM imbios/bun-node:latest-23-alpine AS base
|
||||
|
||||
# Install dependencies into temp directory
|
||||
# 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
|
||||
WORKDIR /temp
|
||||
RUN bun run build
|
||||
RUN bun run build api
|
||||
WORKDIR /temp/dist
|
||||
|
||||
# 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
|
||||
RUN apk add --no-cache libstdc++ && \
|
||||
mkdir -p /app
|
||||
RUN mkdir -p /app
|
||||
|
||||
COPY --from=build /temp/dist /app/dist
|
||||
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.vendor="Versia Pub"
|
||||
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
|
||||
|
|
@ -51,4 +48,4 @@ WORKDIR /app
|
|||
ENV NODE_ENV=production
|
||||
ENTRYPOINT [ "/bin/sh", "/app/entrypoint.sh" ]
|
||||
# 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
|
||||
FROM imbios/bun-node:1-20-alpine AS base
|
||||
|
||||
RUN apk add --no-cache libstdc++
|
||||
FROM imbios/bun-node:latest-23-alpine AS base
|
||||
|
||||
# Install dependencies into temp directory
|
||||
# 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
|
||||
WORKDIR /temp
|
||||
RUN bun run build:worker
|
||||
RUN bun run build worker
|
||||
WORKDIR /temp/dist
|
||||
|
||||
# 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
|
||||
RUN apk add --no-cache libstdc++ && \
|
||||
mkdir -p /app
|
||||
RUN mkdir -p /app
|
||||
|
||||
COPY --from=build /temp/dist /app/dist
|
||||
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.vendor="Versia Pub"
|
||||
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
|
||||
|
|
@ -47,7 +44,8 @@ ARG GIT_COMMIT
|
|||
ENV GIT_COMMIT=$GIT_COMMIT
|
||||
|
||||
# CD to app
|
||||
WORKDIR /app/dist
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENTRYPOINT [ "/bin/sh", "/app/entrypoint.sh" ]
|
||||
# Run migrations and start the server
|
||||
CMD [ "bun", "run", "worker.js" ]
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import process from "node:process";
|
||||
import { appFactory } from "@versia-server/api";
|
||||
import { config } from "@versia-server/config";
|
||||
import { Youch } from "youch";
|
||||
import { sentry } from "@/sentry";
|
||||
import { createServer } from "@/server";
|
||||
import { appFactory } from "~/app";
|
||||
import { config } from "~/config.ts";
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
process.exit();
|
||||
|
|
@ -15,7 +14,6 @@ process.on("uncaughtException", async (error) => {
|
|||
console.error(await youch.toANSI(error));
|
||||
});
|
||||
|
||||
await import("~/entrypoints/api/setup.ts");
|
||||
sentry?.captureMessage("Server started", "info");
|
||||
await import("@versia-server/api/setup");
|
||||
|
||||
createServer(config, await appFactory());
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import type { Status } from "@versia/client/schemas";
|
||||
import {
|
||||
fakeRequest,
|
||||
getTestStatuses,
|
||||
getTestUsers,
|
||||
} from "@versia-server/tests";
|
||||
import { bench, run } from "mitata";
|
||||
import type { z } from "zod";
|
||||
import { configureLoggers } from "@/loggers";
|
||||
import { fakeRequest, getTestStatuses, getTestUsers } from "~/tests/utils";
|
||||
|
||||
await configureLoggers(true);
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
const { users, tokens, deleteUsers } = await getTestUsers(5);
|
||||
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": {
|
||||
"actions": {
|
||||
"source": {
|
||||
|
|
@ -49,7 +49,6 @@
|
|||
}
|
||||
},
|
||||
"useLiteralEnumMembers": "error",
|
||||
"noCommaOperator": "error",
|
||||
"noNegationElse": "error",
|
||||
"noYodaExpression": "error",
|
||||
"useBlockStatements": "error",
|
||||
|
|
@ -89,7 +88,6 @@
|
|||
"accessibility": "explicit"
|
||||
}
|
||||
},
|
||||
"noArguments": "error",
|
||||
"useImportType": "error",
|
||||
"useExportType": "error",
|
||||
"noUselessElse": "error",
|
||||
|
|
@ -99,7 +97,15 @@
|
|||
"noCommonJs": "warn",
|
||||
"noExportedImports": "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": {
|
||||
"noDynamicNamespaceImportAccess": "warn"
|
||||
|
|
@ -119,6 +125,7 @@
|
|||
"noGlobalDirnameFilename": "error",
|
||||
"noProcessGlobal": "warn",
|
||||
"noTsIgnore": "warn",
|
||||
"useReadonlyClassProperties": "error",
|
||||
"useConsistentObjectDefinition": {
|
||||
"level": "warn",
|
||||
"options": {
|
||||
|
|
@ -135,7 +142,9 @@
|
|||
"noUselessEscapeInRegex": "warn",
|
||||
"useSimplifiedLogicExpression": "error",
|
||||
"useWhile": "error",
|
||||
"useNumericLiterals": "error"
|
||||
"useNumericLiterals": "error",
|
||||
"noArguments": "error",
|
||||
"noCommaOperator": "error"
|
||||
},
|
||||
"suspicious": {
|
||||
"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 { $, build } from "bun";
|
||||
import { routes } from "~/routes";
|
||||
import process from "node:process";
|
||||
import { $, build, file, write } from "bun";
|
||||
import manifest from "./package.json" with { type: "json" };
|
||||
|
||||
console.log("Building...");
|
||||
|
||||
await $`rm -rf dist && mkdir dist`;
|
||||
|
||||
// Get all directories under the plugins/ directory
|
||||
const pluginDirs = await readdir("plugins", { withFileTypes: true });
|
||||
const type = process.argv[2] as "api" | "worker";
|
||||
|
||||
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({
|
||||
entrypoints: [
|
||||
"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`),
|
||||
],
|
||||
entrypoints: [`./${type}.ts`],
|
||||
outdir: "dist",
|
||||
target: "bun",
|
||||
splitting: true,
|
||||
minify: false,
|
||||
external: ["acorn", "@bull-board/ui"],
|
||||
minify: true,
|
||||
external: [...packages],
|
||||
});
|
||||
|
||||
console.log("Copying files...");
|
||||
|
||||
// Copy Drizzle migrations to dist
|
||||
await $`cp -r drizzle dist/drizzle`;
|
||||
// Copy each package into dist/node_modules
|
||||
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
|
||||
await $`cp plugins/openid/manifest.json dist/plugins/openid/manifest.json`;
|
||||
|
||||
await $`mkdir -p dist/node_modules`;
|
||||
|
||||
// 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`;
|
||||
|
||||
// Copy acorn to dist
|
||||
await $`cp -rL node_modules/acorn dist/node_modules/acorn`;
|
||||
|
||||
// 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`;
|
||||
|
||||
// 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/`;
|
||||
// Rewrite package.json "exports" field to point to the dist directory and use .js extension
|
||||
const packageJsonPath = `dist/node_modules/${pkg}/package.json`;
|
||||
const packageJson = await file(packageJsonPath).json();
|
||||
for (const [key, value] of Object.entries(packageJson.exports) as [
|
||||
string,
|
||||
{ import?: string },
|
||||
][]) {
|
||||
if (value.import) {
|
||||
packageJson.exports[key] = {
|
||||
import: value.import
|
||||
.replace("./", "./dist/")
|
||||
.replace(/\.ts$/, ".js"),
|
||||
};
|
||||
}
|
||||
}
|
||||
await write(packageJsonPath, JSON.stringify(packageJson, null, 4));
|
||||
}
|
||||
|
||||
console.log("Build complete!");
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@
|
|||
"@jsr" = "https://npm.jsr.io"
|
||||
|
||||
[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 { mockModule } from "@versia-server/tests";
|
||||
import sharp from "sharp";
|
||||
import { mockModule } from "~/tests/utils.ts";
|
||||
import { calculateBlurhash } from "./blurhash.ts";
|
||||
|
||||
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 { notFoundPlugin } from "@clerc/plugin-not-found";
|
||||
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 { searchManager } from "~/classes/search/search-manager.ts";
|
||||
import { setupDatabase } from "~/drizzle/db.ts";
|
||||
import pkg from "~/package.json" with { type: "json" };
|
||||
import pkg from "../package.json" with { type: "json" };
|
||||
import { rebuildIndexCommand } from "./index/rebuild.ts";
|
||||
import { refetchInstanceCommand } from "./instance/refetch.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
|
||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||
import { defineCommand, type Root } from "clerc";
|
||||
import ora from "ora";
|
||||
import {
|
||||
SonicIndexType,
|
||||
searchManager,
|
||||
} from "~/classes/search/search-manager.ts";
|
||||
import { config } from "~/config.ts";
|
||||
|
||||
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";
|
||||
// @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
|
||||
import { defineCommand, type Root } from "clerc";
|
||||
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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
// @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
|
||||
import { defineCommand, type Root } from "clerc";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { renderUnicodeCompact } from "uqr";
|
||||
import { User } from "~/classes/database/user";
|
||||
import { config } from "~/config";
|
||||
import { Users } from "~/drizzle/schema";
|
||||
|
||||
export const createUserCommand = defineCommand(
|
||||
{
|
||||
|
|
@ -54,6 +55,9 @@ export const createUserCommand = defineCommand(
|
|||
isAdmin: admin,
|
||||
});
|
||||
|
||||
// Add to search index
|
||||
await searchManager.addUser(user);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("Failed to create user.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { User } from "@versia-server/kit/db";
|
||||
import chalk from "chalk";
|
||||
// @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
|
||||
import { defineCommand, type Root } from "clerc";
|
||||
import ora from "ora";
|
||||
import { User } from "~/classes/database/user.ts";
|
||||
import { retrieveUser } from "../utils.ts";
|
||||
|
||||
export const refetchUserCommand = defineCommand(
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { Token } from "@versia-server/kit/db";
|
||||
import { randomUUIDv7 } from "bun";
|
||||
import chalk from "chalk";
|
||||
// @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
|
||||
import { defineCommand, type Root } from "clerc";
|
||||
import { randomString } from "@/math.ts";
|
||||
import { Token } from "~/classes/database/token.ts";
|
||||
import { retrieveUser } from "../utils.ts";
|
||||
|
||||
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 { parseUserAddress } from "@/api";
|
||||
import { Instance } from "~/classes/database/instance";
|
||||
import { User } from "~/classes/database/user";
|
||||
import { Users } from "~/drizzle/schema";
|
||||
|
||||
export const retrieveUser = async (
|
||||
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]
|
||||
|
||||
# Available levels: debug, info, warning, error, fatal
|
||||
log_level = "debug"
|
||||
|
||||
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
|
||||
# Available levels: trace, debug, info, warning, error, fatal
|
||||
log_level = "info" # For console output
|
||||
|
||||
# [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
|
||||
# Uncomment to enable
|
||||
# [logging.sentry]
|
||||
# Sentry DSN for error logging
|
||||
# dsn = "https://example.com"
|
||||
# debug = false
|
||||
|
||||
# sample_rate = 1.0
|
||||
# traces_sample_rate = 1.0
|
||||
# Can also be regex
|
||||
# trace_propagation_targets = []
|
||||
# max_breadcrumbs = 100
|
||||
# environment = "production"
|
||||
# log_level = "info"
|
||||
|
||||
[plugins]
|
||||
# Whether to automatically load all plugins in the plugins directory
|
||||
autoload = true
|
||||
|
||||
# Override for autoload
|
||||
[plugins.overrides]
|
||||
enabled = []
|
||||
disabled = []
|
||||
|
||||
[plugins.config."@versia/openid"]
|
||||
[authentication]
|
||||
# If enabled, Versia will require users to log in with an OpenID provider
|
||||
forced = false
|
||||
forced_openid = false
|
||||
|
||||
# Allow registration with OpenID providers
|
||||
# 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
|
||||
# public = ""
|
||||
# private = ""
|
||||
|
|
@ -490,7 +478,7 @@ allow_registration = true
|
|||
# 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):
|
||||
# <base_url>/oauth/sso/<provider_id>/callback.*
|
||||
# [[plugins.config."@versia/openid".providers]]
|
||||
# [[authentication.openid_providers]]
|
||||
# name = "CPlusPatch ID"
|
||||
# id = "cpluspatch-id"
|
||||
# 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 { config } from "~/config.ts";
|
||||
|
||||
/**
|
||||
* 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": {
|
||||
"locked": {
|
||||
"lastModified": 1744536153,
|
||||
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
|
||||
"owner": "nixos",
|
||||
"lastModified": 1751637120,
|
||||
"narHash": "sha256-xVNy/XopSfIG9c46nRmPaKfH1Gn/56vQ8++xWA8itO4=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
|
||||
"rev": "5c724ed1388e53cc231ed98330a60eb2f7be4be3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
description = "Versia Server";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
|
@ -15,7 +15,9 @@
|
|||
}:
|
||||
{
|
||||
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 {
|
||||
inherit versia-server;
|
||||
};
|
||||
|
|
@ -54,7 +56,6 @@
|
|||
buildInputs = with pkgs; [
|
||||
bun
|
||||
vips
|
||||
pnpm
|
||||
nodePackages.typescript
|
||||
nodePackages.typescript-language-server
|
||||
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.overrideAttrs (oldAttrs: {
|
||||
pname = "${oldAttrs.pname}-worker";
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
bun run build:worker
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
entrypointPath = "worker.js";
|
||||
buildType = "worker";
|
||||
|
||||
meta =
|
||||
oldAttrs.meta
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
{
|
||||
lib,
|
||||
stdenv,
|
||||
pnpm,
|
||||
bun,
|
||||
nodejs,
|
||||
vips,
|
||||
makeWrapper,
|
||||
stdenvNoCC,
|
||||
writableTmpDirAsHomeHook,
|
||||
...
|
||||
}: let
|
||||
packageJson = builtins.fromJSON (builtins.readFile ../package.json);
|
||||
|
|
@ -16,35 +17,70 @@ in
|
|||
|
||||
src = ../.;
|
||||
|
||||
# Fixes the build script mv usage
|
||||
pnpmInstallFlags = ["--shamefully-hoist"];
|
||||
node_modules = stdenvNoCC.mkDerivation {
|
||||
pname = "${finalAttrs.pname}-node_modules";
|
||||
inherit (finalAttrs) version src;
|
||||
|
||||
pnpmDeps = pnpm.fetchDeps {
|
||||
inherit (finalAttrs) pname version src pnpmInstallFlags;
|
||||
hash = "sha256-/VCzDp8EfvQkaz/5W3rcoEyOlSB4zeW97qqOTJf6WvA=";
|
||||
nativeBuildInputs = [
|
||||
bun
|
||||
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 = [
|
||||
pnpm
|
||||
pnpm.configHook
|
||||
bun
|
||||
nodejs
|
||||
makeWrapper
|
||||
];
|
||||
|
||||
buildInputs = [
|
||||
vips
|
||||
];
|
||||
configurePhase = ''
|
||||
runHook preConfigure
|
||||
|
||||
cp -R ${finalAttrs.node_modules}/node_modules .
|
||||
|
||||
runHook postConfigure
|
||||
'';
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
bun run build
|
||||
bun run build ${finalAttrs.buildType}
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
entrypointPath = "index.js";
|
||||
buildType = "api";
|
||||
|
||||
installPhase = let
|
||||
libPath = lib.makeLibraryPath [
|
||||
|
|
@ -62,7 +98,7 @@ in
|
|||
cp -r dist $out/${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 MSGPACKR_NATIVE_ACCELERATION_DISABLED true \
|
||||
--prefix PATH : ${binPath} \
|
||||
|
|
|
|||
256
package.json
256
package.json
|
|
@ -20,9 +20,88 @@
|
|||
"activitypub",
|
||||
"bun"
|
||||
],
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"workspaces": {
|
||||
"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": [
|
||||
{
|
||||
"email": "contact@cpluspatch.com",
|
||||
|
|
@ -36,107 +115,108 @@
|
|||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "bun run --hot index.ts",
|
||||
"start": "NODE_ENV=production bun run dist/index.js --prod",
|
||||
"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",
|
||||
"wc": "find server database *.ts docs packages types utils drizzle tests -type f -print0 | wc -m --files0-from=-",
|
||||
"cli": "bun run cli/index.ts",
|
||||
"prune": "ts-prune | grep -v server/ | grep -v dist/ | grep -v '(used in module)'",
|
||||
"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 .",
|
||||
"typecheck": "bunx tsc -p .",
|
||||
"test": "bun test",
|
||||
"docs:dev": "vitepress dev docs",
|
||||
"docs:build": "vitepress build docs",
|
||||
"docs:preview": "vitepress preview docs"
|
||||
"build": "bun run --filter \"*\" build && bun run build.ts",
|
||||
"detect-circular": "bunx madge --circular --extensions ts ./",
|
||||
"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": [
|
||||
"@biomejs/biome",
|
||||
"es5-ext",
|
||||
"esbuild",
|
||||
"msgpackr-extract",
|
||||
"protobufjs",
|
||||
"sharp"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.0.0-beta.5",
|
||||
"@types/bun": "^1.2.16",
|
||||
"@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.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.31.1",
|
||||
"vue": "^3.5.16",
|
||||
"zod-to-json-schema": "^3.24.5"
|
||||
"@biomejs/biome": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/html-to-text": "catalog:",
|
||||
"@types/markdown-it-container": "catalog:",
|
||||
"@types/mime-types": "catalog:",
|
||||
"@types/qs": "catalog:",
|
||||
"@types/web-push": "catalog:",
|
||||
"bun-bagel": "catalog:",
|
||||
"drizzle-kit": "catalog:",
|
||||
"markdown-it-image-figures": "catalog:",
|
||||
"ts-prune": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitepress": "catalog:",
|
||||
"vitepress-plugin-tabs": "catalog:",
|
||||
"vitepress-sidebar": "catalog:",
|
||||
"vue": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.10.1",
|
||||
"@bull-board/hono": "^6.10.1",
|
||||
"@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/zod-validator": "^0.7.0",
|
||||
"@inquirer/confirm": "^5.1.12",
|
||||
"@logtape/file": "^0.12.0",
|
||||
"@logtape/logtape": "^0.12.0",
|
||||
"@scalar/hono-api-reference": "^0.9.4",
|
||||
"@sentry/bun": "^9.29.0",
|
||||
"@bull-board/api": "catalog:",
|
||||
"@bull-board/hono": "catalog:",
|
||||
"@clerc/plugin-completions": "catalog:",
|
||||
"@clerc/plugin-friendly-error": "catalog:",
|
||||
"@clerc/plugin-help": "catalog:",
|
||||
"@clerc/plugin-not-found": "catalog:",
|
||||
"@clerc/plugin-version": "catalog:",
|
||||
"@hackmd/markdown-it-task-lists": "catalog:",
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@inquirer/confirm": "catalog:",
|
||||
"@scalar/hono-api-reference": "catalog:",
|
||||
"@sentry/bun": "catalog:",
|
||||
"@versia-server/api": "workspace:*",
|
||||
"@versia-server/config": "workspace:*",
|
||||
"@versia-server/kit": "workspace:*",
|
||||
"@versia-server/logging": "workspace:*",
|
||||
"@versia-server/tests": "workspace:*",
|
||||
"@versia-server/worker": "workspace:*",
|
||||
"@versia/client": "workspace:*",
|
||||
"@versia/kit": "workspace:*",
|
||||
"@versia/sdk": "workspace:*",
|
||||
"altcha-lib": "^1.3.0",
|
||||
"blurhash": "^2.0.5",
|
||||
"bullmq": "^5.53.3",
|
||||
"chalk": "^5.4.1",
|
||||
"clerc": "^0.44.0",
|
||||
"confbox": "^0.2.2",
|
||||
"drizzle-orm": "^0.44.2",
|
||||
"feed": "^5.1.0",
|
||||
"hono": "^4.7.11",
|
||||
"hono-openapi": "^0.4.8",
|
||||
"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.2",
|
||||
"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.64",
|
||||
"zod-openapi": "^4.2.4",
|
||||
"zod-validation-error": "^3.5.0"
|
||||
"altcha-lib": "catalog:",
|
||||
"blurhash": "catalog:",
|
||||
"bullmq": "catalog:",
|
||||
"chalk": "catalog:",
|
||||
"clerc": "catalog:",
|
||||
"confbox": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"feed": "catalog:",
|
||||
"hono": "catalog:",
|
||||
"hono-openapi": "catalog:",
|
||||
"hono-rate-limiter": "catalog:",
|
||||
"html-to-text": "catalog:",
|
||||
"ioredis": "catalog:",
|
||||
"ip-matching": "catalog:",
|
||||
"iso-639-1": "catalog:",
|
||||
"jose": "catalog:",
|
||||
"linkify-html": "catalog:",
|
||||
"linkify-string": "catalog:",
|
||||
"linkifyjs": "catalog:",
|
||||
"magic-regexp": "catalog:",
|
||||
"markdown-it": "catalog:",
|
||||
"markdown-it-anchor": "catalog:",
|
||||
"markdown-it-container": "catalog:",
|
||||
"markdown-it-mathjax3": "catalog:",
|
||||
"markdown-it-toc-done-right": "catalog:",
|
||||
"mime-types": "catalog:",
|
||||
"mitata": "catalog:",
|
||||
"oauth4webapi": "catalog:",
|
||||
"ora": "catalog:",
|
||||
"qs": "catalog:",
|
||||
"sharp": "catalog:",
|
||||
"sonic-channel": "catalog:",
|
||||
"string-comparison": "catalog:",
|
||||
"stringify-entities": "catalog:",
|
||||
"unicode-emoji-json": "catalog:",
|
||||
"uqr": "catalog:",
|
||||
"web-push": "catalog:",
|
||||
"xss": "catalog:",
|
||||
"youch": "catalog:",
|
||||
"zod": "catalog:",
|
||||
"zod-openapi": "catalog:",
|
||||
"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 chalk from "chalk";
|
||||
import { config } from "@versia-server/config";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import { Hono } from "hono";
|
||||
import { serveStatic } from "hono/bun";
|
||||
import { cors } from "hono/cors";
|
||||
import { createMiddleware } from "hono/factory";
|
||||
import { prettyJSON } from "hono/pretty-json";
|
||||
import { secureHeaders } from "hono/secure-headers";
|
||||
import { openAPISpecs } from "hono-openapi";
|
||||
import { generateSpecs } from "hono-openapi";
|
||||
import { Youch } from "youch";
|
||||
import { applyToHono } from "@/bull-board.ts";
|
||||
import { configureLoggers } from "@/loggers";
|
||||
import { sentry } from "@/sentry";
|
||||
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 pkg from "../../package.json" with { type: "application/json" };
|
||||
import type { ApiRouteExports, HonoEnv } from "../../types/api.ts";
|
||||
import { agentBans } from "./middlewares/agent-bans.ts";
|
||||
import { boundaryCheck } from "./middlewares/boundary-check.ts";
|
||||
import { ipBans } from "./middlewares/ip-bans.ts";
|
||||
import { logger } from "./middlewares/logger.ts";
|
||||
import { rateLimit } from "./middlewares/rate-limit.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>> => {
|
||||
await configureLoggers();
|
||||
const serverLogger = getLogger("server");
|
||||
|
||||
const app = new Hono<HonoEnv>({
|
||||
strict: false,
|
||||
});
|
||||
|
|
@ -111,43 +100,23 @@ export const appFactory = async (): Promise<Hono<HonoEnv>> => {
|
|||
route.default(app);
|
||||
}
|
||||
|
||||
serverLogger.info`Loading plugins`;
|
||||
|
||||
const time1 = performance.now();
|
||||
|
||||
const loader = new PluginLoader();
|
||||
|
||||
const plugins = await loader.loadPlugins(
|
||||
resolve("./plugins"),
|
||||
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,
|
||||
const openApiSpecs = await generateSpecs(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,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
app.get("/openapi.json", (context) => {
|
||||
return context.json(openApiSpecs, 200);
|
||||
});
|
||||
|
||||
app.get(
|
||||
"/docs",
|
||||
|
|
@ -193,7 +162,6 @@ export const appFactory = async (): Promise<Hono<HonoEnv>> => {
|
|||
const youch = new Youch();
|
||||
console.error(await youch.toANSI(error));
|
||||
|
||||
sentry?.captureException(error);
|
||||
return c.json(
|
||||
{
|
||||
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 { ApiError } from "~/classes/errors/api-error";
|
||||
import { config } from "~/config.ts";
|
||||
|
||||
export const agentBans = createMiddleware(async (context, next) => {
|
||||
// Check for banned user agents (regex)
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { ApiError } from "@versia-server/kit";
|
||||
import { createMiddleware } from "hono/factory";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export const boundaryCheck = createMiddleware(async (context, next) => {
|
||||
// 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 { createMiddleware } from "hono/factory";
|
||||
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) => {
|
||||
// Check for banned IPs
|
||||
|
|
@ -22,11 +21,8 @@ export const ipBans = createMiddleware(async (context, next) => {
|
|||
throw new ApiError(403, "Forbidden");
|
||||
}
|
||||
} catch (e) {
|
||||
const logger = getLogger("server");
|
||||
|
||||
logger.error`Error while parsing banned IP "${ip}" `;
|
||||
logger.error`${e}`;
|
||||
sentry?.captureException(e);
|
||||
serverLogger.error`Error while parsing banned IP "${ip}" `;
|
||||
serverLogger.error`${e}`;
|
||||
|
||||
return context.json(
|
||||
{ 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 type { MiddlewareHandler } from "hono";
|
||||
import { rateLimiter } from "hono-rate-limiter";
|
||||
import type { z } from "zod";
|
||||
import type { ApiError } from "~/classes/errors/api-error";
|
||||
import type { z } from "zod/v4";
|
||||
import type { HonoEnv } from "~/types/api";
|
||||
|
||||
// Not exported by hono-rate-limiter
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { config } from "@versia-server/config";
|
||||
import { createMiddleware } from "hono/factory";
|
||||
import { config } from "~/config.ts";
|
||||
|
||||
export const urlCheck = createMiddleware(async (context, next) => {
|
||||
// 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 { eq, type InferSelectModel, type SQL } from "@versia/kit/drizzle";
|
||||
import type { OpenIdLoginFlows } from "@versia/kit/tables";
|
||||
import { type Application, db } from "@versia-server/kit/db";
|
||||
import type { OpenIdLoginFlows } from "@versia-server/kit/tables";
|
||||
import { eq, type InferSelectModel, type SQL } from "drizzle-orm";
|
||||
import {
|
||||
type AuthorizationResponseError,
|
||||
type AuthorizationServer,
|
||||
|
|
@ -165,7 +165,7 @@ export const automaticOidcFlow = async (
|
|||
|
||||
const authServer = await getAuthServer(issuerUrl);
|
||||
|
||||
const parameters = await getParameters(
|
||||
const parameters = getParameters(
|
||||
authServer,
|
||||
issuer.client_id,
|
||||
currentUrl,
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
import { join } from "node:path";
|
||||
import { FileSystemRouter } from "bun";
|
||||
|
||||
// Returns the route filesystem path when given a URL
|
||||
export const routeMatcher = new FileSystemRouter({
|
||||
style: "nextjs",
|
||||
dir: "api",
|
||||
dir: join(import.meta.dir, "routes"),
|
||||
fileExtensions: [".ts", ".js"],
|
||||
});
|
||||
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
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 { randomString } from "@/math";
|
||||
import { config } from "~/config.ts";
|
||||
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
||||
|
||||
const { users, deleteUsers, passwords } = await getTestUsers(1);
|
||||
|
||||
|
|
@ -1,16 +1,15 @@
|
|||
import { Application, User } from "@versia/kit/db";
|
||||
import { Users } from "@versia/kit/tables";
|
||||
import { config } from "@versia-server/config";
|
||||
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 { eq, or } from "drizzle-orm";
|
||||
import type { Context } from "hono";
|
||||
import { setCookie } from "hono/cookie";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { validator } from "hono-openapi/zod";
|
||||
import { describeRoute, validator } from "hono-openapi";
|
||||
import { SignJWT } from "jose";
|
||||
import { z } from "zod";
|
||||
import { apiRoute, handleZodError } from "@/api";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { config } from "~/config.ts";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const returnError = (
|
||||
context: Context,
|
||||
|
|
@ -59,7 +58,7 @@ export default apiRoute((app) =>
|
|||
"query",
|
||||
z.object({
|
||||
scope: z.string().optional(),
|
||||
redirect_uri: z.string().url().optional(),
|
||||
redirect_uri: z.url().optional(),
|
||||
response_type: z.enum([
|
||||
"code",
|
||||
"token",
|
||||
|
|
@ -90,7 +89,6 @@ export default apiRoute((app) =>
|
|||
"form",
|
||||
z.object({
|
||||
identifier: z
|
||||
.string()
|
||||
.email()
|
||||
.toLowerCase()
|
||||
.or(z.string().toLowerCase()),
|
||||
|
|
@ -99,30 +97,7 @@ export default apiRoute((app) =>
|
|||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const oidcConfig = config.plugins?.config?.["@versia/openid"] as
|
||||
| {
|
||||
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) {
|
||||
if (config.authentication.forced_openid) {
|
||||
return returnError(
|
||||
context,
|
||||
"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
|
||||
const jwt = await new SignJWT({
|
||||
sub: user.id,
|
||||
|
|
@ -187,7 +153,7 @@ export default apiRoute((app) =>
|
|||
nbf: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
.setProtectedHeader({ alg: "EdDSA" })
|
||||
.sign(privateKey);
|
||||
.sign(config.authentication.keys.private);
|
||||
|
||||
const application = await Application.fromClientId(client_id);
|
||||
|
||||
|
|
@ -1,11 +1,10 @@
|
|||
import { db } from "@versia/kit/db";
|
||||
import { Applications, Tokens } from "@versia/kit/tables";
|
||||
import { config } from "@versia-server/config";
|
||||
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 { describeRoute } from "hono-openapi";
|
||||
import { validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { apiRoute, handleZodError } from "@/api";
|
||||
import { config } from "~/config.ts";
|
||||
import { describeRoute, validator } from "hono-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* OAuth Code flow
|
||||
|
|
@ -28,7 +27,7 @@ export default apiRoute((app) =>
|
|||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
redirect_uri: z.string().url(),
|
||||
redirect_uri: z.url(),
|
||||
client_id: z.string(),
|
||||
code: z.string(),
|
||||
}),
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
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 { randomString } from "@/math";
|
||||
import { config } from "~/config.ts";
|
||||
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
||||
|
||||
const { users, deleteUsers, passwords } = await getTestUsers(1);
|
||||
const token = randomString(32, "hex");
|
||||
|
|
@ -1,13 +1,12 @@
|
|||
import { User } from "@versia/kit/db";
|
||||
import { Users } from "@versia/kit/tables";
|
||||
import { config } from "@versia-server/config";
|
||||
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 { eq } from "drizzle-orm";
|
||||
import type { Context } from "hono";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { apiRoute, handleZodError } from "@/api";
|
||||
import { config } from "~/config.ts";
|
||||
import { describeRoute, validator } from "hono-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const returnError = (
|
||||
context: Context,
|
||||
|
|
@ -1,23 +1,13 @@
|
|||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
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 { SignJWT } from "jose";
|
||||
import { randomString } from "@/math";
|
||||
import { config } from "~/config.ts";
|
||||
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
||||
|
||||
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({
|
||||
id: randomUUIDv7(),
|
||||
|
|
@ -44,7 +34,7 @@ describe("/oauth/authorize", () => {
|
|||
nbf: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
.setProtectedHeader({ alg: "EdDSA" })
|
||||
.sign(privateKey);
|
||||
.sign(config.authentication.keys.private);
|
||||
|
||||
const response = await fakeRequest("/oauth/authorize", {
|
||||
method: "POST",
|
||||
|
|
@ -115,7 +105,7 @@ describe("/oauth/authorize", () => {
|
|||
aud: application.data.clientId,
|
||||
})
|
||||
.setProtectedHeader({ alg: "EdDSA" })
|
||||
.sign(privateKey);
|
||||
.sign(config.authentication.keys.private);
|
||||
|
||||
const response = await fakeRequest("/oauth/authorize", {
|
||||
method: "POST",
|
||||
|
|
@ -157,7 +147,7 @@ describe("/oauth/authorize", () => {
|
|||
nbf: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
.setProtectedHeader({ alg: "EdDSA" })
|
||||
.sign(privateKey);
|
||||
.sign(config.authentication.keys.private);
|
||||
|
||||
const response = await fakeRequest("/oauth/authorize", {
|
||||
method: "POST",
|
||||
|
|
@ -197,7 +187,7 @@ describe("/oauth/authorize", () => {
|
|||
nbf: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
.setProtectedHeader({ alg: "EdDSA" })
|
||||
.sign(privateKey);
|
||||
.sign(config.authentication.keys.private);
|
||||
|
||||
const response2 = await fakeRequest("/oauth/authorize", {
|
||||
method: "POST",
|
||||
|
|
@ -242,7 +232,7 @@ describe("/oauth/authorize", () => {
|
|||
nbf: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
.setProtectedHeader({ alg: "EdDSA" })
|
||||
.sign(privateKey);
|
||||
.sign(config.authentication.keys.private);
|
||||
|
||||
const response = await fakeRequest("/oauth/authorize", {
|
||||
method: "POST",
|
||||
|
|
@ -286,7 +276,7 @@ describe("/oauth/authorize", () => {
|
|||
nbf: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
.setProtectedHeader({ alg: "EdDSA" })
|
||||
.sign(privateKey);
|
||||
.sign(config.authentication.keys.private);
|
||||
|
||||
const response = await fakeRequest("/oauth/authorize", {
|
||||
method: "POST",
|
||||
|
|
@ -328,7 +318,7 @@ describe("/oauth/authorize", () => {
|
|||
nbf: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
.setProtectedHeader({ alg: "EdDSA" })
|
||||
.sign(privateKey);
|
||||
.sign(config.authentication.keys.private);
|
||||
|
||||
const response = await fakeRequest("/oauth/authorize", {
|
||||
method: "POST",
|
||||
|
|
@ -370,7 +360,7 @@ describe("/oauth/authorize", () => {
|
|||
nbf: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
.setProtectedHeader({ alg: "EdDSA" })
|
||||
.sign(privateKey);
|
||||
.sign(config.authentication.keys.private);
|
||||
|
||||
const response = await fakeRequest("/oauth/authorize", {
|
||||
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 { 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 { fakeRequest, getTestUsers } from "~/tests/utils";
|
||||
|
||||
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 { 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 { fakeRequest, getTestUsers } from "~/tests/utils";
|
||||
|
||||
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 { generateClient, getTestUsers } from "~/tests/utils";
|
||||
import { generateClient, getTestUsers } from "@versia-server/tests";
|
||||
|
||||
const { users, deleteUsers } = await getTestUsers(2);
|
||||
|
||||
|
|
@ -2,11 +2,10 @@ import {
|
|||
Relationship as RelationshipSchema,
|
||||
RolePermission,
|
||||
} from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import { apiRoute, auth, withUserParam } from "@versia-server/kit/api";
|
||||
import { Relationship } from "@versia-server/kit/db";
|
||||
import { describeRoute, resolver } from "hono-openapi";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
|
|
@ -1,10 +1,14 @@
|
|||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import {
|
||||
apiRoute,
|
||||
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 { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
|
|
@ -34,12 +38,13 @@ export default apiRoute((app) =>
|
|||
RolePermission.ViewNotes,
|
||||
RolePermission.ViewAccounts,
|
||||
],
|
||||
|
||||
scopes: ["read:statuses"],
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
page: z.coerce.number().default(0).openapi({
|
||||
page: z.coerce.number().default(0).meta({
|
||||
description: "Page number to fetch. Defaults to 0.",
|
||||
example: 2,
|
||||
}),
|
||||
|
|
@ -1,10 +1,14 @@
|
|||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import {
|
||||
apiRoute,
|
||||
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 { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
|
|
@ -33,12 +37,13 @@ export default apiRoute((app) =>
|
|||
RolePermission.ViewNotes,
|
||||
RolePermission.ViewAccounts,
|
||||
],
|
||||
|
||||
scopes: ["read:statuses"],
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
page: z.coerce.number().default(0).openapi({
|
||||
page: z.coerce.number().default(0).meta({
|
||||
description: "Page number to fetch. Defaults to 0.",
|
||||
example: 2,
|
||||
}),
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
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);
|
||||
|
||||
|
|
@ -3,12 +3,16 @@ import {
|
|||
Relationship as RelationshipSchema,
|
||||
RolePermission,
|
||||
} from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import {
|
||||
apiRoute,
|
||||
auth,
|
||||
handleZodError,
|
||||
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) =>
|
||||
app.post(
|
||||
|
|
@ -57,12 +61,12 @@ export default apiRoute((app) =>
|
|||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
reblogs: z.boolean().default(true).openapi({
|
||||
reblogs: z.boolean().default(true).meta({
|
||||
description:
|
||||
"Receive this account’s reblogs in home timeline?",
|
||||
example: true,
|
||||
}),
|
||||
notify: z.boolean().default(false).openapi({
|
||||
notify: z.boolean().default(false).meta({
|
||||
description:
|
||||
"Receive notifications when this account posts a status?",
|
||||
example: false,
|
||||
|
|
@ -70,7 +74,7 @@ export default apiRoute((app) =>
|
|||
languages: z
|
||||
.array(iso631)
|
||||
.default([])
|
||||
.openapi({
|
||||
.meta({
|
||||
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.",
|
||||
example: ["en", "fr"],
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
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);
|
||||
|
||||
|
|
@ -2,14 +2,18 @@ import {
|
|||
Account as AccountSchema,
|
||||
RolePermission,
|
||||
} from "@versia/client/schemas";
|
||||
import { Timeline } from "@versia/kit/db";
|
||||
import { Users } from "@versia/kit/tables";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
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 { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
|
|
@ -34,7 +38,7 @@ export default apiRoute((app) =>
|
|||
link: z
|
||||
.string()
|
||||
.optional()
|
||||
.openapi({
|
||||
.meta({
|
||||
description:
|
||||
"Links to the next and previous pages",
|
||||
example:
|
||||
|
|
@ -61,22 +65,22 @@ export default apiRoute((app) =>
|
|||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
max_id: AccountSchema.shape.id.optional().openapi({
|
||||
max_id: AccountSchema.shape.id.optional().meta({
|
||||
description:
|
||||
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
|
||||
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
|
||||
}),
|
||||
since_id: AccountSchema.shape.id.optional().openapi({
|
||||
since_id: AccountSchema.shape.id.optional().meta({
|
||||
description:
|
||||
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
|
||||
example: undefined,
|
||||
}),
|
||||
min_id: AccountSchema.shape.id.optional().openapi({
|
||||
min_id: AccountSchema.shape.id.optional().meta({
|
||||
description:
|
||||
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
|
||||
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.",
|
||||
}),
|
||||
}),
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
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);
|
||||
|
||||
|
|
@ -2,14 +2,18 @@ import {
|
|||
Account as AccountSchema,
|
||||
RolePermission,
|
||||
} from "@versia/client/schemas";
|
||||
import { Timeline } from "@versia/kit/db";
|
||||
import { Users } from "@versia/kit/tables";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
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 { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
|
|
@ -35,7 +39,7 @@ export default apiRoute((app) =>
|
|||
link: z
|
||||
.string()
|
||||
.optional()
|
||||
.openapi({
|
||||
.meta({
|
||||
description:
|
||||
"Links to the next and previous pages",
|
||||
example:
|
||||
|
|
@ -62,22 +66,22 @@ export default apiRoute((app) =>
|
|||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
max_id: AccountSchema.shape.id.optional().openapi({
|
||||
max_id: AccountSchema.shape.id.optional().meta({
|
||||
description:
|
||||
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
|
||||
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
|
||||
}),
|
||||
since_id: AccountSchema.shape.id.optional().openapi({
|
||||
since_id: AccountSchema.shape.id.optional().meta({
|
||||
description:
|
||||
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
|
||||
example: undefined,
|
||||
}),
|
||||
min_id: AccountSchema.shape.id.optional().openapi({
|
||||
min_id: AccountSchema.shape.id.optional().meta({
|
||||
description:
|
||||
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
|
||||
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.",
|
||||
}),
|
||||
}),
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
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 timeline = (await getTestStatuses(5, users[0])).toReversed();
|
||||
|
|
@ -2,10 +2,9 @@ import {
|
|||
Account as AccountSchema,
|
||||
RolePermission,
|
||||
} from "@versia/client/schemas";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import { apiRoute, auth, withUserParam } from "@versia-server/kit/api";
|
||||
import { describeRoute, resolver } from "hono-openapi";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
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);
|
||||
|
||||
|
|
@ -2,16 +2,20 @@ import {
|
|||
Relationship as RelationshipSchema,
|
||||
RolePermission,
|
||||
} from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import {
|
||||
apiRoute,
|
||||
auth,
|
||||
handleZodError,
|
||||
withUserParam,
|
||||
} from "@versia-server/kit/api";
|
||||
import { Relationship } from "@versia-server/kit/db";
|
||||
import {
|
||||
RelationshipJobType,
|
||||
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) =>
|
||||
app.post(
|
||||
|
|
@ -51,7 +55,7 @@ export default apiRoute((app) =>
|
|||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
notifications: z.boolean().default(true).openapi({
|
||||
notifications: z.boolean().default(true).meta({
|
||||
description: "Mute notifications in addition to statuses?",
|
||||
}),
|
||||
duration: z
|
||||
|
|
@ -60,7 +64,7 @@ export default apiRoute((app) =>
|
|||
.min(0)
|
||||
.max(60 * 60 * 24 * 365 * 5)
|
||||
.default(0)
|
||||
.openapi({
|
||||
.meta({
|
||||
description:
|
||||
"How long the mute should last, in seconds. 0 means indefinite.",
|
||||
}),
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
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);
|
||||
|
||||
|
|
@ -2,12 +2,16 @@ import {
|
|||
Relationship as RelationshipSchema,
|
||||
RolePermission,
|
||||
} from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import {
|
||||
apiRoute,
|
||||
auth,
|
||||
handleZodError,
|
||||
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) =>
|
||||
app.post(
|
||||
|
|
@ -45,7 +49,7 @@ export default apiRoute((app) =>
|
|||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
comment: RelationshipSchema.shape.note.optional().openapi({
|
||||
comment: RelationshipSchema.shape.note.optional().meta({
|
||||
description:
|
||||
"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 { generateClient, getTestUsers } from "~/tests/utils";
|
||||
import { generateClient, getTestUsers } from "@versia-server/tests";
|
||||
|
||||
const { users, deleteUsers } = await getTestUsers(2);
|
||||
|
||||
|
|
@ -2,10 +2,9 @@ import {
|
|||
Relationship as RelationshipSchema,
|
||||
RolePermission,
|
||||
} from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { apiRoute, auth, withUserParam } from "@versia-server/kit/api";
|
||||
import { Relationship } from "@versia-server/kit/db";
|
||||
import { describeRoute, resolver } from "hono-openapi";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
|
|
@ -2,11 +2,10 @@ import {
|
|||
Account as AccountSchema,
|
||||
RolePermission,
|
||||
} from "@versia/client/schemas";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { User } from "~/classes/database/user";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import { apiRoute, auth, withUserParam } from "@versia-server/kit/api";
|
||||
import { User } from "@versia-server/kit/db";
|
||||
import { describeRoute, resolver } from "hono-openapi";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
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);
|
||||
|
||||
|
|
@ -2,11 +2,10 @@ import {
|
|||
Relationship as RelationshipSchema,
|
||||
RolePermission,
|
||||
} from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import { apiRoute, auth, withUserParam } from "@versia-server/kit/api";
|
||||
import { Relationship } from "@versia-server/kit/db";
|
||||
import { describeRoute, resolver } from "hono-openapi";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
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 { generateClient, getTestUsers } from "~/tests/utils";
|
||||
|
||||
const { users, deleteUsers } = await getTestUsers(2);
|
||||
let role: Role;
|
||||
|
|
@ -3,12 +3,16 @@ import {
|
|||
RolePermission,
|
||||
Role as RoleSchema,
|
||||
} from "@versia/client/schemas";
|
||||
import { Role } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import {
|
||||
apiRoute,
|
||||
auth,
|
||||
handleZodError,
|
||||
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) => {
|
||||
app.post(
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
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 { generateClient, getTestUsers } from "~/tests/utils";
|
||||
|
||||
const { users, deleteUsers } = await getTestUsers(2);
|
||||
let role: Role;
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
import { Role as RoleSchema } from "@versia/client/schemas";
|
||||
import { Role } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { apiRoute, auth, withUserParam } from "@versia-server/kit/api";
|
||||
import { Role } from "@versia-server/kit/db";
|
||||
import { describeRoute, resolver } from "hono-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export default apiRoute((app) => {
|
||||
app.get(
|
||||
|
|
@ -3,7 +3,7 @@ import {
|
|||
generateClient,
|
||||
getTestStatuses,
|
||||
getTestUsers,
|
||||
} from "~/tests/utils.ts";
|
||||
} from "@versia-server/tests";
|
||||
|
||||
const { users, deleteUsers } = await getTestUsers(5);
|
||||
const timeline = (await getTestStatuses(5, users[1])).toReversed();
|
||||
|
|
@ -3,14 +3,18 @@ import {
|
|||
Status as StatusSchema,
|
||||
zBoolean,
|
||||
} from "@versia/client/schemas";
|
||||
import { Timeline } from "@versia/kit/db";
|
||||
import { Notes } from "@versia/kit/tables";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
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 { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
|
|
@ -42,50 +46,45 @@ export default apiRoute((app) =>
|
|||
RolePermission.ViewNotes,
|
||||
RolePermission.ViewAccounts,
|
||||
],
|
||||
|
||||
scopes: ["read:statuses"],
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
max_id: StatusSchema.shape.id.optional().openapi({
|
||||
max_id: StatusSchema.shape.id.optional().meta({
|
||||
description:
|
||||
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
|
||||
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
|
||||
}),
|
||||
since_id: StatusSchema.shape.id.optional().openapi({
|
||||
since_id: StatusSchema.shape.id.optional().meta({
|
||||
description:
|
||||
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
|
||||
example: undefined,
|
||||
}),
|
||||
min_id: StatusSchema.shape.id.optional().openapi({
|
||||
min_id: StatusSchema.shape.id.optional().meta({
|
||||
description:
|
||||
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
|
||||
example: undefined,
|
||||
}),
|
||||
limit: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(40)
|
||||
.default(20)
|
||||
.openapi({
|
||||
description: "Maximum number of results to return.",
|
||||
}),
|
||||
only_media: zBoolean.default(false).openapi({
|
||||
limit: z.coerce.number().int().min(1).max(40).default(20).meta({
|
||||
description: "Maximum number of results to return.",
|
||||
}),
|
||||
only_media: zBoolean.default(false).meta({
|
||||
description: "Filter out statuses without attachments.",
|
||||
}),
|
||||
exclude_replies: zBoolean.default(false).openapi({
|
||||
exclude_replies: zBoolean.default(false).meta({
|
||||
description:
|
||||
"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.",
|
||||
}),
|
||||
pinned: zBoolean.default(false).openapi({
|
||||
pinned: zBoolean.default(false).meta({
|
||||
description:
|
||||
"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:
|
||||
"Filter for statuses using a specific hashtag.",
|
||||
}),
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
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);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue