Compare commits

...

29 commits

Author SHA1 Message Date
Jesse Wierzbinski a6c9d6cd4f
chore: 🐛 Update Nix hashes
Some checks failed
CodeQL Scan / Analyze (javascript-typescript) (push) Failing after 0s
Build Docker Images / lint (push) Failing after 7s
Build Docker Images / check (push) Failing after 6s
Build Docker Images / tests (push) Failing after 6s
Build Docker Images / build (server, Dockerfile, ${{ github.repository_owner }}/server) (push) Has been skipped
Build Docker Images / build (worker, Worker.Dockerfile, ${{ github.repository_owner }}/worker) (push) Has been skipped
Build Docker Images / detect-circular (push) Failing after 6s
Deploy Docs to GitHub Pages / build (push) Failing after 0s
Deploy Docs to GitHub Pages / Deploy (push) Has been skipped
Mirror to Codeberg / Mirror (push) Failing after 0s
Nix Build / check (push) Failing after 0s
Test Publish / build (client) (push) Failing after 0s
Test Publish / build (sdk) (push) Failing after 0s
2025-07-07 06:11:21 +02:00
Jesse Wierzbinski b5e9e35427
refactor: 🔥 Remove plugin functionality, move OpenID plugin to core 2025-07-07 05:52:11 +02:00
Gaspard Wierzbinski 278bf960cb
Merge pull request #41 from versia-pub/refactor/packages
Refactor/packages
2025-07-07 05:16:09 +02:00
Jesse Wierzbinski 0bf5f7c983
fix: 🚨 Fix further DeepSource issues 2025-07-07 05:12:23 +02:00
Jesse Wierzbinski 870b6dbe85
fix: 🚨 Fix Deepsource warnings 2025-07-07 05:08:34 +02:00
Jesse Wierzbinski 2fffbcbede
fix: 🐛 Fix weird imports failing build 2025-07-07 04:52:46 +02:00
Jesse Wierzbinski 551b9a94fe
ci: 💚 Use correct name in CI 2025-07-07 04:39:36 +02:00
Jesse Wierzbinski 24d4150da4
refactor: ⬆️ Upgrade to Zod v4 and hono-openapi 0.5.0 2025-07-07 03:42:35 +02:00
Jesse Wierzbinski add2429606
docs: 📝 Improve Copilot instructions file
Some checks failed
Mirror to Codeberg / Mirror (push) Failing after 0s
Test Publish / build (client) (push) Failing after 1s
Test Publish / build (sdk) (push) Failing after 0s
2025-07-06 05:06:05 +02:00
Jesse Wierzbinski eb096c5991
fix: 💚 Fix the Nix build, remove dependency on PNPM 2025-07-06 02:25:06 +02:00
Jesse Wierzbinski 30bb801f9f
fix: 💚 Fix Docker image builds 2025-07-06 02:10:44 +02:00
Jesse Wierzbinski 6d7c545c88
chore: ⬆️ Upgrade Bun to 1.2.18 2025-07-06 02:03:27 +02:00
Jesse Wierzbinski a1300466f4
chore: ⬆️ Upgrade dependencies 2025-07-06 02:02:48 +02:00
Jesse Wierzbinski 90b6399407
refactor: ♻️ Rewrite build system to fit the monorepo architecture
Some checks failed
Mirror to Codeberg / Mirror (push) Failing after 0s
Test Publish / build (client) (push) Failing after 1s
Test Publish / build (sdk) (push) Failing after 0s
2025-07-04 06:29:43 +02:00
Jesse Wierzbinski 7de4b573e3
refactor(worker): 🚚 Move queue code to plugin-kit package
Some checks failed
Mirror to Codeberg / Mirror (push) Failing after 1s
Test Publish / build (client) (push) Failing after 1s
Test Publish / build (sdk) (push) Failing after 1s
2025-06-29 22:56:52 +02:00
Jesse Wierzbinski dc802ff5f6
feat(api): Begin work on Streaming API 2025-06-29 22:23:03 +02:00
Jesse Wierzbinski 59cd519337
fix(api): 🐛 Fix error when masto-fe stupidly sends empty spoiler_text
Some checks failed
Mirror to Codeberg / Mirror (push) Failing after 0s
Test Publish / build (client) (push) Failing after 0s
Test Publish / build (sdk) (push) Failing after 0s
2025-06-23 18:53:40 +02:00
Jesse Wierzbinski aff51b651c
refactor: ♻️ Rewrite logging logic into a unified package
Some checks failed
Mirror to Codeberg / Mirror (push) Failing after 0s
Test Publish / build (client) (push) Failing after 0s
Test Publish / build (sdk) (push) Failing after 0s
2025-06-22 18:43:03 +02:00
Jesse Wierzbinski e1bd389bf1
refactor: 🔥 Remove Devcontainer
It was useless and nobody has ever used it once.
2025-06-22 17:56:54 +02:00
Jesse Wierzbinski 2310e8b33d
chore: ⬆️ Upgrade Bun to 1.2.17 2025-06-22 17:55:50 +02:00
Jesse Wierzbinski 129bc97b09
chore: ⬆️ Upgrade dependencies 2025-06-22 17:53:53 +02:00
Jesse Wierzbinski 1a666e8371
refactor: ♻️ [BROKEN] Refactor Nix build to use fetchBunDeps PR 2025-06-22 17:46:29 +02:00
Jesse Wierzbinski 03940cd8fd
fix: 📄 Add licenses to both JSR packages
Some checks failed
Mirror to Codeberg / Mirror (push) Failing after 0s
Test Publish / build (client) (push) Failing after 0s
Test Publish / build (sdk) (push) Failing after 0s
2025-06-17 19:59:37 +02:00
Jesse Wierzbinski 1f03017327
refactor: 🚚 Rename @versia/kit to @versia-server/kit
Some checks failed
Mirror to Codeberg / Mirror (push) Failing after 0s
Test Publish / build (client) (push) Failing after 0s
Test Publish / build (sdk) (push) Failing after 0s
2025-06-15 23:50:34 +02:00
Jesse Wierzbinski 3798e170d0
refactor: 🚚 Move more utilities into packages 2025-06-15 23:43:27 +02:00
Jesse Wierzbinski 5cae547f8d
chore: 💚 Update Nix hashes 2025-06-15 22:26:43 +02:00
Jesse Wierzbinski fde70fa61a
refactor: 🚚 Move testing to its own sub-package 2025-06-15 22:17:33 +02:00
Jesse Wierzbinski a211772309
fix: 🐛 Fix Nix build
Some checks failed
Mirror to Codeberg / Mirror (push) Failing after 0s
Test Publish / build (client) (push) Failing after 0s
Test Publish / build (sdk) (push) Failing after 0s
2025-06-15 19:23:11 +02:00
Jesse Wierzbinski a6d3ebbeef
refactor: 🚚 Organize code into sub-packages, instead of a single large package 2025-06-15 04:38:20 +02:00
509 changed files with 7568 additions and 13843 deletions

