Merge pull request #41 from versia-pub/refactor/packages

Refactor/packages
This commit is contained in:
Gaspard Wierzbinski 2025-07-07 05:16:09 +02:00 committed by GitHub
commit 278bf960cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
500 changed files with 6188 additions and 11661 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,31 +429,28 @@ text = "No spam"
[logging] [logging]
# Available levels: debug, info, warning, error, fatal # Available levels: trace, debug, info, warning, error, fatal
log_level = "debug" log_level = "info" # For console output
log_file_path = "logs/versia.log"
[logging.types]
# Either pass a boolean
# requests = true
# Or a table with the following keys:
# requests_content = { level = "debug", log_file_path = "logs/requests.log" }
# Available types are: requests, responses, requests_content, filters
# [logging.file]
# path = "logs/versia.log"
# log_level = "info"
#
# [logging.file.rotation]
# max_size = 10_000_000 # 10 MB
# max_files = 10 # Keep 10 rotated files
#
# https://sentry.io support # https://sentry.io support
# Uncomment to enable
# [logging.sentry] # [logging.sentry]
# Sentry DSN for error logging
# dsn = "https://example.com" # dsn = "https://example.com"
# debug = false # debug = false
# sample_rate = 1.0 # sample_rate = 1.0
# traces_sample_rate = 1.0 # traces_sample_rate = 1.0
# Can also be regex # Can also be regex
# trace_propagation_targets = [] # trace_propagation_targets = []
# max_breadcrumbs = 100 # max_breadcrumbs = 100
# environment = "production" # environment = "production"
# log_level = "info"
[plugins] [plugins]
# Whether to automatically load all plugins in the plugins directory # Whether to automatically load all plugins in the plugins directory

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. 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 - name: Run typechecks
run: | 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: tests:
uses: ./.github/workflows/tests.yml uses: ./.github/workflows/tests.yml
detect-circular:
uses: ./.github/workflows/circular-imports.yml
build: build:
if: ${{ success() }} if: ${{ success() }}
needs: [lint, check, tests] needs: [lint, check, tests]

7
.madgerc Normal file
View file

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

View file