View file

@ -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

View file

@ -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"
]
}
}
}

View file

@ -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)

View file

@ -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.

View file

@ -24,4 +24,4 @@ jobs:
- name: Run typechecks
run: |
bun run check
bun run typecheck

27
.github/workflows/circular-imports.yml vendored Normal file
View 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

View file

@ -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
View file

@ -0,0 +1,7 @@
{
"detectiveOptions": {
"ts": {
"skipTypeImports": true
}
}
}

View file

@ -6,7 +6,6 @@
"cli",
"federation",
"config",
"plugin",
"worker",
"media",
"packages/client",

View file

@ -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

View file

@ -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

View file

@ -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" ]

View file

@ -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" ]

View file

@ -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());

View file

@ -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]);

View file

@ -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",

View file

@ -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!");

View file

@ -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!");

903
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -2,4 +2,4 @@
"@jsr" = "https://npm.jsr.io"
[test]
preload = ["./tests/setup.ts"]
preload = ["./packages/tests/setup.ts"]

View file

@ -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`);
})();

View file

@ -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;
};

View file

@ -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));
};

View file

@ -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", () => {

View file

@ -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;
}
}

View file

@ -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>,
},
]);
});
});
*/

View file

@ -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`)}`;
}
}
}

View file

@ -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();

View file

@ -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";

View file

@ -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(
{

View file

@ -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(
{

View file

@ -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.");
}

View file

@ -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(

View file

@ -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(

View file

@ -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,

View file

@ -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
View file

@ -0,0 +1 @@
../config

View file

@ -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)

View file

@ -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

View file

@ -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`;

View file

@ -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"
}

View file

@ -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

View file

@ -1 +0,0 @@
await import("~/entrypoints/api/index.ts");

View file

@ -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();
});

View file

@ -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

View file

@ -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} \

View file

@ -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:"
}
}

View file

@ -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
View 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!");

View file

@ -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)

View file

@ -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

View file

@ -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}` },

View 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();
});

View file

@ -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

View file

@ -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
View 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:"
}
}

View file

@ -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,

View file

@ -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"],
});

View file

@ -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);

View file

@ -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);

View file

@ -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(),
}),

View file

@ -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");

View file

@ -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,

View file

@ -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",

View 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());
},
),
);

View file

@ -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);

View 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);
},
);
});

View 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()}`,
);
},
);
});

View 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(),
);
},
);
});

View file

@ -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);

View 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,
);
},
);
});

View file

@ -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);

View file

@ -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(

View file

@ -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,
}),

View file

@ -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,
}),

View file

@ -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);

View file

@ -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 accounts 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 accounts posts in all languages.",
example: ["en", "fr"],

View file

@ -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);

View file

@ -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.",
}),
}),

View file

@ -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);

View file

@ -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.",
}),
}),

View file

@ -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();

View file

@ -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(

View file

@ -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);

View file

@ -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.",
}),

View file

@ -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);

View file

@ -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.",
}),

View file

@ -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);

View file

@ -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(

View file

@ -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(

View file

@ -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);

View file

@ -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(

View file

@ -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;

View file

@ -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(

View file

@ -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;

View file

@ -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(

View file

@ -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();

View file

@ -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.",
}),

View file

@ -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