@ -9,7 +9,7 @@
### Backend ### Backend
- [x] 🚀 Upgraded Bun to `1.2.15` - [x] 🚀 Upgraded Bun to `1.2.18`
# `0.8.0` • Federation 2: Electric Boogaloo # `0.8.0` • Federation 2: Electric Boogaloo

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: To scan for all TypeScript errors, run:
```sh ```sh
bun check bun typecheck
``` ```
### Commit messages ### Commit messages
@ -153,4 +153,4 @@ If you find a bug, please open an issue on GitHub. Please make sure to include t
# License # License
Versia Server is licensed under the [AGPLv3 or later](https://www.gnu.org/licenses/agpl-3.0.en.html) license. By contributing to Versia, you agree to license your contributions under the same license. Versia Server is licensed under the [AGPLv3 or later](https://www.gnu.org/licenses/agpl-3.0.en.html) license. By contributing to Versia, you agree to license your contributions under the same license.

View file

@ -1,7 +1,5 @@
# Node is required for building the project # Node is required for building the project
FROM imbios/bun-node:1-20-alpine AS base FROM imbios/bun-node:latest-23-alpine AS base
RUN apk add --no-cache libstdc++
# Install dependencies into temp directory # Install dependencies into temp directory
# This will cache them and speed up future builds # This will cache them and speed up future builds
@ -22,20 +20,19 @@ COPY --from=install /temp/node_modules /temp/node_modules
# Build the project # Build the project
WORKDIR /temp WORKDIR /temp
RUN bun run build RUN bun run build api
WORKDIR /temp/dist WORKDIR /temp/dist
# Copy production dependencies and source code into final image # Copy production dependencies and source code into final image
FROM oven/bun:1.2.15-alpine FROM oven/bun:1.2.18-alpine
# Install libstdc++ for Bun and create app directory # Install libstdc++ for Bun and create app directory
RUN apk add --no-cache libstdc++ && \ RUN mkdir -p /app
mkdir -p /app
COPY --from=build /temp/dist /app/dist COPY --from=build /temp/dist /app/dist
COPY entrypoint.sh /app COPY entrypoint.sh /app
LABEL org.opencontainers.image.authors="Gaspard Wierzbinski (https://cpluspatch.dev)" LABEL org.opencontainers.image.authors="Gaspard Wierzbinski (https://cpluspatch.com)"
LABEL org.opencontainers.image.source="https://github.com/versia-pub/server" LABEL org.opencontainers.image.source="https://github.com/versia-pub/server"
LABEL org.opencontainers.image.vendor="Versia Pub" LABEL org.opencontainers.image.vendor="Versia Pub"
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later" LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
@ -51,4 +48,4 @@ WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENTRYPOINT [ "/bin/sh", "/app/entrypoint.sh" ] ENTRYPOINT [ "/bin/sh", "/app/entrypoint.sh" ]
# Run migrations and start the server # Run migrations and start the server
CMD [ "bun", "run", "index.js" ] CMD [ "bun", "run", "api.js" ]

View file

@ -1,7 +1,5 @@
# Node is required for building the project # Node is required for building the project
FROM imbios/bun-node:1-20-alpine AS base FROM imbios/bun-node:latest-23-alpine AS base
RUN apk add --no-cache libstdc++
# Install dependencies into temp directory # Install dependencies into temp directory
# This will cache them and speed up future builds # This will cache them and speed up future builds
@ -22,20 +20,19 @@ COPY --from=install /temp/node_modules /temp/node_modules
# Build the project # Build the project
WORKDIR /temp WORKDIR /temp
RUN bun run build:worker RUN bun run build worker
WORKDIR /temp/dist WORKDIR /temp/dist
# Copy production dependencies and source code into final image # Copy production dependencies and source code into final image
FROM oven/bun:1.2.15-alpine FROM oven/bun:1.2.18-alpine
# Install libstdc++ for Bun and create app directory # Install libstdc++ for Bun and create app directory
RUN apk add --no-cache libstdc++ && \ RUN mkdir -p /app
mkdir -p /app
COPY --from=build /temp/dist /app/dist COPY --from=build /temp/dist /app/dist
COPY entrypoint.sh /app COPY entrypoint.sh /app
LABEL org.opencontainers.image.authors="Gaspard Wierzbinski (https://cpluspatch.dev)" LABEL org.opencontainers.image.authors="Gaspard Wierzbinski (https://cpluspatch.com)"
LABEL org.opencontainers.image.source="https://github.com/versia-pub/server" LABEL org.opencontainers.image.source="https://github.com/versia-pub/server"
LABEL org.opencontainers.image.vendor="Versia Pub" LABEL org.opencontainers.image.vendor="Versia Pub"
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later" LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
@ -47,7 +44,8 @@ ARG GIT_COMMIT
ENV GIT_COMMIT=$GIT_COMMIT ENV GIT_COMMIT=$GIT_COMMIT
# CD to app # CD to app
WORKDIR /app/dist WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENTRYPOINT [ "/bin/sh", "/app/entrypoint.sh" ]
# Run migrations and start the server # Run migrations and start the server
CMD [ "bun", "run", "worker.js" ] CMD [ "bun", "run", "worker.js" ]

View file

@ -1,9 +1,8 @@
import process from "node:process"; import process from "node:process";
import { appFactory } from "@versia-server/api";
import { config } from "@versia-server/config";
import { Youch } from "youch"; import { Youch } from "youch";
import { sentry } from "@/sentry";
import { createServer } from "@/server"; import { createServer } from "@/server";
import { appFactory } from "~/app";
import { config } from "~/config.ts";
process.on("SIGINT", () => { process.on("SIGINT", () => {
process.exit(); process.exit();
@ -15,7 +14,6 @@ process.on("uncaughtException", async (error) => {
console.error(await youch.toANSI(error)); console.error(await youch.toANSI(error));
}); });
await import("~/entrypoints/api/setup.ts"); await import("@versia-server/api/setup");
sentry?.captureMessage("Server started", "info");
createServer(config, await appFactory()); createServer(config, await appFactory());

View file

@ -1,10 +1,11 @@
import type { Status } from "@versia/client/schemas"; import type { Status } from "@versia/client/schemas";
import {
fakeRequest,
getTestStatuses,
getTestUsers,
} from "@versia-server/tests";
import { bench, run } from "mitata"; import { bench, run } from "mitata";
import type { z } from "zod"; import type { z } from "zod/v4";
import { configureLoggers } from "@/loggers";
import { fakeRequest, getTestStatuses, getTestUsers } from "~/tests/utils";
await configureLoggers(true);
const { users, tokens, deleteUsers } = await getTestUsers(5); const { users, tokens, deleteUsers } = await getTestUsers(5);
await getTestStatuses(40, users[0]); await getTestStatuses(40, users[0]);

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": { "assist": {
"actions": { "actions": {
"source": { "source": {
@ -49,7 +49,6 @@
} }
}, },
"useLiteralEnumMembers": "error", "useLiteralEnumMembers": "error",
"noCommaOperator": "error",
"noNegationElse": "error", "noNegationElse": "error",
"noYodaExpression": "error", "noYodaExpression": "error",
"useBlockStatements": "error", "useBlockStatements": "error",
@ -89,7 +88,6 @@
"accessibility": "explicit" "accessibility": "explicit"
} }
}, },
"noArguments": "error",
"useImportType": "error", "useImportType": "error",
"useExportType": "error", "useExportType": "error",
"noUselessElse": "error", "noUselessElse": "error",
@ -99,7 +97,15 @@
"noCommonJs": "warn", "noCommonJs": "warn",
"noExportedImports": "warn", "noExportedImports": "warn",
"noSubstr": "warn", "noSubstr": "warn",
"useTrimStartEnd": "warn" "useTrimStartEnd": "warn",
"noRestrictedImports": {
"options": {
"paths": {
"~/packages/": "Use the appropriate package instead of importing from the packages directory directly."
}
},
"level": "error"
}
}, },
"performance": { "performance": {
"noDynamicNamespaceImportAccess": "warn" "noDynamicNamespaceImportAccess": "warn"
@ -119,6 +125,7 @@
"noGlobalDirnameFilename": "error", "noGlobalDirnameFilename": "error",
"noProcessGlobal": "warn", "noProcessGlobal": "warn",
"noTsIgnore": "warn", "noTsIgnore": "warn",
"useReadonlyClassProperties": "error",
"useConsistentObjectDefinition": { "useConsistentObjectDefinition": {
"level": "warn", "level": "warn",
"options": { "options": {
@ -135,7 +142,9 @@
"noUselessEscapeInRegex": "warn", "noUselessEscapeInRegex": "warn",
"useSimplifiedLogicExpression": "error", "useSimplifiedLogicExpression": "error",
"useWhile": "error", "useWhile": "error",
"useNumericLiterals": "error" "useNumericLiterals": "error",
"noArguments": "error",
"noCommaOperator": "error"
}, },
"suspicious": { "suspicious": {
"noDuplicateTestHooks": "error", "noDuplicateTestHooks": "error",

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 process from "node:process";
import { $, build } from "bun"; import { $, build, file, write } from "bun";
import { routes } from "~/routes"; import manifest from "./package.json" with { type: "json" };
console.log("Building..."); console.log("Building...");
await $`rm -rf dist && mkdir dist`; await $`rm -rf dist && mkdir dist`;
// Get all directories under the plugins/ directory const type = process.argv[2] as "api" | "worker";
const pluginDirs = await readdir("plugins", { withFileTypes: true });
if (type !== "api" && type !== "worker") {
throw new Error("Invalid build type. Use 'api' or 'worker'.");
}
const packages = Object.keys(manifest.dependencies)
.filter((dep) => dep.startsWith("@versia"))
.filter((dep) => dep !== "@versia-server/tests");
await build({ await build({
entrypoints: [ entrypoints: [`./${type}.ts`],
"index.ts",
// HACK: Include to avoid cyclical import errors
"config.ts",
"cli/index.ts",
// Force Bun to include endpoints
...Object.values(routes),
// Include all plugins
...pluginDirs
.filter((dir) => dir.isDirectory())
.map((dir) => `plugins/${dir.name}/index.ts`),
],
outdir: "dist", outdir: "dist",
target: "bun", target: "bun",
splitting: true, splitting: true,
minify: false, minify: true,
external: ["acorn", "@bull-board/ui"], external: [...packages],
}); });
console.log("Copying files..."); console.log("Copying files...");
// Copy Drizzle migrations to dist // Copy each package into dist/node_modules
await $`cp -r drizzle dist/drizzle`; for (const pkg of packages) {
const directory = pkg.split("/")[1] || pkg;
await $`mkdir -p dist/node_modules/${pkg}`;
// Copy the built package files
await $`cp -rL packages/${directory}/{dist,package.json} dist/node_modules/${pkg}`;
// Copy plugin manifests // Rewrite package.json "exports" field to point to the dist directory and use .js extension
await $`cp plugins/openid/manifest.json dist/plugins/openid/manifest.json`; const packageJsonPath = `dist/node_modules/${pkg}/package.json`;
const packageJson = await file(packageJsonPath).json();
await $`mkdir -p dist/node_modules`; for (const [key, value] of Object.entries(packageJson.exports) as [
string,
// Copy Sharp to dist { import?: string },
await $`mkdir -p dist/node_modules/@img`; ][]) {
await $`cp -rL node_modules/@img/sharp-libvips-linux* dist/node_modules/@img`; if (value.import) {
await $`cp -rL node_modules/@img/sharp-linux* dist/node_modules/@img`; packageJson.exports[key] = {
import: value.import
// Copy acorn to dist .replace("./", "./dist/")
await $`cp -rL node_modules/acorn dist/node_modules/acorn`; .replace(/\.ts$/, ".js"),
};
// Copy bull-board to dist }
await $`mkdir -p dist/node_modules/@bull-board`; }
await $`cp -rL node_modules/@bull-board/ui dist/node_modules/@bull-board/ui`; await write(packageJsonPath, JSON.stringify(packageJson, null, 4));
}
// Copy the Bee Movie script from pages
await $`cp beemovie.txt dist/beemovie.txt`;
// Copy package.json
await $`cp package.json dist/package.json`;
// Fixes issues with sharp
await $`cp -rL node_modules/detect-libc dist/node_modules/`;
console.log("Build complete!"); console.log("Build complete!");

903
bun.lock

File diff suppressed because it is too large Load diff

View file

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

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 { describe, expect, it } from "bun:test";
import { mockModule } from "@versia-server/tests";
import sharp from "sharp"; import sharp from "sharp";
import { mockModule } from "~/tests/utils.ts";
import { calculateBlurhash } from "./blurhash.ts"; import { calculateBlurhash } from "./blurhash.ts";
describe("BlurhashPreprocessor", () => { describe("BlurhashPreprocessor", () => {

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,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 { helpPlugin } from "@clerc/plugin-help";
import { notFoundPlugin } from "@clerc/plugin-not-found"; import { notFoundPlugin } from "@clerc/plugin-not-found";
import { versionPlugin } from "@clerc/plugin-version"; import { versionPlugin } from "@clerc/plugin-version";
import { setupDatabase } from "@versia-server/kit/db";
import { searchManager } from "@versia-server/kit/search";
import { Clerc } from "clerc"; import { Clerc } from "clerc";
import { searchManager } from "~/classes/search/search-manager.ts"; import pkg from "../package.json" with { type: "json" };
import { setupDatabase } from "~/drizzle/db.ts";
import pkg from "~/package.json" with { type: "json" };
import { rebuildIndexCommand } from "./index/rebuild.ts"; import { rebuildIndexCommand } from "./index/rebuild.ts";
import { refetchInstanceCommand } from "./instance/refetch.ts"; import { refetchInstanceCommand } from "./instance/refetch.ts";
import { createUserCommand } from "./user/create.ts"; import { createUserCommand } from "./user/create.ts";

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 // @ts-expect-error - Root import is required or the Clec type definitions won't work
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work // biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
import { defineCommand, type Root } from "clerc"; import { defineCommand, type Root } from "clerc";
import ora from "ora"; import ora from "ora";
import {
SonicIndexType,
searchManager,
} from "~/classes/search/search-manager.ts";
import { config } from "~/config.ts";
export const rebuildIndexCommand = defineCommand( export const rebuildIndexCommand = defineCommand(
{ {

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"; import chalk from "chalk";
// @ts-expect-error - Root import is required or the Clec type definitions won't work // @ts-expect-error - Root import is required or the Clec type definitions won't work
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work // biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
import { defineCommand, type Root } from "clerc"; import { defineCommand, type Root } from "clerc";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { Instance } from "~/classes/database/instance.ts";
import { FetchJobType, fetchQueue } from "~/classes/queues/fetch.ts";
import { Instances } from "~/drizzle/schema.ts";
export const refetchInstanceCommand = defineCommand( export const refetchInstanceCommand = defineCommand(
{ {

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"; import chalk from "chalk";
// @ts-expect-error - Root import is required or the Clec type definitions won't work // @ts-expect-error - Root import is required or the Clec type definitions won't work
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work // biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
import { defineCommand, type Root } from "clerc"; import { defineCommand, type Root } from "clerc";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import { renderUnicodeCompact } from "uqr"; import { renderUnicodeCompact } from "uqr";
import { User } from "~/classes/database/user";
import { config } from "~/config";
import { Users } from "~/drizzle/schema";
export const createUserCommand = defineCommand( export const createUserCommand = defineCommand(
{ {
@ -54,6 +55,9 @@ export const createUserCommand = defineCommand(
isAdmin: admin, isAdmin: admin,
}); });
// Add to search index
await searchManager.addUser(user);
if (!user) { if (!user) {
throw new Error("Failed to create user."); throw new Error("Failed to create user.");
} }

View file

@ -1,9 +1,9 @@
import { User } from "@versia-server/kit/db";
import chalk from "chalk"; import chalk from "chalk";
// @ts-expect-error - Root import is required or the Clec type definitions won't work // @ts-expect-error - Root import is required or the Clec type definitions won't work
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work // biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
import { defineCommand, type Root } from "clerc"; import { defineCommand, type Root } from "clerc";
import ora from "ora"; import ora from "ora";
import { User } from "~/classes/database/user.ts";
import { retrieveUser } from "../utils.ts"; import { retrieveUser } from "../utils.ts";
export const refetchUserCommand = defineCommand( export const refetchUserCommand = defineCommand(

View file

@ -1,10 +1,10 @@
import { Token } from "@versia-server/kit/db";
import { randomUUIDv7 } from "bun"; import { randomUUIDv7 } from "bun";
import chalk from "chalk"; import chalk from "chalk";
// @ts-expect-error - Root import is required or the Clec type definitions won't work // @ts-expect-error - Root import is required or the Clec type definitions won't work
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work // biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
import { defineCommand, type Root } from "clerc"; import { defineCommand, type Root } from "clerc";
import { randomString } from "@/math.ts"; import { randomString } from "@/math.ts";
import { Token } from "~/classes/database/token.ts";
import { retrieveUser } from "../utils.ts"; import { retrieveUser } from "../utils.ts";
export const generateTokenCommand = defineCommand( export const generateTokenCommand = defineCommand(

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 { and, eq, isNull } from "drizzle-orm";
import { parseUserAddress } from "@/api";
import { Instance } from "~/classes/database/instance";
import { User } from "~/classes/database/user";
import { Users } from "~/drizzle/schema";
export const retrieveUser = async ( export const retrieveUser = async (
usernameOrHandle: string, usernameOrHandle: string,

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,31 +435,29 @@ text = "No spam"
[logging] [logging]
# Available levels: debug, info, warning, error, fatal # Available levels: trace, debug, info, warning, error, fatal
log_level = "debug" log_level = "info" # For console output
log_file_path = "logs/versia.log"
[logging.types]
# Either pass a boolean
# requests = true
# Or a table with the following keys:
# requests_content = { level = "debug", log_file_path = "logs/requests.log" }
# Available types are: requests, responses, requests_content, filters
# [logging.file]
# path = "logs/versia.log"
# log_level = "info"
#
# [logging.file.rotation]
# max_size = 10_000_000 # 10 MB
# max_files = 10 # Keep 10 rotated files
#
# https://sentry.io support # https://sentry.io support
# Uncomment to enable
# [logging.sentry] # [logging.sentry]
# Sentry DSN for error logging
# dsn = "https://example.com" # dsn = "https://example.com"
# debug = false # debug = false
# sample_rate = 1.0 # sample_rate = 1.0
# traces_sample_rate = 1.0 # traces_sample_rate = 1.0
# Can also be regex # Can also be regex
# trace_propagation_targets = [] # trace_propagation_targets = []
# max_breadcrumbs = 100 # max_breadcrumbs = 100
# environment = "production" # environment = "production"
# log_level = "info"
[plugins] [plugins]
# Whether to automatically load all plugins in the plugins directory # Whether to automatically load all plugins in the plugins directory

View file

@ -1,5 +1,5 @@
import { config } from "@versia-server/config";
import type { Config } from "drizzle-kit"; import type { Config } from "drizzle-kit";
import { config } from "~/config.ts";
/** /**
* Drizzle can't properly resolve imports with top-level await, so uncomment * Drizzle can't properly resolve imports with top-level await, so uncomment

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": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1744536153, "lastModified": 1751637120,
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=", "narHash": "sha256-xVNy/XopSfIG9c46nRmPaKfH1Gn/56vQ8++xWA8itO4=",
"owner": "nixos", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11", "rev": "5c724ed1388e53cc231ed98330a60eb2f7be4be3",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "nixos", "owner": "NixOS",
"ref": "nixpkgs-unstable", "ref": "nixos-unstable",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }

View file

@ -2,7 +2,7 @@
description = "Versia Server"; description = "Versia Server";
inputs = { inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils"; flake-utils.url = "github:numtide/flake-utils";
}; };
@ -15,7 +15,9 @@
}: }:
{ {
overlays.default = final: prev: rec { overlays.default = final: prev: rec {
versia-server = final.callPackage ./nix/package.nix {}; versia-server =
final.callPackage ./nix/package.nix {
};
versia-server-worker = final.callPackage ./nix/package-worker.nix { versia-server-worker = final.callPackage ./nix/package-worker.nix {
inherit versia-server; inherit versia-server;
}; };
@ -54,7 +56,6 @@
buildInputs = with pkgs; [ buildInputs = with pkgs; [
bun bun
vips vips
pnpm
nodePackages.typescript nodePackages.typescript
nodePackages.typescript-language-server nodePackages.typescript-language-server
nix-ld nix-ld

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, ...}:
versia-server.overrideAttrs (oldAttrs: { versia-server.overrideAttrs (oldAttrs: {
pname = "${oldAttrs.pname}-worker"; pname = "${oldAttrs.pname}-worker";
buildPhase = ''
runHook preBuild
bun run build:worker buildType = "worker";
runHook postBuild
'';
entrypointPath = "worker.js";
meta = meta =
oldAttrs.meta oldAttrs.meta

View file

@ -1,11 +1,12 @@
{ {
lib, lib,
stdenv, stdenv,
pnpm,
bun, bun,
nodejs, nodejs,
vips, vips,
makeWrapper, makeWrapper,
stdenvNoCC,
writableTmpDirAsHomeHook,
... ...
}: let }: let
packageJson = builtins.fromJSON (builtins.readFile ../package.json); packageJson = builtins.fromJSON (builtins.readFile ../package.json);
@ -16,35 +17,70 @@ in
src = ../.; src = ../.;
# Fixes the build script mv usage node_modules = stdenvNoCC.mkDerivation {
pnpmInstallFlags = ["--shamefully-hoist"]; pname = "${finalAttrs.pname}-node_modules";
inherit (finalAttrs) version src;
pnpmDeps = pnpm.fetchDeps { nativeBuildInputs = [
inherit (finalAttrs) pname version src pnpmInstallFlags; bun
hash = "sha256-/VCzDp8EfvQkaz/5W3rcoEyOlSB4zeW97qqOTJf6WvA="; nodejs
writableTmpDirAsHomeHook
];
dontConfigure = true;
buildPhase = ''
runHook preBuild
export BUN_INSTALL_CACHE_DIR=$(mktemp -d)
bun install \
--force \
--frozen-lockfile \
--no-progress
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/node_modules
cp -R ./node_modules $out
runHook postInstall
'';
# Required else we get errors that our fixed-output derivation references store paths
dontFixup = true;
outputHash = "sha256-/RQv87hjLdH6+41yR7+bGp3j200DVhIrKWoI1MKIqJs=";
outputHashAlgo = "sha256";
outputHashMode = "recursive";
}; };
nativeBuildInputs = [ nativeBuildInputs = [
pnpm
pnpm.configHook
bun bun
nodejs
makeWrapper makeWrapper
]; ];
buildInputs = [ configurePhase = ''
vips runHook preConfigure
];
cp -R ${finalAttrs.node_modules}/node_modules .
runHook postConfigure
'';
buildPhase = '' buildPhase = ''
runHook preBuild runHook preBuild
bun run build bun run build ${finalAttrs.buildType}
runHook postBuild runHook postBuild
''; '';
entrypointPath = "index.js"; buildType = "api";
installPhase = let installPhase = let
libPath = lib.makeLibraryPath [ libPath = lib.makeLibraryPath [
@ -62,7 +98,7 @@ in
cp -r dist $out/${finalAttrs.pname} cp -r dist $out/${finalAttrs.pname}
makeWrapper ${lib.getExe bun} $out/bin/${finalAttrs.pname} \ makeWrapper ${lib.getExe bun} $out/bin/${finalAttrs.pname} \
--add-flags "run $out/${finalAttrs.pname}/${finalAttrs.entrypointPath}" \ --add-flags "run $out/${finalAttrs.pname}/${finalAttrs.buildType}.js" \
--set NODE_PATH $out/${finalAttrs.pname}/node_modules \ --set NODE_PATH $out/${finalAttrs.pname}/node_modules \
--set MSGPACKR_NATIVE_ACCELERATION_DISABLED true \ --set MSGPACKR_NATIVE_ACCELERATION_DISABLED true \
--prefix PATH : ${binPath} \ --prefix PATH : ${binPath} \

View file

@ -20,9 +20,88 @@
"activitypub", "activitypub",
"bun" "bun"
], ],
"workspaces": [ "workspaces": {
"packages/*" "packages": [
], "packages/*"
],
"catalog": {
"@biomejs/biome": "^2.0.6",
"@types/bun": "^1.2.18",
"@types/html-to-text": "^9.0.4",
"@types/markdown-it-container": "^2.0.10",
"@types/mime-types": "^3.0.1",
"@types/qs": "^6.14.0",
"@types/web-push": "^3.6.4",
"bun-bagel": "^1.2.0",
"drizzle-kit": "^0.31.4",
"mitt": "^3.0.1",
"markdown-it-image-figures": "^2.1.1",
"ts-prune": "^0.10.3",
"typescript": "^5.8.3",
"vitepress": "^1.6.3",
"vitepress-plugin-tabs": "^0.7.1",
"vitepress-sidebar": "^1.32.1",
"vue": "^3.5.17",
"@bull-board/api": "^6.11.0",
"@bull-board/hono": "^6.11.0",
"@clerc/plugin-completions": "^0.44.0",
"@clerc/plugin-friendly-error": "^0.44.0",
"@clerc/plugin-help": "^0.44.0",
"@clerc/plugin-not-found": "^0.44.0",
"@clerc/plugin-version": "^0.44.0",
"@hackmd/markdown-it-task-lists": "^2.1.4",
"@hono/standard-validator": "^0.1.2",
"@inquirer/confirm": "^5.1.13",
"@logtape/file": "^1.0.0",
"@logtape/logtape": "^1.0.0",
"@logtape/sentry": "^1.0.0",
"@logtape/otel": "^1.0.0",
"@scalar/hono-api-reference": "^0.9.7",
"@sentry/bun": "^9.35.0",
"altcha-lib": "^1.3.0",
"blurhash": "^2.0.5",
"bullmq": "^5.56.1",
"chalk": "^5.4.1",
"clerc": "^0.44.0",
"confbox": "^0.2.2",
"drizzle-orm": "^0.44.2",
"feed": "^5.1.0",
"hono": "^4.8.4",
"hono-openapi": "npm:@cpluspatch/hono-openapi@0.5.1",
"hono-rate-limiter": "^0.4.2",
"html-to-text": "^9.0.5",
"ioredis": "^5.6.1",
"ip-matching": "^2.1.2",
"iso-639-1": "^3.1.5",
"jose": "^6.0.11",
"linkify-html": "^4.3.1",
"linkify-string": "^4.3.1",
"linkifyjs": "^4.3.1",
"magic-regexp": "^0.10.0",
"markdown-it": "^14.1.0",
"markdown-it-anchor": "^9.2.0",
"markdown-it-container": "^4.0.0",
"markdown-it-mathjax3": "^4.3.2",
"markdown-it-toc-done-right": "^4.2.0",
"mime-types": "^3.0.1",
"mitata": "^1.0.34",
"oauth4webapi": "^3.5.5",
"ora": "^8.2.0",
"qs": "^6.14.0",
"sharp": "^0.34.2",
"sonic-channel": "^1.3.1",
"string-comparison": "^1.3.0",
"stringify-entities": "^4.0.4",
"unicode-emoji-json": "^0.8.0",
"uqr": "^0.1.2",
"web-push": "^3.6.7",
"xss": "^1.0.15",
"youch": "^4.1.0-beta.7",
"zod": "^3.25.74",
"zod-openapi": "^5.0.0",
"zod-validation-error": "^4.0.0-beta.1"
}
},
"maintainers": [ "maintainers": [
{ {
"email": "contact@cpluspatch.com", "email": "contact@cpluspatch.com",
@ -36,107 +115,107 @@
}, },
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "bun run --hot index.ts",
"start": "NODE_ENV=production bun run dist/index.js --prod",
"lint": "biome check .", "lint": "biome check .",
"build": "bun run build.ts",
"build:worker": "bun run build-worker.ts",
"cloc": "cloc . --exclude-dir node_modules,dist,.output,.nuxt,meta,logs --exclude-ext sql,log,pem", "cloc": "cloc . --exclude-dir node_modules,dist,.output,.nuxt,meta,logs --exclude-ext sql,log,pem",
"wc": "find server database *.ts docs packages types utils drizzle tests -type f -print0 | wc -m --files0-from=-",
"cli": "bun run cli/index.ts", "cli": "bun run cli/index.ts",
"prune": "ts-prune | grep -v server/ | grep -v dist/ | grep -v '(used in module)'", "typecheck": "bunx tsc -p .",
"schema:generate": "bun run classes/config/to-json-schema.ts > config/config.schema.json && bun run packages/plugin-kit/json-schema.ts > packages/plugin-kit/manifest.schema.json",
"check": "bunx tsc -p .",
"test": "bun test", "test": "bun test",
"docs:dev": "vitepress dev docs", "build": "bun run --filter \"*\" build && bun run build.ts",
"docs:build": "vitepress build docs", "detect-circular": "bunx madge --circular --extensions ts ./",
"docs:preview": "vitepress preview docs" "update-nix-hashes": "bash scripts/update-nix.sh",
"run-api": "bun run build api && cd dist && ln -s ../config config && bun run api.js",
"run-worker": "bun run build worker && cd dist && ln -s ../config config && bun run worker.js",
"dev": "bun run --hot api.ts",
"worker:dev": "bun run --hot worker.ts"
}, },
"trustedDependencies": [ "trustedDependencies": [
"@biomejs/biome", "@biomejs/biome",
"es5-ext", "es5-ext",
"esbuild", "esbuild",
"msgpackr-extract", "msgpackr-extract",
"protobufjs",
"sharp" "sharp"
], ],
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.0.0-beta.5", "@biomejs/biome": "catalog:",
"@types/bun": "^1.2.16", "@types/bun": "catalog:",
"@types/html-to-text": "^9.0.4", "@types/html-to-text": "catalog:",
"@types/markdown-it-container": "^2.0.10", "@types/markdown-it-container": "catalog:",
"@types/mime-types": "^3.0.1", "@types/mime-types": "catalog:",
"@types/qs": "^6.14.0", "@types/qs": "catalog:",
"@types/web-push": "^3.6.4", "@types/web-push": "catalog:",
"bun-bagel": "^1.2.0", "bun-bagel": "catalog:",
"drizzle-kit": "^0.31.1", "drizzle-kit": "catalog:",
"markdown-it-image-figures": "^2.1.1", "markdown-it-image-figures": "catalog:",
"ts-prune": "^0.10.3", "ts-prune": "catalog:",
"typescript": "^5.8.3", "typescript": "catalog:",
"vitepress": "^1.6.3", "vitepress": "catalog:",
"vitepress-plugin-tabs": "^0.7.1", "vitepress-plugin-tabs": "catalog:",
"vitepress-sidebar": "^1.31.1", "vitepress-sidebar": "catalog:",
"vue": "^3.5.16", "vue": "catalog:"
"zod-to-json-schema": "^3.24.5"
}, },
"dependencies": { "dependencies": {
"@bull-board/api": "^6.10.1", "@bull-board/api": "catalog:",
"@bull-board/hono": "^6.10.1", "@bull-board/hono": "catalog:",
"@clerc/plugin-completions": "^0.44.0", "@clerc/plugin-completions": "catalog:",
"@clerc/plugin-friendly-error": "^0.44.0", "@clerc/plugin-friendly-error": "catalog:",
"@clerc/plugin-help": "^0.44.0", "@clerc/plugin-help": "catalog:",
"@clerc/plugin-not-found": "^0.44.0", "@clerc/plugin-not-found": "catalog:",
"@clerc/plugin-version": "^0.44.0", "@clerc/plugin-version": "catalog:",
"@hackmd/markdown-it-task-lists": "^2.1.4", "@hackmd/markdown-it-task-lists": "catalog:",
"@hono/zod-validator": "^0.7.0", "@hono/standard-validator": "catalog:",
"@inquirer/confirm": "^5.1.12", "@inquirer/confirm": "catalog:",
"@logtape/file": "^0.12.0", "@scalar/hono-api-reference": "catalog:",
"@logtape/logtape": "^0.12.0", "@sentry/bun": "catalog:",
"@scalar/hono-api-reference": "^0.9.4", "@versia-server/api": "workspace:*",
"@sentry/bun": "^9.29.0", "@versia-server/config": "workspace:*",
"@versia-server/kit": "workspace:*",
"@versia-server/logging": "workspace:*",
"@versia-server/tests": "workspace:*",
"@versia-server/worker": "workspace:*",
"@versia/client": "workspace:*", "@versia/client": "workspace:*",
"@versia/kit": "workspace:*",
"@versia/sdk": "workspace:*", "@versia/sdk": "workspace:*",
"altcha-lib": "^1.3.0", "altcha-lib": "catalog:",
"blurhash": "^2.0.5", "blurhash": "catalog:",
"bullmq": "^5.53.3", "bullmq": "catalog:",
"chalk": "^5.4.1", "chalk": "catalog:",
"clerc": "^0.44.0", "clerc": "catalog:",
"confbox": "^0.2.2", "confbox": "catalog:",
"drizzle-orm": "^0.44.2", "drizzle-orm": "catalog:",
"feed": "^5.1.0", "feed": "catalog:",
"hono": "^4.7.11", "hono": "catalog:",
"hono-openapi": "^0.4.8", "hono-openapi": "catalog:",
"hono-rate-limiter": "^0.4.2", "hono-rate-limiter": "catalog:",
"html-to-text": "^9.0.5", "html-to-text": "catalog:",
"ioredis": "^5.6.1", "ioredis": "catalog:",
"ip-matching": "^2.1.2", "ip-matching": "catalog:",
"iso-639-1": "^3.1.5", "iso-639-1": "catalog:",
"jose": "^6.0.11", "jose": "catalog:",
"linkify-html": "^4.3.1", "linkify-html": "catalog:",
"linkify-string": "^4.3.1", "linkify-string": "catalog:",
"linkifyjs": "^4.3.1", "linkifyjs": "catalog:",
"magic-regexp": "^0.10.0", "magic-regexp": "catalog:",
"markdown-it": "^14.1.0", "markdown-it": "catalog:",
"markdown-it-anchor": "^9.2.0", "markdown-it-anchor": "catalog:",
"markdown-it-container": "^4.0.0", "markdown-it-container": "catalog:",
"markdown-it-mathjax3": "^4.3.2", "markdown-it-mathjax3": "catalog:",
"markdown-it-toc-done-right": "^4.2.0", "markdown-it-toc-done-right": "catalog:",
"mime-types": "^3.0.1", "mime-types": "catalog:",
"mitata": "^1.0.34", "mitata": "catalog:",
"oauth4webapi": "^3.5.2", "oauth4webapi": "catalog:",
"ora": "^8.2.0", "ora": "catalog:",
"qs": "^6.14.0", "qs": "catalog:",
"sharp": "^0.34.2", "sharp": "catalog:",
"sonic-channel": "^1.3.1", "sonic-channel": "catalog:",
"string-comparison": "^1.3.0", "string-comparison": "catalog:",
"stringify-entities": "^4.0.4", "stringify-entities": "catalog:",
"unicode-emoji-json": "^0.8.0", "unicode-emoji-json": "catalog:",
"uqr": "^0.1.2", "uqr": "catalog:",
"web-push": "^3.6.7", "web-push": "catalog:",
"xss": "^1.0.15", "xss": "catalog:",
"youch": "^4.1.0-beta.7", "youch": "catalog:",
"zod": "^3.25.64", "zod": "catalog:",
"zod-openapi": "^4.2.4", "zod-openapi": "catalog:",
"zod-validation-error": "^3.5.0" "zod-validation-error": "catalog:"
} }
} }

View file

@ -1,6 +1,8 @@
import { resolve } from "node:path"; import { join } from "node:path";
import { getLogger } from "@logtape/logtape";
import { Scalar } from "@scalar/hono-api-reference"; import { Scalar } from "@scalar/hono-api-reference";
import { config } from "@versia-server/config";
import { ApiError } from "@versia-server/kit";
import { serverLogger } from "@versia-server/logging";
import chalk from "chalk"; import chalk from "chalk";
import { Hono } from "hono"; import { Hono } from "hono";
import { serveStatic } from "hono/bun"; import { serveStatic } from "hono/bun";
@ -8,29 +10,20 @@ import { cors } from "hono/cors";
import { createMiddleware } from "hono/factory"; import { createMiddleware } from "hono/factory";
import { prettyJSON } from "hono/pretty-json"; import { prettyJSON } from "hono/pretty-json";
import { secureHeaders } from "hono/secure-headers"; import { secureHeaders } from "hono/secure-headers";
import { openAPISpecs } from "hono-openapi"; import { generateSpecs } from "hono-openapi";
import { Youch } from "youch"; import { Youch } from "youch";
import { applyToHono } from "@/bull-board.ts"; import { applyToHono } from "@/bull-board.ts";
import { configureLoggers } from "@/loggers"; import pkg from "../../package.json" with { type: "application/json" };
import { sentry } from "@/sentry"; import type { ApiRouteExports, HonoEnv } from "../../types/api.ts";
import { config } from "~/config.ts";
import pkg from "~/package.json" with { type: "application/json" };
import { ApiError } from "./classes/errors/api-error.ts";
import { PluginLoader } from "./classes/plugin/loader.ts";
import { agentBans } from "./middlewares/agent-bans.ts"; import { agentBans } from "./middlewares/agent-bans.ts";
import { boundaryCheck } from "./middlewares/boundary-check.ts"; import { boundaryCheck } from "./middlewares/boundary-check.ts";
import { ipBans } from "./middlewares/ip-bans.ts"; import { ipBans } from "./middlewares/ip-bans.ts";
import { logger } from "./middlewares/logger.ts"; import { logger } from "./middlewares/logger.ts";
import { rateLimit } from "./middlewares/rate-limit.ts"; import { rateLimit } from "./middlewares/rate-limit.ts";
import { PluginLoader } from "./plugin-loader.ts";
import { routes } from "./routes.ts"; import { routes } from "./routes.ts";
import type { ApiRouteExports, HonoEnv } from "./types/api.ts";
// Extends Zod with OpenAPI schema generation
import "zod-openapi/extend";
export const appFactory = async (): Promise<Hono<HonoEnv>> => { export const appFactory = async (): Promise<Hono<HonoEnv>> => {
await configureLoggers();
const serverLogger = getLogger("server");
const app = new Hono<HonoEnv>({ const app = new Hono<HonoEnv>({
strict: false, strict: false,
}); });
@ -118,13 +111,13 @@ export const appFactory = async (): Promise<Hono<HonoEnv>> => {
const loader = new PluginLoader(); const loader = new PluginLoader();
const plugins = await loader.loadPlugins( const plugins = await loader.loadPlugins(
resolve("./plugins"), join(import.meta.dir, "plugins"),
config.plugins?.autoload ?? true, config.plugins?.autoload ?? true,
config.plugins?.overrides.enabled, config.plugins?.overrides.enabled,
config.plugins?.overrides.disabled, config.plugins?.overrides.disabled,
); );
await PluginLoader.addToApp(plugins, app, serverLogger); await PluginLoader.addToApp(plugins, app);
const time2 = performance.now(); const time2 = performance.now();
@ -132,22 +125,23 @@ export const appFactory = async (): Promise<Hono<HonoEnv>> => {
(time2 - time1).toFixed(2), (time2 - time1).toFixed(2),
)}ms`}`; )}ms`}`;
app.get( const openApiSpecs = await generateSpecs(app, {
"/openapi.json", documentation: {
openAPISpecs(app, { info: {
documentation: { title: "Versia Server API",
info: { version: pkg.version,
title: "Versia Server API", license: {
version: pkg.version, name: "AGPL-3.0",
license: { url: "https://www.gnu.org/licenses/agpl-3.0.html",
name: "AGPL-3.0",
url: "https://www.gnu.org/licenses/agpl-3.0.html",
},
contact: pkg.author,
}, },
contact: pkg.author,
}, },
}), },
); });
app.get("/openapi.json", (context) => {
return context.json(openApiSpecs, 200);
});
app.get( app.get(
"/docs", "/docs",
@ -193,7 +187,6 @@ export const appFactory = async (): Promise<Hono<HonoEnv>> => {
const youch = new Youch(); const youch = new Youch();
console.error(await youch.toANSI(error)); console.error(await youch.toANSI(error));
sentry?.captureException(error);
return c.json( return c.json(
{ {
error: "A server error occured", error: "A server error occured",

49
packages/api/build.ts Normal file
View file

@ -0,0 +1,49 @@
import { readdir } from "node:fs/promises";
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`;
// Get all directories under the plugins/ directory
const pluginDirs = await readdir("plugins", { withFileTypes: true });
await build({
entrypoints: [
...Object.values(manifest.exports).map((entry) => entry.import),
// Force Bun to include endpoints
...Object.values(routes),
// Include all plugins
...pluginDirs
.filter((dir) => dir.isDirectory())
.map((dir) => `plugins/${dir.name}/index.ts`),
],
outdir: "dist",
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...");
// Copy plugin manifests
await $`cp plugins/openid/manifest.json dist/plugins/openid/manifest.json`;
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 { createMiddleware } from "hono/factory";
import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/config.ts";
export const agentBans = createMiddleware(async (context, next) => { export const agentBans = createMiddleware(async (context, next) => {
// Check for banned user agents (regex) // Check for banned user agents (regex)

View file

@ -1,5 +1,5 @@
import { ApiError } from "@versia-server/kit";
import { createMiddleware } from "hono/factory"; import { createMiddleware } from "hono/factory";
import { ApiError } from "~/classes/errors/api-error";
export const boundaryCheck = createMiddleware(async (context, next) => { export const boundaryCheck = createMiddleware(async (context, next) => {
// Checks that FormData boundary is present // Checks that FormData boundary is present

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 type { SocketAddress } from "bun";
import { createMiddleware } from "hono/factory"; import { createMiddleware } from "hono/factory";
import { matches } from "ip-matching"; import { matches } from "ip-matching";
import { sentry } from "@/sentry";
import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/config.ts";
export const ipBans = createMiddleware(async (context, next) => { export const ipBans = createMiddleware(async (context, next) => {
// Check for banned IPs // Check for banned IPs
@ -22,11 +21,8 @@ export const ipBans = createMiddleware(async (context, next) => {
throw new ApiError(403, "Forbidden"); throw new ApiError(403, "Forbidden");
} }
} catch (e) { } catch (e) {
const logger = getLogger("server"); serverLogger.error`Error while parsing banned IP "${ip}" `;
serverLogger.error`${e}`;
logger.error`Error while parsing banned IP "${ip}" `;
logger.error`${e}`;
sentry?.captureException(e);
return context.json( return context.json(
{ error: `A server error occured: ${(e as Error).message}` }, { error: `A server error occured: ${(e as Error).message}` },

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 { env } from "bun";
import type { MiddlewareHandler } from "hono"; import type { MiddlewareHandler } from "hono";
import { rateLimiter } from "hono-rate-limiter"; import { rateLimiter } from "hono-rate-limiter";
import type { z } from "zod"; import type { z } from "zod/v4";
import type { ApiError } from "~/classes/errors/api-error";
import type { HonoEnv } from "~/types/api"; import type { HonoEnv } from "~/types/api";
// Not exported by hono-rate-limiter // Not exported by hono-rate-limiter

View file

@ -1,5 +1,5 @@
import { config } from "@versia-server/config";
import { createMiddleware } from "hono/factory"; import { createMiddleware } from "hono/factory";
import { config } from "~/config.ts";
export const urlCheck = createMiddleware(async (context, next) => { export const urlCheck = createMiddleware(async (context, next) => {
// Check that request URL matches base_url // Check that request URL matches base_url

82
packages/api/package.json Normal file
View file

@ -0,0 +1,82 @@
{
"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",
"schema:generate": "bun run classes/config/to-json-schema.ts > config/config.schema.json && bun run packages/kit/json-schema.ts > packages/kit/manifest.schema.json",
"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,22 +1,19 @@
import { readdir } from "node:fs/promises"; import { readdir } from "node:fs/promises";
import { getLogger, type Logger } from "@logtape/logtape"; import { config } from "@versia-server/config";
import { type Manifest, manifestSchema, Plugin } from "@versia-server/kit";
import { pluginLogger, serverLogger } from "@versia-server/logging";
import { file, sleep } from "bun"; import { file, sleep } from "bun";
import chalk from "chalk"; import chalk from "chalk";
import { parseJSON5, parseJSONC } from "confbox"; import { parseJSON5, parseJSONC } from "confbox";
import type { Hono } from "hono"; import type { Hono } from "hono";
import type { ZodTypeAny } from "zod"; import type { ZodTypeAny } from "zod/v4";
import { fromZodError, type ValidationError } from "zod-validation-error"; 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"; import type { HonoEnv } from "~/types/api";
/** /**
* Class to manage plugins. * Class to manage plugins.
*/ */
export class PluginLoader { export class PluginLoader {
private logger = getLogger("plugin");
/** /**
* Get all directories in a given directory. * Get all directories in a given directory.
* @param {string} dir - The directory to search. * @param {string} dir - The directory to search.
@ -56,7 +53,7 @@ export class PluginLoader {
* @returns {Promise<unknown>} - The parsed manifest content. * @returns {Promise<unknown>} - The parsed manifest content.
* @throws Will throw an error if the manifest file cannot be parsed. * @throws Will throw an error if the manifest file cannot be parsed.
*/ */
private async parseManifestFile( private static async parseManifestFile(
manifestPath: string, manifestPath: string,
manifestFile: string, manifestFile: string,
): Promise<unknown> { ): Promise<unknown> {
@ -75,8 +72,7 @@ export class PluginLoader {
throw new Error(`Unsupported manifest file type: ${manifestFile}`); throw new Error(`Unsupported manifest file type: ${manifestFile}`);
} catch (e) { } catch (e) {
this.logger pluginLogger.fatal`Could not parse plugin manifest ${chalk.blue(manifestPath)} as ${manifestFile.split(".").pop()?.toUpperCase()}.`;
.fatal`Could not parse plugin manifest ${chalk.blue(manifestPath)} as ${manifestFile.split(".").pop()?.toUpperCase()}.`;
throw e; throw e;
} }
} }
@ -112,7 +108,10 @@ export class PluginLoader {
* @returns {Promise<Manifest>} - The parsed manifest object. * @returns {Promise<Manifest>} - The parsed manifest object.
* @throws Will throw an error if the manifest file is missing or invalid. * @throws Will throw an error if the manifest file is missing or invalid.
*/ */
public async parseManifest(dir: string, plugin: string): Promise<Manifest> { public static async parseManifest(
dir: string,
plugin: string,
): Promise<Manifest> {
const manifestFile = await PluginLoader.findManifestFile( const manifestFile = await PluginLoader.findManifestFile(
`${dir}/${plugin}`, `${dir}/${plugin}`,
); );
@ -122,7 +121,7 @@ export class PluginLoader {
} }
const manifestPath = `${dir}/${plugin}/${manifestFile}`; const manifestPath = `${dir}/${plugin}/${manifestFile}`;
const manifest = await this.parseManifestFile( const manifest = await PluginLoader.parseManifestFile(
manifestPath, manifestPath,
manifestFile, manifestFile,
); );
@ -130,8 +129,7 @@ export class PluginLoader {
const result = await manifestSchema.safeParseAsync(manifest); const result = await manifestSchema.safeParseAsync(manifest);
if (!result.success) { if (!result.success) {
this.logger pluginLogger.fatal`Plugin manifest ${chalk.blue(manifestPath)} is invalid.`;
.fatal`Plugin manifest ${chalk.blue(manifestPath)} is invalid.`;
throw fromZodError(result.error); throw fromZodError(result.error);
} }
@ -145,7 +143,7 @@ export class PluginLoader {
* @returns {Promise<Plugin<ZodTypeAny>>} - The loaded Plugin instance. * @returns {Promise<Plugin<ZodTypeAny>>} - The loaded Plugin instance.
* @throws Will throw an error if the entrypoint's default export is not a Plugin. * @throws Will throw an error if the entrypoint's default export is not a Plugin.
*/ */
public async loadPlugin( public static async loadPlugin(
dir: string, dir: string,
entrypoint: string, entrypoint: string,
): Promise<Plugin<ZodTypeAny>> { ): Promise<Plugin<ZodTypeAny>> {
@ -155,8 +153,7 @@ export class PluginLoader {
return plugin; return plugin;
} }
this.logger pluginLogger.fatal`Default export of entrypoint ${chalk.blue(entrypoint)} at ${chalk.blue(dir)} is not a Plugin.`;
.fatal`Default export of entrypoint ${chalk.blue(entrypoint)} at ${chalk.blue(dir)} is not a Plugin.`;
throw new Error("Entrypoint is not a Plugin"); throw new Error("Entrypoint is not a Plugin");
} }
@ -178,14 +175,13 @@ export class PluginLoader {
const disabledOn = (disabled?.length ?? 0) > 0; const disabledOn = (disabled?.length ?? 0) > 0;
if (enabledOn && disabledOn) { if (enabledOn && disabledOn) {
this.logger pluginLogger.fatal`Both enabled and disabled lists are specified. Only one of them can be used.`;
.fatal`Both enabled and disabled lists are specified. Only one of them can be used.`;
throw new Error("Invalid configuration"); throw new Error("Invalid configuration");
} }
return Promise.all( return Promise.all(
plugins.map(async (plugin) => { plugins.map(async (plugin) => {
const manifest = await this.parseManifest(dir, plugin); const manifest = await PluginLoader.parseManifest(dir, plugin);
// If autoload is disabled, only load plugins explicitly enabled // If autoload is disabled, only load plugins explicitly enabled
if ( if (
@ -204,7 +200,7 @@ export class PluginLoader {
return null; return null;
} }
const pluginInstance = await this.loadPlugin( const pluginInstance = await PluginLoader.loadPlugin(
dir, dir,
`${plugin}/index`, `${plugin}/index`,
); );
@ -220,10 +216,9 @@ export class PluginLoader {
plugin: Plugin<ZodTypeAny>; plugin: Plugin<ZodTypeAny>;
}[], }[],
app: Hono<HonoEnv>, app: Hono<HonoEnv>,
logger: Logger,
): Promise<void> { ): Promise<void> {
for (const data of plugins) { 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}]`)}`; serverLogger.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(); const time1 = performance.now();
@ -233,13 +228,13 @@ export class PluginLoader {
config.plugins?.config?.[data.manifest.name], config.plugins?.config?.[data.manifest.name],
); );
} catch (e) { } catch (e) {
logger.fatal`Error encountered while loading plugin ${chalk.blueBright(data.manifest.name)} ${chalk.blueBright(data.manifest.version)} configuration.`; serverLogger.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.`; serverLogger.fatal`This is due to invalid, missing or incomplete configuration.`;
logger.fatal`Put your configuration at ${chalk.blueBright( serverLogger.fatal`Put your configuration at ${chalk.blueBright(
"plugins.config.<plugin-name>", "plugins.config.<plugin-name>",
)}`; )}`;
logger.fatal`Here is the error message, please fix the configuration file accordingly:`; serverLogger.fatal`Here is the error message, please fix the configuration file accordingly:`;
logger.fatal`${(e as ValidationError).message}`; serverLogger.fatal`${(e as ValidationError).message}`;
await sleep(Number.POSITIVE_INFINITY); await sleep(Number.POSITIVE_INFINITY);
} }
@ -251,7 +246,7 @@ export class PluginLoader {
const time3 = performance.now(); const time3 = performance.now();
logger.info`Plugin ${chalk.blueBright(data.manifest.name)} ${chalk.blueBright( serverLogger.info`Plugin ${chalk.blueBright(data.manifest.name)} ${chalk.blueBright(
data.manifest.version, data.manifest.version,
)} loaded in ${chalk.gray( )} loaded in ${chalk.gray(
`${(time2 - time1).toFixed(2)}ms`, `${(time2 - time1).toFixed(2)}ms`,

View file

@ -1,12 +1,11 @@
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import { Hooks, Plugin } from "@versia/kit"; import { keyPair, sensitiveString, url } from "@versia-server/config";
import { User } from "@versia/kit/db"; import { ApiError, Hooks, Plugin } from "@versia-server/kit";
import { User } from "@versia-server/kit/db";
import { getCookie } from "hono/cookie"; import { getCookie } from "hono/cookie";
import { jwtVerify } from "jose"; import { jwtVerify } from "jose";
import { JOSEError, JWTExpired } from "jose/errors"; import { JOSEError, JWTExpired } from "jose/errors";
import { z } from "zod"; import { z } from "zod/v4";
import { keyPair, sensitiveString, url } from "~/classes/config/schema.ts";
import { ApiError } from "~/classes/errors/api-error.ts";
import authorizeRoute from "./routes/authorize.ts"; import authorizeRoute from "./routes/authorize.ts";
import jwksRoute from "./routes/jwks.ts"; import jwksRoute from "./routes/jwks.ts";
import ssoLoginCallbackRoute from "./routes/oauth/callback.ts"; import ssoLoginCallbackRoute from "./routes/oauth/callback.ts";

View file

@ -1,5 +1,5 @@
{ {
"$schema": "https://raw.githubusercontent.com/versia-pub/server/refs/heads/main/packages/plugin-kit/manifest.schema.json", "$schema": "https://raw.githubusercontent.com/versia-pub/server/refs/heads/main/packages/kit/manifest.schema.json",
"name": "@versia/openid", "name": "@versia/openid",
"description": "OpenID authentication.", "description": "OpenID authentication.",
"version": "0.1.0", "version": "0.1.0",

View file

@ -1,11 +1,11 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, test } from "bun:test";
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import { Application } from "@versia/kit/db"; import { config } from "@versia-server/config";
import { Application } from "@versia-server/kit/db";
import { fakeRequest, getTestUsers } from "@versia-server/tests";
import { randomUUIDv7 } from "bun"; import { randomUUIDv7 } from "bun";
import { SignJWT } from "jose"; import { SignJWT } from "jose";
import { randomString } from "@/math"; import { randomString } from "@/math";
import { config } from "~/config.ts";
import { fakeRequest, getTestUsers } from "~/tests/utils";
const { deleteUsers, tokens, users } = await getTestUsers(1); const { deleteUsers, tokens, users } = await getTestUsers(1);
const privateKey = await crypto.subtle.importKey( const privateKey = await crypto.subtle.importKey(

View file

@ -1,12 +1,11 @@
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import { Application, Token, User } from "@versia/kit/db"; import { auth, handleZodError, jsonOrForm } from "@versia-server/kit/api";
import { Application, Token, User } from "@versia-server/kit/db";
import { randomUUIDv7 } from "bun"; import { randomUUIDv7 } from "bun";
import { describeRoute } from "hono-openapi"; import { describeRoute, validator } from "hono-openapi";
import { validator } from "hono-openapi/zod";
import { type JWTPayload, jwtVerify, SignJWT } from "jose"; import { type JWTPayload, jwtVerify, SignJWT } from "jose";
import { JOSEError } from "jose/errors"; import { JOSEError } from "jose/errors";
import { z } from "zod"; import { z } from "zod/v4";
import { auth, handleZodError, jsonOrForm } from "@/api";
import { randomString } from "@/math"; import { randomString } from "@/math";
import { errorRedirect, errors } from "../errors.ts"; import { errorRedirect, errors } from "../errors.ts";
import type { PluginType } from "../index.ts"; import type { PluginType } from "../index.ts";
@ -50,7 +49,6 @@ export default (plugin: PluginType): void =>
.object({ .object({
scope: z.string().optional(), scope: z.string().optional(),
redirect_uri: z redirect_uri: z
.string()
.url() .url()
.optional() .optional()
.or(z.literal("urn:ietf:wg:oauth:2.0:oob")), .or(z.literal("urn:ietf:wg:oauth:2.0:oob")),
@ -141,7 +139,7 @@ export default (plugin: PluginType): void =>
); );
} }
if (!z.string().uuid().safeParse(sub).success) { if (!z.uuid().safeParse(sub).success) {
return errorRedirect( return errorRedirect(
context, context,
errors.InvalidSub, errors.InvalidSub,

View file

@ -1,7 +1,7 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, test } from "bun:test";
import { Application } from "@versia/kit/db"; import { Application } from "@versia-server/kit/db";
import { fakeRequest } from "@versia-server/tests";
import { randomUUIDv7 } from "bun"; import { randomUUIDv7 } from "bun";
import { fakeRequest } from "~/tests/utils";
const application = await Application.insert({ const application = await Application.insert({
id: randomUUIDv7(), id: randomUUIDv7(),

View file

@ -1,8 +1,7 @@
import { describeRoute } from "hono-openapi"; import { auth } from "@versia-server/kit/api";
import { resolver } from "hono-openapi/zod"; import { describeRoute, resolver } from "hono-openapi";
import { exportJWK } from "jose"; import { exportJWK } from "jose";
import { z } from "zod"; import { z } from "zod/v4";
import { auth } from "@/api";
import type { PluginType } from "../index.ts"; import type { PluginType } from "../index.ts";
export default (plugin: PluginType): void => { export default (plugin: PluginType): void => {

View file

@ -1,19 +1,20 @@
import { import {
Account as AccountSchema, Account as AccountSchema,
RolePermission, RolePermission,
zBoolean,
} from "@versia/client/schemas"; } from "@versia/client/schemas";
import { db, Media, Token, User } from "@versia/kit/db"; import { ApiError } from "@versia-server/kit";
import { and, eq, isNull, type SQL } from "@versia/kit/drizzle"; import { handleZodError } from "@versia-server/kit/api";
import { OpenIdAccounts, Users } from "@versia/kit/tables"; 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 { randomUUIDv7 } from "bun";
import { and, eq, isNull, type SQL } from "drizzle-orm";
import { setCookie } from "hono/cookie"; import { setCookie } from "hono/cookie";
import { describeRoute } from "hono-openapi"; import { describeRoute, validator } from "hono-openapi";
import { validator } from "hono-openapi/zod";
import { SignJWT } from "jose"; import { SignJWT } from "jose";
import { z } from "zod"; import { z } from "zod/v4";
import { handleZodError } from "@/api";
import { randomString } from "@/math.ts"; import { randomString } from "@/math.ts";
import { ApiError } from "~/classes/errors/api-error.ts";
import type { PluginType } from "../../index.ts"; import type { PluginType } from "../../index.ts";
import { automaticOidcFlow } from "../../utils.ts"; import { automaticOidcFlow } from "../../utils.ts";
@ -46,13 +47,8 @@ export default (plugin: PluginType): void => {
z.object({ z.object({
client_id: z.string().optional(), client_id: z.string().optional(),
flow: z.string(), flow: z.string(),
link: z link: zBoolean.optional(),
.string() user_id: z.uuid().optional(),
.transform((v) =>
["true", "1", "on"].includes(v.toLowerCase()),
)
.optional(),
user_id: z.string().uuid().optional(),
}), }),
handleZodError, handleZodError,
), ),
@ -242,6 +238,9 @@ export default (plugin: PluginType): void => {
avatar: avatar ?? undefined, avatar: avatar ?? undefined,
}); });
// Add to search index
await searchManager.addUser(user);
// Link account // Link account
await db.insert(OpenIdAccounts).values({ await db.insert(OpenIdAccounts).values({
id: randomUUIDv7(), id: randomUUIDv7(),

View file

@ -1,7 +1,7 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, test } from "bun:test";
import { Application, Token } from "@versia/kit/db"; import { Application, Token } from "@versia-server/kit/db";
import { fakeRequest, getTestUsers } from "@versia-server/tests";
import { randomUUIDv7 } from "bun"; import { randomUUIDv7 } from "bun";
import { fakeRequest, getTestUsers } from "~/tests/utils";
const { deleteUsers, users } = await getTestUsers(1); const { deleteUsers, users } = await getTestUsers(1);

View file

@ -1,10 +1,9 @@
import { db, Token } from "@versia/kit/db"; import { handleZodError, jsonOrForm } from "@versia-server/kit/api";
import { and, eq } from "@versia/kit/drizzle"; import { db, Token } from "@versia-server/kit/db";
import { Tokens } from "@versia/kit/tables"; import { Tokens } from "@versia-server/kit/tables";
import { describeRoute } from "hono-openapi"; import { and, eq } from "drizzle-orm";
import { resolver, validator } from "hono-openapi/zod"; import { describeRoute, resolver, validator } from "hono-openapi";
import { z } from "zod"; import { z } from "zod/v4";
import { handleZodError, jsonOrForm } from "@/api";
import type { PluginType } from "../../index.ts"; import type { PluginType } from "../../index.ts";
export default (plugin: PluginType): void => { export default (plugin: PluginType): void => {

View file

@ -1,16 +1,15 @@
import { Application, db } from "@versia/kit/db"; import { handleZodError } from "@versia-server/kit/api";
import { OpenIdLoginFlows } from "@versia/kit/tables"; import { Application, db } from "@versia-server/kit/db";
import { OpenIdLoginFlows } from "@versia-server/kit/tables";
import { randomUUIDv7 } from "bun"; import { randomUUIDv7 } from "bun";
import { describeRoute } from "hono-openapi"; import { describeRoute, validator } from "hono-openapi";
import { validator } from "hono-openapi/zod";
import { import {
calculatePKCECodeChallenge, calculatePKCECodeChallenge,
discoveryRequest, discoveryRequest,
generateRandomCodeVerifier, generateRandomCodeVerifier,
processDiscoveryResponse, processDiscoveryResponse,
} from "oauth4webapi"; } from "oauth4webapi";
import { z } from "zod"; import { z } from "zod/v4";
import { handleZodError } from "@/api.ts";
import type { PluginType } from "../../index.ts"; import type { PluginType } from "../../index.ts";
import { oauthRedirectUri } from "../../utils.ts"; import { oauthRedirectUri } from "../../utils.ts";
@ -34,7 +33,7 @@ export default (plugin: PluginType): void => {
z.object({ z.object({
issuer: z.string(), issuer: z.string(),
client_id: z.string().optional(), client_id: z.string().optional(),
redirect_uri: z.string().url().optional(), redirect_uri: z.url().optional(),
scope: z.string().optional(), scope: z.string().optional(),
response_type: z.enum(["code"]).optional(), response_type: z.enum(["code"]).optional(),
}), }),

View file

@ -1,7 +1,7 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, test } from "bun:test";
import { Application, Token } from "@versia/kit/db"; import { Application, Token } from "@versia-server/kit/db";
import { fakeRequest, getTestUsers } from "@versia-server/tests";
import { randomUUIDv7 } from "bun"; import { randomUUIDv7 } from "bun";
import { fakeRequest, getTestUsers } from "~/tests/utils";
const { deleteUsers, users } = await getTestUsers(1); const { deleteUsers, users } = await getTestUsers(1);

View file

@ -1,10 +1,9 @@
import { Application, Token } from "@versia/kit/db"; import { handleZodError, jsonOrForm } from "@versia-server/kit/api";
import { and, eq } from "@versia/kit/drizzle"; import { Application, Token } from "@versia-server/kit/db";
import { Tokens } from "@versia/kit/tables"; import { Tokens } from "@versia-server/kit/tables";
import { describeRoute } from "hono-openapi"; import { and, eq } from "drizzle-orm";
import { resolver, validator } from "hono-openapi/zod"; import { describeRoute, resolver, validator } from "hono-openapi";
import { z } from "zod"; import { z } from "zod/v4";
import { handleZodError, jsonOrForm } from "@/api";
import type { PluginType } from "../../index.ts"; import type { PluginType } from "../../index.ts";
export default (plugin: PluginType): void => { export default (plugin: PluginType): void => {
@ -80,7 +79,7 @@ export default (plugin: PluginType): void => {
client_secret: z.string().optional(), client_secret: z.string().optional(),
username: z.string().trim().optional(), username: z.string().trim().optional(),
password: z.string().trim().optional(), password: z.string().trim().optional(),
redirect_uri: z.string().url().optional(), redirect_uri: z.url().optional(),
refresh_token: z.string().optional(), refresh_token: z.string().optional(),
scope: z.string().optional(), scope: z.string().optional(),
assertion: z.string().optional(), assertion: z.string().optional(),

View file

@ -1,5 +1,5 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, test } from "bun:test";
import { fakeRequest, getTestUsers } from "~/tests/utils"; import { fakeRequest, getTestUsers } from "@versia-server/tests";
const { deleteUsers, tokens } = await getTestUsers(1); const { deleteUsers, tokens } = await getTestUsers(1);

View file

@ -1,13 +1,12 @@
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import { db } from "@versia/kit/db"; import { ApiError } from "@versia-server/kit";
import { and, eq, type SQL } from "@versia/kit/drizzle"; import { auth, handleZodError } from "@versia-server/kit/api";
import { OpenIdAccounts } from "@versia/kit/tables"; import { db } from "@versia-server/kit/db";
import { describeRoute } from "hono-openapi"; import { OpenIdAccounts } from "@versia-server/kit/tables";
import { resolver, validator } from "hono-openapi/zod"; import { and, eq, type SQL } from "drizzle-orm";
import { z } from "zod"; import { describeRoute, resolver, validator } from "hono-openapi";
import { auth, handleZodError } from "@/api"; import { z } from "zod/v4";
import { ApiError } from "~/classes/errors/api-error"; import type { PluginType } from "../../../index.ts";
import type { PluginType } from "~/plugins/openid";
export default (plugin: PluginType): void => { export default (plugin: PluginType): void => {
plugin.registerRoute("/api/v1/sso/:id", (app) => { plugin.registerRoute("/api/v1/sso/:id", (app) => {

View file

@ -1,5 +1,5 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, test } from "bun:test";
import { fakeRequest, getTestUsers } from "~/tests/utils"; import { fakeRequest, getTestUsers } from "@versia-server/tests";
const { deleteUsers, tokens } = await getTestUsers(1); const { deleteUsers, tokens } = await getTestUsers(1);

View file

@ -1,16 +1,15 @@
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import { Application, db } from "@versia/kit/db"; import { ApiError } from "@versia-server/kit";
import { OpenIdLoginFlows } from "@versia/kit/tables"; import { auth, 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 { randomUUIDv7 } from "bun";
import { describeRoute } from "hono-openapi"; import { describeRoute, resolver, validator } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { import {
calculatePKCECodeChallenge, calculatePKCECodeChallenge,
generateRandomCodeVerifier, generateRandomCodeVerifier,
} from "oauth4webapi"; } from "oauth4webapi";
import { z } from "zod"; import { z } from "zod/v4";
import { auth, handleZodError } from "@/api";
import { ApiError } from "~/classes/errors/api-error.ts";
import type { PluginType } from "../../index.ts"; import type { PluginType } from "../../index.ts";
import { oauthDiscoveryRequest, oauthRedirectUri } from "../../utils.ts"; import { oauthDiscoveryRequest, oauthRedirectUri } from "../../utils.ts";

View file

@ -1,6 +1,6 @@
import { type Application, db } from "@versia/kit/db"; import { type Application, db } from "@versia-server/kit/db";
import { eq, type InferSelectModel, type SQL } from "@versia/kit/drizzle"; import type { OpenIdLoginFlows } from "@versia-server/kit/tables";
import type { OpenIdLoginFlows } from "@versia/kit/tables"; import { eq, type InferSelectModel, type SQL } from "drizzle-orm";
import { import {
type AuthorizationResponseError, type AuthorizationResponseError,
type AuthorizationServer, type AuthorizationServer,
@ -165,7 +165,7 @@ export const automaticOidcFlow = async (
const authServer = await getAuthServer(issuerUrl); const authServer = await getAuthServer(issuerUrl);
const parameters = await getParameters( const parameters = getParameters(
authServer, authServer,
issuer.client_id, issuer.client_id,
currentUrl, currentUrl,

View file

@ -1,8 +1,10 @@
import { join } from "node:path";
import { FileSystemRouter } from "bun"; import { FileSystemRouter } from "bun";
// Returns the route filesystem path when given a URL // Returns the route filesystem path when given a URL
export const routeMatcher = new FileSystemRouter({ export const routeMatcher = new FileSystemRouter({
style: "nextjs", style: "nextjs",
dir: "api", dir: join(import.meta.dir, "routes"),
fileExtensions: [".ts", ".js"], fileExtensions: [".ts", ".js"],
}); });

View file

@ -1,9 +1,9 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, test } from "bun:test";
import { Application } from "@versia/kit/db"; import { config } from "@versia-server/config";
import { Application } from "@versia-server/kit/db";
import { fakeRequest, getTestUsers } from "@versia-server/tests";
import { randomUUIDv7 } from "bun"; import { randomUUIDv7 } from "bun";
import { randomString } from "@/math"; import { randomString } from "@/math";
import { config } from "~/config.ts";
import { fakeRequest, getTestUsers } from "~/tests/utils";
const { users, deleteUsers, passwords } = await getTestUsers(1); const { users, deleteUsers, passwords } = await getTestUsers(1);

View file

@ -1,16 +1,15 @@
import { Application, User } from "@versia/kit/db"; import { config } from "@versia-server/config";
import { Users } from "@versia/kit/tables"; import { ApiError } from "@versia-server/kit";
import { apiRoute, handleZodError } from "@versia-server/kit/api";
import { Application, User } from "@versia-server/kit/db";
import { Users } from "@versia-server/kit/tables";
import { password as bunPassword } from "bun"; import { password as bunPassword } from "bun";
import { eq, or } from "drizzle-orm"; import { eq, or } from "drizzle-orm";
import type { Context } from "hono"; import type { Context } from "hono";
import { setCookie } from "hono/cookie"; import { setCookie } from "hono/cookie";
import { describeRoute } from "hono-openapi"; import { describeRoute, validator } from "hono-openapi";
import { validator } from "hono-openapi/zod";
import { SignJWT } from "jose"; import { SignJWT } from "jose";
import { z } from "zod"; import { z } from "zod/v4";
import { apiRoute, handleZodError } from "@/api";
import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/config.ts";
const returnError = ( const returnError = (
context: Context, context: Context,
@ -59,7 +58,7 @@ export default apiRoute((app) =>
"query", "query",
z.object({ z.object({
scope: z.string().optional(), scope: z.string().optional(),
redirect_uri: z.string().url().optional(), redirect_uri: z.url().optional(),
response_type: z.enum([ response_type: z.enum([
"code", "code",
"token", "token",
@ -90,7 +89,6 @@ export default apiRoute((app) =>
"form", "form",
z.object({ z.object({
identifier: z identifier: z
.string()
.email() .email()
.toLowerCase() .toLowerCase()
.or(z.string().toLowerCase()), .or(z.string().toLowerCase()),

View file

@ -1,11 +1,10 @@
import { db } from "@versia/kit/db"; import { config } from "@versia-server/config";
import { Applications, Tokens } from "@versia/kit/tables"; import { apiRoute, handleZodError } from "@versia-server/kit/api";
import { db } from "@versia-server/kit/db";
import { Applications, Tokens } from "@versia-server/kit/tables";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { describeRoute } from "hono-openapi"; import { describeRoute, validator } from "hono-openapi";
import { validator } from "hono-openapi/zod"; import { z } from "zod/v4";
import { z } from "zod";
import { apiRoute, handleZodError } from "@/api";
import { config } from "~/config.ts";
/** /**
* OAuth Code flow * OAuth Code flow
@ -28,7 +27,7 @@ export default apiRoute((app) =>
validator( validator(
"query", "query",
z.object({ z.object({
redirect_uri: z.string().url(), redirect_uri: z.url(),
client_id: z.string(), client_id: z.string(),
code: z.string(), code: z.string(),
}), }),

View file

@ -1,9 +1,9 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, test } from "bun:test";
import { Application } from "@versia/kit/db"; import { config } from "@versia-server/config";
import { Application } from "@versia-server/kit/db";
import { fakeRequest, getTestUsers } from "@versia-server/tests";
import { randomUUIDv7 } from "bun"; import { randomUUIDv7 } from "bun";
import { randomString } from "@/math"; import { randomString } from "@/math";
import { config } from "~/config.ts";
import { fakeRequest, getTestUsers } from "~/tests/utils";
const { users, deleteUsers, passwords } = await getTestUsers(1); const { users, deleteUsers, passwords } = await getTestUsers(1);
const token = randomString(32, "hex"); const token = randomString(32, "hex");

View file

@ -1,13 +1,12 @@
import { User } from "@versia/kit/db"; import { config } from "@versia-server/config";
import { Users } from "@versia/kit/tables"; import { apiRoute, handleZodError } from "@versia-server/kit/api";
import { User } from "@versia-server/kit/db";
import { Users } from "@versia-server/kit/tables";
import { password as bunPassword } from "bun"; import { password as bunPassword } from "bun";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { Context } from "hono"; import type { Context } from "hono";
import { describeRoute } from "hono-openapi"; import { describeRoute, validator } from "hono-openapi";
import { validator } from "hono-openapi/zod"; import { z } from "zod/v4";
import { z } from "zod";
import { apiRoute, handleZodError } from "@/api";
import { config } from "~/config.ts";
const returnError = ( const returnError = (
context: Context, context: Context,

View file

@ -1,5 +1,5 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, test } from "bun:test";
import { generateClient, getTestUsers } from "~/tests/utils"; import { generateClient, getTestUsers } from "@versia-server/tests";
const { users, deleteUsers } = await getTestUsers(2); const { users, deleteUsers } = await getTestUsers(2);

View file

@ -2,11 +2,10 @@ import {
Relationship as RelationshipSchema, Relationship as RelationshipSchema,
RolePermission, RolePermission,
} from "@versia/client/schemas"; } from "@versia/client/schemas";
import { Relationship } from "@versia/kit/db"; import { ApiError } from "@versia-server/kit";
import { describeRoute } from "hono-openapi"; import { apiRoute, auth, withUserParam } from "@versia-server/kit/api";
import { resolver } from "hono-openapi/zod"; import { Relationship } from "@versia-server/kit/db";
import { apiRoute, auth, withUserParam } from "@/api"; import { describeRoute, resolver } from "hono-openapi";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) => export default apiRoute((app) =>
app.post( app.post(

View file

@ -1,10 +1,14 @@
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import { describeRoute } from "hono-openapi"; import { ApiError } from "@versia-server/kit";
import { resolver, validator } from "hono-openapi/zod"; import {
import { z } from "zod"; apiRoute,
import { apiRoute, auth, handleZodError, withUserParam } from "@/api"; auth,
handleZodError,
withUserParam,
} from "@versia-server/kit/api";
import { describeRoute, resolver, validator } from "hono-openapi";
import { z } from "zod/v4";
import { getFeed } from "@/rss"; import { getFeed } from "@/rss";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) => export default apiRoute((app) =>
app.get( app.get(
@ -34,12 +38,13 @@ export default apiRoute((app) =>
RolePermission.ViewNotes, RolePermission.ViewNotes,
RolePermission.ViewAccounts, RolePermission.ViewAccounts,
], ],
scopes: ["read:statuses"], scopes: ["read:statuses"],
}), }),
validator( validator(
"query", "query",
z.object({ z.object({
page: z.coerce.number().default(0).openapi({ page: z.coerce.number().default(0).meta({
description: "Page number to fetch. Defaults to 0.", description: "Page number to fetch. Defaults to 0.",
example: 2, example: 2,
}), }),

View file

@ -1,10 +1,14 @@
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import { describeRoute } from "hono-openapi"; import { ApiError } from "@versia-server/kit";
import { resolver, validator } from "hono-openapi/zod"; import {
import { z } from "zod"; apiRoute,
import { apiRoute, auth, handleZodError, withUserParam } from "@/api"; auth,
handleZodError,
withUserParam,
} from "@versia-server/kit/api";
import { describeRoute, resolver, validator } from "hono-openapi";
import { z } from "zod/v4";
import { getFeed } from "@/rss"; import { getFeed } from "@/rss";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) => export default apiRoute((app) =>
app.get( app.get(
@ -33,12 +37,13 @@ export default apiRoute((app) =>
RolePermission.ViewNotes, RolePermission.ViewNotes,
RolePermission.ViewAccounts, RolePermission.ViewAccounts,
], ],
scopes: ["read:statuses"], scopes: ["read:statuses"],
}), }),
validator( validator(
"query", "query",
z.object({ z.object({
page: z.coerce.number().default(0).openapi({ page: z.coerce.number().default(0).meta({
description: "Page number to fetch. Defaults to 0.", description: "Page number to fetch. Defaults to 0.",
example: 2, example: 2,
}), }),

View file

@ -1,5 +1,5 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, test } from "bun:test";
import { generateClient, getTestUsers } from "~/tests/utils"; import { generateClient, getTestUsers } from "@versia-server/tests";
const { users, deleteUsers } = await getTestUsers(3); const { users, deleteUsers } = await getTestUsers(3);

View file

@ -3,12 +3,16 @@ import {
Relationship as RelationshipSchema, Relationship as RelationshipSchema,
RolePermission, RolePermission,
} from "@versia/client/schemas"; } from "@versia/client/schemas";
import { Relationship } from "@versia/kit/db"; import { ApiError } from "@versia-server/kit";
import { describeRoute } from "hono-openapi"; import {
import { resolver, validator } from "hono-openapi/zod"; apiRoute,
import { z } from "zod"; auth,
import { apiRoute, auth, handleZodError, withUserParam } from "@/api"; handleZodError,
import { ApiError } from "~/classes/errors/api-error"; withUserParam,
} from "@versia-server/kit/api";
import { Relationship } from "@versia-server/kit/db";
import { describeRoute, resolver, validator } from "hono-openapi";
import { z } from "zod/v4";
export default apiRoute((app) => export default apiRoute((app) =>
app.post( app.post(
@ -57,12 +61,12 @@ export default apiRoute((app) =>
validator( validator(
"json", "json",
z.object({ z.object({
reblogs: z.boolean().default(true).openapi({ reblogs: z.boolean().default(true).meta({
description: description:
"Receive this accounts reblogs in home timeline?", "Receive this accounts reblogs in home timeline?",
example: true, example: true,
}), }),
notify: z.boolean().default(false).openapi({ notify: z.boolean().default(false).meta({
description: description:
"Receive notifications when this account posts a status?", "Receive notifications when this account posts a status?",
example: false, example: false,
@ -70,7 +74,7 @@ export default apiRoute((app) =>
languages: z languages: z
.array(iso631) .array(iso631)
.default([]) .default([])
.openapi({ .meta({
description: description:
"Array of String (ISO 639-1 language two-letter code). Filter received statuses for these languages. If not provided, you will receive this accounts posts in all languages.", "Array of String (ISO 639-1 language two-letter code). Filter received statuses for these languages. If not provided, you will receive this accounts posts in all languages.",
example: ["en", "fr"], example: ["en", "fr"],

View file

@ -1,5 +1,5 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { generateClient, getTestUsers } from "~/tests/utils"; import { generateClient, getTestUsers } from "@versia-server/tests";
const { users, deleteUsers } = await getTestUsers(5); const { users, deleteUsers } = await getTestUsers(5);

View file

@ -2,14 +2,18 @@ import {
Account as AccountSchema, Account as AccountSchema,
RolePermission, RolePermission,
} from "@versia/client/schemas"; } from "@versia/client/schemas";
import { Timeline } from "@versia/kit/db"; import { ApiError } from "@versia-server/kit";
import { Users } from "@versia/kit/tables"; import {
apiRoute,
auth,
handleZodError,
withUserParam,
} from "@versia-server/kit/api";
import { Timeline } from "@versia-server/kit/db";
import { Users } from "@versia-server/kit/tables";
import { and, gt, gte, lt, sql } from "drizzle-orm"; import { and, gt, gte, lt, sql } from "drizzle-orm";
import { describeRoute } from "hono-openapi"; import { describeRoute, resolver, validator } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod"; import { z } from "zod/v4";
import { z } from "zod";
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) => export default apiRoute((app) =>
app.get( app.get(
@ -34,7 +38,7 @@ export default apiRoute((app) =>
link: z link: z
.string() .string()
.optional() .optional()
.openapi({ .meta({
description: description:
"Links to the next and previous pages", "Links to the next and previous pages",
example: example:
@ -61,22 +65,22 @@ export default apiRoute((app) =>
validator( validator(
"query", "query",
z.object({ z.object({
max_id: AccountSchema.shape.id.optional().openapi({ max_id: AccountSchema.shape.id.optional().meta({
description: description:
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.", "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa", example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
}), }),
since_id: AccountSchema.shape.id.optional().openapi({ since_id: AccountSchema.shape.id.optional().meta({
description: description:
"All results returned will be greater than this ID. In effect, sets a lower bound on results.", "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
example: undefined, example: undefined,
}), }),
min_id: AccountSchema.shape.id.optional().openapi({ min_id: AccountSchema.shape.id.optional().meta({
description: description:
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.", "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
example: undefined, example: undefined,
}), }),
limit: z.number().int().min(1).max(40).default(20).openapi({ limit: z.number().int().min(1).max(40).default(20).meta({
description: "Maximum number of results to return.", description: "Maximum number of results to return.",
}), }),
}), }),

View file

@ -1,5 +1,5 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { generateClient, getTestUsers } from "~/tests/utils"; import { generateClient, getTestUsers } from "@versia-server/tests";
const { users, deleteUsers } = await getTestUsers(5); const { users, deleteUsers } = await getTestUsers(5);

View file

@ -2,14 +2,18 @@ import {
Account as AccountSchema, Account as AccountSchema,
RolePermission, RolePermission,
} from "@versia/client/schemas"; } from "@versia/client/schemas";
import { Timeline } from "@versia/kit/db"; import { ApiError } from "@versia-server/kit";
import { Users } from "@versia/kit/tables"; import {
apiRoute,
auth,
handleZodError,
withUserParam,
} from "@versia-server/kit/api";
import { Timeline } from "@versia-server/kit/db";
import { Users } from "@versia-server/kit/tables";
import { and, gt, gte, lt, sql } from "drizzle-orm"; import { and, gt, gte, lt, sql } from "drizzle-orm";
import { describeRoute } from "hono-openapi"; import { describeRoute, resolver, validator } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod"; import { z } from "zod/v4";
import { z } from "zod";
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) => export default apiRoute((app) =>
app.get( app.get(
@ -35,7 +39,7 @@ export default apiRoute((app) =>
link: z link: z
.string() .string()
.optional() .optional()
.openapi({ .meta({
description: description:
"Links to the next and previous pages", "Links to the next and previous pages",
example: example:
@ -62,22 +66,22 @@ export default apiRoute((app) =>
validator( validator(
"query", "query",
z.object({ z.object({
max_id: AccountSchema.shape.id.optional().openapi({ max_id: AccountSchema.shape.id.optional().meta({
description: description:
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.", "All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa", example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
}), }),
since_id: AccountSchema.shape.id.optional().openapi({ since_id: AccountSchema.shape.id.optional().meta({
description: description:
"All results returned will be greater than this ID. In effect, sets a lower bound on results.", "All results returned will be greater than this ID. In effect, sets a lower bound on results.",
example: undefined, example: undefined,
}), }),
min_id: AccountSchema.shape.id.optional().openapi({ min_id: AccountSchema.shape.id.optional().meta({
description: description:
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.", "Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
example: undefined, example: undefined,
}), }),
limit: z.number().int().min(1).max(40).default(20).openapi({ limit: z.number().int().min(1).max(40).default(20).meta({
description: "Maximum number of results to return.", description: "Maximum number of results to return.",
}), }),
}), }),

View file

@ -1,5 +1,9 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { generateClient, getTestStatuses, getTestUsers } from "~/tests/utils"; import {
generateClient,
getTestStatuses,
getTestUsers,
} from "@versia-server/tests";
const { users, deleteUsers } = await getTestUsers(5); const { users, deleteUsers } = await getTestUsers(5);
const timeline = (await getTestStatuses(5, users[0])).toReversed(); const timeline = (await getTestStatuses(5, users[0])).toReversed();

View file

@ -2,10 +2,9 @@ import {
Account as AccountSchema, Account as AccountSchema,
RolePermission, RolePermission,
} from "@versia/client/schemas"; } from "@versia/client/schemas";
import { describeRoute } from "hono-openapi"; import { ApiError } from "@versia-server/kit";
import { resolver } from "hono-openapi/zod"; import { apiRoute, auth, withUserParam } from "@versia-server/kit/api";
import { apiRoute, auth, withUserParam } from "@/api"; import { describeRoute, resolver } from "hono-openapi";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) => export default apiRoute((app) =>
app.get( app.get(

View file

@ -1,5 +1,5 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, test } from "bun:test";
import { generateClient, getTestUsers } from "~/tests/utils"; import { generateClient, getTestUsers } from "@versia-server/tests";
const { users, deleteUsers } = await getTestUsers(2); const { users, deleteUsers } = await getTestUsers(2);

View file

@ -2,16 +2,20 @@ import {
Relationship as RelationshipSchema, Relationship as RelationshipSchema,
RolePermission, RolePermission,
} from "@versia/client/schemas"; } from "@versia/client/schemas";
import { Relationship } from "@versia/kit/db"; import { ApiError } from "@versia-server/kit";
import { describeRoute } from "hono-openapi"; import {
import { resolver, validator } from "hono-openapi/zod"; apiRoute,
import { z } from "zod"; auth,
import { apiRoute, auth, handleZodError, withUserParam } from "@/api"; handleZodError,
import { ApiError } from "~/classes/errors/api-error"; withUserParam,
} from "@versia-server/kit/api";
import { Relationship } from "@versia-server/kit/db";
import { import {
RelationshipJobType, RelationshipJobType,
relationshipQueue, relationshipQueue,
} from "~/classes/queues/relationships"; } from "@versia-server/kit/queues/relationships";
import { describeRoute, resolver, validator } from "hono-openapi";
import { z } from "zod/v4";
export default apiRoute((app) => export default apiRoute((app) =>
app.post( app.post(
@ -51,7 +55,7 @@ export default apiRoute((app) =>
validator( validator(
"json", "json",
z.object({ z.object({
notifications: z.boolean().default(true).openapi({ notifications: z.boolean().default(true).meta({
description: "Mute notifications in addition to statuses?", description: "Mute notifications in addition to statuses?",
}), }),
duration: z duration: z
@ -60,7 +64,7 @@ export default apiRoute((app) =>
.min(0) .min(0)
.max(60 * 60 * 24 * 365 * 5) .max(60 * 60 * 24 * 365 * 5)
.default(0) .default(0)
.openapi({ .meta({
description: description:
"How long the mute should last, in seconds. 0 means indefinite.", "How long the mute should last, in seconds. 0 means indefinite.",
}), }),

View file

@ -1,5 +1,5 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, test } from "bun:test";
import { generateClient, getTestUsers } from "~/tests/utils"; import { generateClient, getTestUsers } from "@versia-server/tests";
const { users, deleteUsers } = await getTestUsers(2); const { users, deleteUsers } = await getTestUsers(2);

View file

@ -2,12 +2,16 @@ import {
Relationship as RelationshipSchema, Relationship as RelationshipSchema,
RolePermission, RolePermission,
} from "@versia/client/schemas"; } from "@versia/client/schemas";
import { Relationship } from "@versia/kit/db"; import { ApiError } from "@versia-server/kit";
import { describeRoute } from "hono-openapi"; import {
import { resolver, validator } from "hono-openapi/zod"; apiRoute,
import { z } from "zod"; auth,
import { apiRoute, auth, handleZodError, withUserParam } from "@/api"; handleZodError,
import { ApiError } from "~/classes/errors/api-error"; withUserParam,
} from "@versia-server/kit/api";
import { Relationship } from "@versia-server/kit/db";
import { describeRoute, resolver, validator } from "hono-openapi";
import { z } from "zod/v4";
export default apiRoute((app) => export default apiRoute((app) =>
app.post( app.post(
@ -45,7 +49,7 @@ export default apiRoute((app) =>
validator( validator(
"json", "json",
z.object({ z.object({
comment: RelationshipSchema.shape.note.optional().openapi({ comment: RelationshipSchema.shape.note.optional().meta({
description: description:
"The comment to be set on that user. Provide an empty string or leave out this parameter to clear the currently set note.", "The comment to be set on that user. Provide an empty string or leave out this parameter to clear the currently set note.",
}), }),

View file

@ -1,5 +1,5 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, test } from "bun:test";
import { generateClient, getTestUsers } from "~/tests/utils"; import { generateClient, getTestUsers } from "@versia-server/tests";
const { users, deleteUsers } = await getTestUsers(2); const { users, deleteUsers } = await getTestUsers(2);

View file

@ -2,10 +2,9 @@ import {
Relationship as RelationshipSchema, Relationship as RelationshipSchema,
RolePermission, RolePermission,
} from "@versia/client/schemas"; } from "@versia/client/schemas";
import { Relationship } from "@versia/kit/db"; import { apiRoute, auth, withUserParam } from "@versia-server/kit/api";
import { describeRoute } from "hono-openapi"; import { Relationship } from "@versia-server/kit/db";
import { resolver } from "hono-openapi/zod"; import { describeRoute, resolver } from "hono-openapi";
import { apiRoute, auth, withUserParam } from "@/api";
export default apiRoute((app) => export default apiRoute((app) =>
app.post( app.post(

View file

@ -2,11 +2,10 @@ import {
Account as AccountSchema, Account as AccountSchema,
RolePermission, RolePermission,
} from "@versia/client/schemas"; } from "@versia/client/schemas";
import { describeRoute } from "hono-openapi"; import { ApiError } from "@versia-server/kit";
import { resolver } from "hono-openapi/zod"; import { apiRoute, auth, withUserParam } from "@versia-server/kit/api";
import { apiRoute, auth, withUserParam } from "@/api"; import { User } from "@versia-server/kit/db";
import { User } from "~/classes/database/user"; import { describeRoute, resolver } from "hono-openapi";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) => export default apiRoute((app) =>
app.post( app.post(

View file

@ -1,5 +1,5 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { generateClient, getTestUsers } from "~/tests/utils"; import { generateClient, getTestUsers } from "@versia-server/tests";
const { users, deleteUsers } = await getTestUsers(2); const { users, deleteUsers } = await getTestUsers(2);

View file

@ -2,11 +2,10 @@ import {
Relationship as RelationshipSchema, Relationship as RelationshipSchema,
RolePermission, RolePermission,
} from "@versia/client/schemas"; } from "@versia/client/schemas";
import { Relationship } from "@versia/kit/db"; import { ApiError } from "@versia-server/kit";
import { describeRoute } from "hono-openapi"; import { apiRoute, auth, withUserParam } from "@versia-server/kit/api";
import { resolver } from "hono-openapi/zod"; import { Relationship } from "@versia-server/kit/db";
import { apiRoute, auth, withUserParam } from "@/api"; import { describeRoute, resolver } from "hono-openapi";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) => export default apiRoute((app) =>
app.post( app.post(

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