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

View file

@ -13,4 +13,10 @@ const add = (a: number, b: number): number => a + b;
We always write TypeScript with double quotes and four spaces for indentation, so when your responses include TypeScript code, please follow those conventions.
Our codebase uses Drizzle as an ORM, with custom abstractions in `classes/database/` for interacting with the database. The `@versia/kit/db` and `@versia/kit/tables` packages are aliases for these abstractions.
Our codebase uses Drizzle as an ORM, which is exposed in the `@versia-server/kit/db` and `@versia-server/kit/tables` packages. This project uses a monorepo structure with Bun as the package manager.
The app has two modes: worker and API. The worker mode is used for background tasks, while the API mode serves HTTP requests. The entry point for the worker is `worker.ts`, and for the API, it is `api.ts`.
Run the typechecker with `bun run typecheck` to ensure that all TypeScript code is type-checked correctly. Run tests with `bun test` to ensure that all tests pass. Run the linter and formatter with `bun lint` to ensure that the code adheres to our style guidelines, and `bun lint --write` to automatically fix minor/formatting issues.
Cover all new functionality with tests, and ensure that all tests pass before submitting your code.

View file

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

27
.github/workflows/circular-imports.yml vendored Normal file
View file

@ -0,0 +1,27 @@
name: Check Circular Imports
on:
workflow_call:
jobs:
tests:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install NPM packages
run: |
bun install
- name: Run typechecks
run: |
bun run detect-circular

View file

@ -18,6 +18,9 @@ jobs:
tests:
uses: ./.github/workflows/tests.yml
detect-circular:
uses: ./.github/workflows/circular-imports.yml
build:
if: ${{ success() }}
needs: [lint, check, tests]

7
.madgerc Normal file
View file

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

View file

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

View file

@ -112,7 +112,7 @@ TypeScript errors should be ignored with `// @ts-expect-error` comments, as well
To scan for all TypeScript errors, run:
```sh
bun check
bun typecheck
```
### Commit messages

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,31 +0,0 @@
import { $, build } from "bun";
console.log("Building...");
await $`rm -rf dist && mkdir dist`;
await build({
entrypoints: [
"worker.ts",
// HACK: Include to avoid cyclical import errors
"config.ts",
],
outdir: "dist",
target: "bun",
splitting: true,
minify: false,
});
console.log("Copying files...");
// Copy Drizzle migrations to dist
await $`cp -rL drizzle dist/drizzle`;
// Copy Sharp to dist
await $`mkdir -p dist/node_modules/@img`;
await $`cp -rL node_modules/@img/sharp-libvips-linux* dist/node_modules/@img`;
await $`cp -rL node_modules/@img/sharp-linux* dist/node_modules/@img`;
await $`cp -rL node_modules/detect-libc dist/node_modules/`;
console.log("Build complete!");

View file

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

903
bun.lock

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,12 +0,0 @@
import { zodToJsonSchema } from "zod-to-json-schema";
await import("~/config.ts");
// This is an awkward way to avoid import cycles for some reason
await (async () => {
const { ConfigSchema } = await import("./schema.ts");
const jsonSchema = zodToJsonSchema(ConfigSchema, {});
console.write(`${JSON.stringify(jsonSchema, null, 4)}\n`);
})();

View file

@ -1,359 +0,0 @@
import markdownItTaskLists from "@hackmd/markdown-it-task-lists";
import { db, type Note, User } from "@versia/kit/db";
import { Instances, Users } from "@versia/kit/tables";
import { FederationRequester } from "@versia/sdk/http";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import linkifyHtml from "linkify-html";
import {
anyOf,
charIn,
createRegExp,
digit,
exactly,
global,
letter,
} from "magic-regexp";
import MarkdownIt from "markdown-it";
import markdownItContainer from "markdown-it-container";
import markdownItTocDoneRight from "markdown-it-toc-done-right";
import { mentionValidator } from "@/api";
import { sanitizeHtml, sanitizeHtmlInline } from "@/sanitization";
import { config } from "~/config.ts";
import type * as VersiaEntities from "~/packages/sdk/entities/index.ts";
import { transformOutputToUserWithRelations, userRelations } from "./user.ts";
/**
* Wrapper against the Status object to make it easier to work with
* @param query
* @returns
*/
export const findManyNotes = async (
query: Parameters<typeof db.query.Notes.findMany>[0],
userId?: string,
): Promise<(typeof Note.$type)[]> => {
const output = await db.query.Notes.findMany({
...query,
with: {
...query?.with,
attachments: {
with: {
media: true,
},
},
reactions: {
with: {
emoji: {
with: {
instance: true,
media: true,
},
},
},
},
emojis: {
with: {
emoji: {
with: {
instance: true,
media: true,
},
},
},
},
author: {
with: {
...userRelations,
},
},
mentions: {
with: {
user: {
with: {
instance: true,
},
},
},
},
reblog: {
with: {
attachments: {
with: {
media: true,
},
},
reactions: {
with: {
emoji: {
with: {
instance: true,
media: true,
},
},
},
},
emojis: {
with: {
emoji: {
with: {
instance: true,
media: true,
},
},
},
},
likes: true,
application: true,
mentions: {
with: {
user: {
with: userRelations,
},
},
},
author: {
with: {
...userRelations,
},
},
},
extras: {
pinned: userId
? sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."noteId" = "Notes_reblog".id AND "UserToPinnedNotes"."userId" = ${userId})`.as(
"pinned",
)
: sql`false`.as("pinned"),
reblogged: userId
? sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."authorId" = ${userId} AND "Notes"."reblogId" = "Notes_reblog".id)`.as(
"reblogged",
)
: sql`false`.as("reblogged"),
muted: userId
? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."ownerId" = ${userId} AND "Relationships"."subjectId" = "Notes_reblog"."authorId" AND "Relationships"."muting" = true)`.as(
"muted",
)
: sql`false`.as("muted"),
liked: userId
? sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = "Notes_reblog".id AND "Likes"."likerId" = ${userId})`.as(
"liked",
)
: sql`false`.as("liked"),
},
},
reply: true,
quote: true,
},
extras: {
pinned: userId
? sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."noteId" = "Notes".id AND "UserToPinnedNotes"."userId" = ${userId})`.as(
"pinned",
)
: sql`false`.as("pinned"),
reblogged: userId
? sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."authorId" = ${userId} AND "Notes"."reblogId" = "Notes".id)`.as(
"reblogged",
)
: sql`false`.as("reblogged"),
muted: userId
? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."ownerId" = ${userId} AND "Relationships"."subjectId" = "Notes"."authorId" AND "Relationships"."muting" = true)`.as(
"muted",
)
: sql`false`.as("muted"),
liked: userId
? sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = "Notes".id AND "Likes"."likerId" = ${userId})`.as(
"liked",
)
: sql`false`.as("liked"),
...query?.extras,
},
});
return output.map((post) => ({
...post,
author: transformOutputToUserWithRelations(post.author),
mentions: post.mentions.map((mention) => ({
...mention.user,
endpoints: mention.user.endpoints,
})),
attachments: post.attachments.map((attachment) => attachment.media),
emojis: (post.emojis ?? []).map((emoji) => emoji.emoji),
reblog: post.reblog && {
...post.reblog,
author: transformOutputToUserWithRelations(post.reblog.author),
mentions: post.reblog.mentions.map((mention) => ({
...mention.user,
endpoints: mention.user.endpoints,
})),
attachments: post.reblog.attachments.map(
(attachment) => attachment.media,
),
emojis: (post.reblog.emojis ?? []).map((emoji) => emoji.emoji),
pinned: Boolean(post.reblog.pinned),
reblogged: Boolean(post.reblog.reblogged),
muted: Boolean(post.reblog.muted),
liked: Boolean(post.reblog.liked),
},
pinned: Boolean(post.pinned),
reblogged: Boolean(post.reblogged),
muted: Boolean(post.muted),
liked: Boolean(post.liked),
}));
};
/**
* Get people mentioned in the content (match @username or @username@domain.com mentions)
* @param text The text to parse mentions from.
* @returns An array of users mentioned in the text.
*/
export const parseTextMentions = async (text: string): Promise<User[]> => {
const mentionedPeople = [...text.matchAll(mentionValidator)];
if (mentionedPeople.length === 0) {
return [];
}
const baseUrlHost = config.http.base_url.host;
const isLocal = (host?: string): boolean => host === baseUrlHost || !host;
// Find local and matching users
const foundUsers = await db
.select({
id: Users.id,
username: Users.username,
baseUrl: Instances.baseUrl,
})
.from(Users)
.leftJoin(Instances, eq(Users.instanceId, Instances.id))
.where(
or(
...mentionedPeople.map((person) =>
and(
eq(Users.username, person[1] ?? ""),
isLocal(person[2])
? isNull(Users.instanceId)
: eq(Instances.baseUrl, person[2] ?? ""),
),
),
),
);
// Separate found and unresolved users
const finalList = await User.manyFromSql(
inArray(
Users.id,
foundUsers.map((u) => u.id),
),
);
// Every remote user that isn't in database
const notFoundRemoteUsers = mentionedPeople.filter(
(p) =>
!(
foundUsers.some(
(user) => user.username === p[1] && user.baseUrl === p[2],
) || isLocal(p[2])
),
);
// Resolve remote mentions not in database
for (const person of notFoundRemoteUsers) {
const url = await FederationRequester.resolveWebFinger(
person[1] ?? "",
person[2] ?? "",
);
if (url) {
const user = await User.resolve(url);
if (user) {
finalList.push(user);
}
}
}
return finalList;
};
export const replaceTextMentions = (text: string, mentions: User[]): string => {
return mentions.reduce((finalText, mention) => {
const { username, instance } = mention.data;
const { uri } = mention;
const baseHost = config.http.base_url.host;
const linkTemplate = (displayText: string): string =>
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${uri}">${displayText}</a>`;
if (mention.remote) {
return finalText.replaceAll(
`@${username}@${instance?.baseUrl}`,
linkTemplate(`@${username}@${instance?.baseUrl}`),
);
}
return finalText.replace(
createRegExp(
exactly(
exactly(`@${username}`)
.notBefore(anyOf(letter, digit, charIn("@")))
.notAfter(anyOf(letter, digit, charIn("@"))),
).or(exactly(`@${username}@${baseHost}`)),
[global],
),
linkTemplate(`@${username}@${baseHost}`),
);
}, text);
};
export const contentToHtml = async (
content: VersiaEntities.TextContentFormat,
mentions: User[] = [],
inline = false,
): Promise<string> => {
const sanitizer = inline ? sanitizeHtmlInline : sanitizeHtml;
let htmlContent = "";
if (content.data["text/html"]) {
htmlContent = await sanitizer(content.data["text/html"].content);
} else if (content.data["text/markdown"]) {
htmlContent = await sanitizer(
await markdownParse(content.data["text/markdown"].content),
);
} else if (content.data["text/plain"]?.content) {
htmlContent = (await sanitizer(content.data["text/plain"].content))
.split("\n")
.map((line) => `<p>${line}</p>`)
.join("\n");
}
htmlContent = replaceTextMentions(htmlContent, mentions);
return linkifyHtml(htmlContent, {
defaultProtocol: "https",
validate: { email: (): false => false },
target: "_blank",
rel: "nofollow noopener noreferrer",
});
};
export const markdownParse = async (content: string): Promise<string> => {
return (await getMarkdownRenderer()).render(content);
};
export const getMarkdownRenderer = (): MarkdownIt => {
const renderer = MarkdownIt({
html: true,
linkify: true,
});
renderer.use(markdownItTocDoneRight, {
containerClass: "toc",
level: [1, 2, 3, 4],
listType: "ul",
listClass: "toc-list",
itemClass: "toc-item",
linkClass: "toc-link",
});
renderer.use(markdownItTaskLists);
renderer.use(markdownItContainer);
return renderer;
};

View file

@ -1,101 +0,0 @@
import {
type Application,
db,
type Emoji,
type Instance,
type Media,
type Role,
type Token,
type User,
} from "@versia/kit/db";
import type { Users } from "@versia/kit/tables";
import type { InferSelectModel } from "drizzle-orm";
export const userRelations = {
instance: true,
emojis: {
with: {
emoji: {
with: {
instance: true,
media: true,
},
},
},
},
avatar: true,
header: true,
roles: {
with: {
role: true,
},
},
} as const;
export interface AuthData {
user: User | null;
token: Token | null;
application: Application | null;
}
export const transformOutputToUserWithRelations = (
user: Omit<InferSelectModel<typeof Users>, "endpoints"> & {
followerCount: unknown;
followingCount: unknown;
statusCount: unknown;
avatar: typeof Media.$type | null;
header: typeof Media.$type | null;
emojis: {
userId: string;
emojiId: string;
emoji?: typeof Emoji.$type;
}[];
instance: typeof Instance.$type | null;
roles: {
userId: string;
roleId: string;
role?: typeof Role.$type;
}[];
endpoints: unknown;
},
): typeof User.$type => {
return {
...user,
followerCount: Number(user.followerCount),
followingCount: Number(user.followingCount),
statusCount: Number(user.statusCount),
endpoints:
user.endpoints ??
({} as Partial<{
dislikes: string;
featured: string;
likes: string;
followers: string;
following: string;
inbox: string;
outbox: string;
}>),
emojis: user.emojis.map(
(emoji) =>
(emoji as unknown as Record<string, object>)
.emoji as typeof Emoji.$type,
),
roles: user.roles
.map((role) => role.role)
.filter(Boolean) as (typeof Role.$type)[],
};
};
export const findManyUsers = async (
query: Parameters<typeof db.query.Users.findMany>[0],
): Promise<(typeof User.$type)[]> => {
const output = await db.query.Users.findMany({
...query,
with: {
...userRelations,
...query?.with,
},
});
return output.map((user) => transformOutputToUserWithRelations(user));
};

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from "bun:test";
import { mockModule } from "@versia-server/tests";
import sharp from "sharp";
import { mockModule } from "~/tests/utils.ts";
import { calculateBlurhash } from "./blurhash.ts";
describe("BlurhashPreprocessor", () => {

View file

@ -1,25 +0,0 @@
import { config } from "~/config.ts";
export class ProxiableUrl extends URL {
private isAllowedOrigin(): boolean {
const allowedOrigins: URL[] = [config.http.base_url].concat(
config.s3?.public_url ?? [],
);
return allowedOrigins.some((origin) =>
this.hostname.endsWith(origin.hostname),
);
}
public get proxied(): string {
// Don't proxy from CDN and self, since those sources are trusted
if (this.isAllowedOrigin()) {
return this.href;
}
const urlAsBase64Url = Buffer.from(this.href).toString("base64url");
return new URL(`/media/proxy/${urlAsBase64Url}`, config.http.base_url)
.href;
}
}

View file

@ -1,221 +0,0 @@
/* import {
afterEach,
beforeEach,
describe,
expect,
jest,
mock,
test,
} from "bun:test";
import { ZodError, type ZodTypeAny, z } from "zod";
import { Plugin } from "~/packages/plugin-kit";
import { type Manifest, manifestSchema } from "~/packages/plugin-kit/schema";
import { PluginLoader } from "./loader.ts";
const mockReaddir = jest.fn();
const mockGetLogger = jest.fn(() => ({
fatal: jest.fn(),
}));
const mockParseJSON5 = jest.fn();
const mockParseJSONC = jest.fn();
const mockFromZodError = jest.fn();
mock.module("node:fs/promises", () => ({
readdir: mockReaddir,
}));
mock.module("@logtape/logtape", () => ({
getLogger: mockGetLogger,
}));
mock.module("confbox", () => ({
parseJSON5: mockParseJSON5,
parseJSONC: mockParseJSONC,
}));
mock.module("zod-validation-error", () => ({
fromZodError: mockFromZodError,
}));
describe("PluginLoader", () => {
let pluginLoader: PluginLoader;
beforeEach(() => {
pluginLoader = new PluginLoader();
});
afterEach(() => {
jest.clearAllMocks();
});
test("getDirectories should return directories", async () => {
mockReaddir.mockResolvedValue([
{ name: "dir1", isDirectory: (): true => true },
{ name: "file1", isDirectory: (): false => false },
{ name: "dir2", isDirectory: (): true => true },
]);
// biome-ignore lint/complexity/useLiteralKeys: Private method
const directories = await PluginLoader["getDirectories"]("/some/path");
expect(directories).toEqual(["dir1", "dir2"]);
});
test("findManifestFile should return manifest file if found", async () => {
mockReaddir.mockResolvedValue(["manifest.json", "otherfile.txt"]);
const manifestFile =
// biome-ignore lint/complexity/useLiteralKeys: Private method
await PluginLoader["findManifestFile"]("/some/path");
expect(manifestFile).toBe("manifest.json");
});
test("hasEntrypoint should return true if entrypoint file is found", async () => {
mockReaddir.mockResolvedValue(["index.ts", "otherfile.txt"]);
// biome-ignore lint/complexity/useLiteralKeys: Private method
const hasEntrypoint = await PluginLoader["hasEntrypoint"]("/some/path");
expect(hasEntrypoint).toBe(true);
});
test("parseManifestFile should parse JSON manifest", async () => {
const manifestContent = { name: "test-plugin" };
Bun.file = jest.fn().mockReturnValue({
text: (): Promise<string> =>
Promise.resolve(JSON.stringify(manifestContent)),
});
// biome-ignore lint/complexity/useLiteralKeys: Private method
const manifest = await pluginLoader["parseManifestFile"](
"/some/path/manifest.json",
"manifest.json",
);
expect(manifest).toEqual(manifestContent);
});
test("findPlugins should return plugin directories with valid manifest and entrypoint", async () => {
mockReaddir
.mockResolvedValueOnce([
{ name: "plugin1", isDirectory: (): true => true },
{ name: "plugin2", isDirectory: (): true => true },
])
.mockResolvedValue(["manifest.json", "index.ts"]);
const plugins = await PluginLoader.findPlugins("/some/path");
expect(plugins).toEqual(["plugin1", "plugin2"]);
});
test("parseManifest should parse and validate manifest", async () => {
const manifestContent: Manifest = {
name: "test-plugin",
version: "1.1.0",
description: "Doobaee",
};
mockReaddir.mockResolvedValue(["manifest.json"]);
Bun.file = jest.fn().mockReturnValue({
text: (): Promise<string> =>
Promise.resolve(JSON.stringify(manifestContent)),
});
manifestSchema.safeParseAsync = jest.fn().mockResolvedValue({
success: true,
data: manifestContent,
});
const manifest = await pluginLoader.parseManifest(
"/some/path",
"plugin1",
);
expect(manifest).toEqual(manifestContent);
});
test("parseManifest should throw error if manifest is missing", async () => {
mockReaddir.mockResolvedValue([]);
await expect(
pluginLoader.parseManifest("/some/path", "plugin1"),
).rejects.toThrow("Plugin plugin1 is missing a manifest file");
});
test("parseManifest should throw error if manifest is invalid", async () => {
// @ts-expect-error trying to cause a type error here
const manifestContent: Manifest = {
name: "test-plugin",
version: "1.1.0",
};
mockReaddir.mockResolvedValue(["manifest.json"]);
Bun.file = jest.fn().mockReturnValue({
text: (): Promise<string> =>
Promise.resolve(JSON.stringify(manifestContent)),
});
manifestSchema.safeParseAsync = jest.fn().mockResolvedValue({
success: false,
error: new ZodError([]),
});
await expect(
pluginLoader.parseManifest("/some/path", "plugin1"),
).rejects.toThrow();
});
test("loadPlugin should load and return a Plugin instance", async () => {
const mockPlugin = new Plugin(z.object({}));
mock.module("/some/path/index.ts", () => ({
default: mockPlugin,
}));
const plugin = await pluginLoader.loadPlugin("/some/path", "index.ts");
expect(plugin).toBeInstanceOf(Plugin);
});
test("loadPlugin should throw error if default export is not a Plugin", async () => {
mock.module("/some/path/index.ts", () => ({
default: "cheese",
}));
await expect(
pluginLoader.loadPlugin("/some/path", "index.ts"),
).rejects.toThrow("Entrypoint is not a Plugin");
});
test("loadPlugins should load all plugins in a directory", async () => {
const manifestContent: Manifest = {
name: "test-plugin",
version: "1.1.0",
description: "Doobaee",
};
const mockPlugin = new Plugin(z.object({}));
mockReaddir
.mockResolvedValueOnce([
{ name: "plugin1", isDirectory: (): true => true },
{ name: "plugin2", isDirectory: (): true => true },
])
.mockResolvedValue(["manifest.json", "index.ts"]);
Bun.file = jest.fn().mockReturnValue({
text: (): Promise<string> =>
Promise.resolve(JSON.stringify(manifestContent)),
});
manifestSchema.safeParseAsync = jest.fn().mockResolvedValue({
success: true,
data: manifestContent,
});
mock.module("/some/path/plugin1/index", () => ({
default: mockPlugin,
}));
mock.module("/some/path/plugin2/index", () => ({
default: mockPlugin,
}));
const plugins = await pluginLoader.loadPlugins("/some/path", true);
expect(plugins).toEqual([
{
manifest: manifestContent,
plugin: mockPlugin as unknown as Plugin<ZodTypeAny>,
},
{
manifest: manifestContent,
plugin: mockPlugin as unknown as Plugin<ZodTypeAny>,
},
]);
});
});
*/

View file

@ -1,317 +0,0 @@
/**
* @file search-manager.ts
* @description Sonic search integration for indexing and searching accounts and statuses
*/
import { getLogger } from "@logtape/logtape";
import { db, Note, User } from "@versia/kit/db";
import type { SQL, ValueOrArray } from "drizzle-orm";
import {
Ingest as SonicChannelIngest,
Search as SonicChannelSearch,
} from "sonic-channel";
import { config } from "~/config.ts";
/**
* Enum for Sonic index types
*/
export enum SonicIndexType {
Accounts = "accounts",
Statuses = "statuses",
}
/**
* Class for managing Sonic search operations
*/
export class SonicSearchManager {
private searchChannel: SonicChannelSearch;
private ingestChannel: SonicChannelIngest;
private connected = false;
private logger = getLogger("sonic");
/**
* @param config Configuration for Sonic
*/
public constructor() {
if (!config.search.sonic) {
throw new Error("Sonic configuration is missing");
}
this.searchChannel = new SonicChannelSearch({
host: config.search.sonic.host,
port: config.search.sonic.port,
auth: config.search.sonic.password,
});
this.ingestChannel = new SonicChannelIngest({
host: config.search.sonic.host,
port: config.search.sonic.port,
auth: config.search.sonic.password,
});
}
/**
* Connect to Sonic
*/
public async connect(silent = false): Promise<void> {
if (!config.search.enabled) {
!silent && this.logger.info`Sonic search is disabled`;
return;
}
if (this.connected) {
return;
}
!silent && this.logger.info`Connecting to Sonic...`;
// Connect to Sonic
await new Promise<boolean>((resolve, reject) => {
this.searchChannel.connect({
connected: (): void => {
!silent &&
this.logger.info`Connected to Sonic Search Channel`;
resolve(true);
},
disconnected: (): void =>
this.logger
.error`Disconnected from Sonic Search Channel. You might be using an incorrect password.`,
timeout: (): void =>
this.logger
.error`Sonic Search Channel connection timed out`,
retrying: (): void =>
this.logger
.warn`Retrying connection to Sonic Search Channel`,
error: (error): void => {
this.logger
.error`Failed to connect to Sonic Search Channel: ${error}`;
reject(error);
},
});
});
await new Promise<boolean>((resolve, reject) => {
this.ingestChannel.connect({
connected: (): void => {
!silent &&
this.logger.info`Connected to Sonic Ingest Channel`;
resolve(true);
},
disconnected: (): void =>
this.logger.error`Disconnected from Sonic Ingest Channel`,
timeout: (): void =>
this.logger
.error`Sonic Ingest Channel connection timed out`,
retrying: (): void =>
this.logger
.warn`Retrying connection to Sonic Ingest Channel`,
error: (error): void => {
this.logger
.error`Failed to connect to Sonic Ingest Channel: ${error}`;
reject(error);
},
});
});
try {
await Promise.all([
this.searchChannel.ping(),
this.ingestChannel.ping(),
]);
this.connected = true;
!silent && this.logger.info`Connected to Sonic`;
} catch (error) {
this.logger.fatal`Error while connecting to Sonic: ${error}`;
throw error;
}
}
/**
* Add a user to Sonic
* @param user User to add
*/
public async addUser(user: User): Promise<void> {
if (!config.search.enabled) {
return;
}
try {
await this.ingestChannel.push(
SonicIndexType.Accounts,
"users",
user.id,
`${user.data.username} ${user.data.displayName} ${user.data.note}`,
);
} catch (error) {
this.logger.error`Failed to add user to Sonic: ${error}`;
}
}
/**
* Get a batch of accounts from the database
* @param n Batch number
* @param batchSize Size of the batch
*/
private static getNthDatabaseAccountBatch(
n: number,
batchSize = 1000,
): Promise<Record<string, string | null | Date>[]> {
return db.query.Users.findMany({
offset: n * batchSize,
limit: batchSize,
columns: {
id: true,
username: true,
displayName: true,
note: true,
createdAt: true,
},
orderBy: (user, { asc }): ValueOrArray<SQL> => asc(user.createdAt),
});
}
/**
* Get a batch of statuses from the database
* @param n Batch number
* @param batchSize Size of the batch
*/
private static getNthDatabaseStatusBatch(
n: number,
batchSize = 1000,
): Promise<Record<string, string | Date>[]> {
return db.query.Notes.findMany({
offset: n * batchSize,
limit: batchSize,
columns: {
id: true,
content: true,
createdAt: true,
},
orderBy: (status, { asc }): ValueOrArray<SQL> =>
asc(status.createdAt),
});
}
/**
* Rebuild search indexes
* @param indexes Indexes to rebuild
* @param batchSize Size of each batch
* @param progressCallback Callback for progress updates
*/
public async rebuildSearchIndexes(
indexes: SonicIndexType[],
batchSize = 100,
progressCallback?: (progress: number) => void,
): Promise<void> {
for (const index of indexes) {
if (index === SonicIndexType.Accounts) {
await this.rebuildAccountsIndex(batchSize, progressCallback);
} else if (index === SonicIndexType.Statuses) {
await this.rebuildStatusesIndex(batchSize, progressCallback);
}
}
}
/**
* Rebuild accounts index
* @param batchSize Size of each batch
* @param progressCallback Callback for progress updates
*/
private async rebuildAccountsIndex(
batchSize: number,
progressCallback?: (progress: number) => void,
): Promise<void> {
const accountCount = await User.getCount();
const batchCount = Math.ceil(accountCount / batchSize);
for (let i = 0; i < batchCount; i++) {
const accounts =
await SonicSearchManager.getNthDatabaseAccountBatch(
i,
batchSize,
);
await Promise.all(
accounts.map((account) =>
this.ingestChannel.push(
SonicIndexType.Accounts,
"users",
account.id as string,
`${account.username} ${account.displayName} ${account.note}`,
),
),
);
progressCallback?.((i + 1) / batchCount);
}
}
/**
* Rebuild statuses index
* @param batchSize Size of each batch
* @param progressCallback Callback for progress updates
*/
private async rebuildStatusesIndex(
batchSize: number,
progressCallback?: (progress: number) => void,
): Promise<void> {
const statusCount = await Note.getCount();
const batchCount = Math.ceil(statusCount / batchSize);
for (let i = 0; i < batchCount; i++) {
const statuses = await SonicSearchManager.getNthDatabaseStatusBatch(
i,
batchSize,
);
await Promise.all(
statuses.map((status) =>
this.ingestChannel.push(
SonicIndexType.Statuses,
"notes",
status.id as string,
status.content as string,
),
),
);
progressCallback?.((i + 1) / batchCount);
}
}
/**
* Search for accounts
* @param query Search query
* @param limit Maximum number of results
* @param offset Offset for pagination
*/
public searchAccounts(
query: string,
limit = 10,
offset = 0,
): Promise<string[]> {
return this.searchChannel.query(
SonicIndexType.Accounts,
"users",
query,
{ limit, offset },
);
}
/**
* Search for statuses
* @param query Search query
* @param limit Maximum number of results
* @param offset Offset for pagination
*/
public searchStatuses(
query: string,
limit = 10,
offset = 0,
): Promise<string[]> {
return this.searchChannel.query(
SonicIndexType.Statuses,
"notes",
query,
{ limit, offset },
);
}
}
export const searchManager = new SonicSearchManager();

View file

@ -3,10 +3,10 @@ import { friendlyErrorPlugin } from "@clerc/plugin-friendly-error";
import { helpPlugin } from "@clerc/plugin-help";
import { notFoundPlugin } from "@clerc/plugin-not-found";
import { versionPlugin } from "@clerc/plugin-version";
import { setupDatabase } from "@versia-server/kit/db";
import { searchManager } from "@versia-server/kit/search";
import { Clerc } from "clerc";
import { searchManager } from "~/classes/search/search-manager.ts";
import { setupDatabase } from "~/drizzle/db.ts";
import pkg from "~/package.json" with { type: "json" };
import pkg from "../package.json" with { type: "json" };
import { rebuildIndexCommand } from "./index/rebuild.ts";
import { refetchInstanceCommand } from "./instance/refetch.ts";
import { createUserCommand } from "./user/create.ts";

View file

@ -1,12 +1,9 @@
import { config } from "@versia-server/config";
import { SonicIndexType, searchManager } from "@versia-server/kit/search";
// @ts-expect-error - Root import is required or the Clec type definitions won't work
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
import { defineCommand, type Root } from "clerc";
import ora from "ora";
import {
SonicIndexType,
searchManager,
} from "~/classes/search/search-manager.ts";
import { config } from "~/config.ts";
export const rebuildIndexCommand = defineCommand(
{

View file

@ -1,11 +1,11 @@
import { Instance } from "@versia-server/kit/db";
import { FetchJobType, fetchQueue } from "@versia-server/kit/queues/fetch";
import { Instances } from "@versia-server/kit/tables";
import chalk from "chalk";
// @ts-expect-error - Root import is required or the Clec type definitions won't work
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
import { defineCommand, type Root } from "clerc";
import { eq } from "drizzle-orm";
import { Instance } from "~/classes/database/instance.ts";
import { FetchJobType, fetchQueue } from "~/classes/queues/fetch.ts";
import { Instances } from "~/drizzle/schema.ts";
export const refetchInstanceCommand = defineCommand(
{

View file

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

View file

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

View file

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

View file

@ -1,8 +1,7 @@
import { Instance, User } from "@versia-server/kit/db";
import { parseUserAddress } from "@versia-server/kit/parsers";
import { Users } from "@versia-server/kit/tables";
import { and, eq, isNull } from "drizzle-orm";
import { parseUserAddress } from "@/api";
import { Instance } from "~/classes/database/instance";
import { User } from "~/classes/database/user";
import { Users } from "~/drizzle/schema";
export const retrieveUser = async (
usernameOrHandle: string,

View file

@ -1,48 +0,0 @@
/**
* @file config.ts
* @summary Config system to retrieve and modify system configuration
* @description Can read from a hand-written file, config.toml, or from a machine-saved file, config.internal.toml
* Fuses both and provides a way to retrieve individual values
*/
import { env, file } from "bun";
import chalk from "chalk";
import { parseTOML } from "confbox";
import type { z } from "zod";
import { fromZodError } from "zod-validation-error";
import { ConfigSchema } from "./classes/config/schema.ts";
const CONFIG_LOCATION = env.CONFIG_LOCATION ?? "./config/config.toml";
const configFile = file(CONFIG_LOCATION);
if (!(await configFile.exists())) {
throw new Error(
`config file at "${CONFIG_LOCATION}" does not exist or is not accessible.`,
);
}
const configText = await configFile.text();
const config = await parseTOML<z.infer<typeof ConfigSchema>>(configText);
const parsed = await ConfigSchema.safeParseAsync(config);
if (!parsed.success) {
console.error(
`⚠ Error encountered while loading ${chalk.gray(CONFIG_LOCATION)}.`,
);
console.error(
"⚠ This is due to invalid, missing or incorrect values in the configuration file.",
);
console.error(
"⚠ Here is the error message, please fix the configuration file accordingly:",
);
const errorMessage = fromZodError(parsed.error).message;
console.info(errorMessage);
throw new Error("Configuration file is invalid.");
}
const exportedConfig = parsed.data;
export { exportedConfig as config };

1
config/config Symbolic link
View file

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

View file

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

View file

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

View file

@ -1,45 +0,0 @@
import process from "node:process";
import { getLogger } from "@logtape/logtape";
import chalk from "chalk";
import { sentry } from "@/sentry";
import { getDeliveryWorker } from "~/classes/queues/delivery";
import { getFetchWorker } from "~/classes/queues/fetch";
import { getInboxWorker } from "~/classes/queues/inbox";
import { getMediaWorker } from "~/classes/queues/media";
import { getPushWorker } from "~/classes/queues/push";
import { getRelationshipWorker } from "~/classes/queues/relationships";
process.on("SIGINT", () => {
process.exit();
});
await import("~/entrypoints/worker/setup.ts");
sentry?.captureMessage("Server started", "info");
const serverLogger = getLogger("server");
serverLogger.info`Starting Fetch Worker...`;
getFetchWorker();
serverLogger.info`${chalk.green("✔")} Fetch Worker started`;
serverLogger.info`Starting Delivery Worker...`;
getDeliveryWorker();
serverLogger.info`${chalk.green("✔")} Delivery Worker started`;
serverLogger.info`Starting Inbox Worker...`;
getInboxWorker();
serverLogger.info`${chalk.green("✔")} Inbox Worker started`;
serverLogger.info`Starting Push Worker...`;
getPushWorker();
serverLogger.info`${chalk.green("✔")} Push Worker started`;
serverLogger.info`Starting Media Worker...`;
getMediaWorker();
serverLogger.info`${chalk.green("✔")} Media Worker started`;
serverLogger.info`Starting Relationship Worker...`;
getRelationshipWorker();
serverLogger.info`${chalk.green("✔")} Relationship Worker started`;
serverLogger.info`${chalk.green("✔✔✔✔✔✔")} All workers started`;

View file

@ -20,16 +20,16 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1744536153,
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
"owner": "nixos",
"lastModified": 1751637120,
"narHash": "sha256-xVNy/XopSfIG9c46nRmPaKfH1Gn/56vQ8++xWA8itO4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
"rev": "5c724ed1388e53cc231ed98330a60eb2f7be4be3",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixpkgs-unstable",
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}

View file

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

View file

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

View file

@ -1,37 +0,0 @@
import { getLogger } from "@logtape/logtape";
import { SHA256 } from "bun";
import chalk from "chalk";
import { createMiddleware } from "hono/factory";
import { config } from "~/config.ts";
export const logger = createMiddleware(async (context, next) => {
if (config.logging.types.requests) {
const serverLogger = getLogger("server");
const body = await context.req.raw.clone().text();
const urlAndMethod = `${chalk.green(context.req.method)} ${chalk.blue(context.req.url)}`;
const hash = `${chalk.bold("Hash")}: ${chalk.yellow(
new SHA256().update(body).digest("hex"),
)}`;
const headers = `${chalk.bold("Headers")}:\n${Array.from(
context.req.raw.headers.entries(),
)
.map(
([key, value]) =>
` - ${chalk.cyan(key)}: ${chalk.white(value)}`,
)
.join("\n")}`;
const bodyLog = `${chalk.bold("Body")}: ${chalk.gray(body)}`;
if (config.logging.types.requests_content) {
serverLogger.debug`${urlAndMethod}\n${hash}\n${headers}\n${bodyLog}`;
} else {
serverLogger.debug`${urlAndMethod}`;
}
}
await next();
});

View file

@ -2,14 +2,8 @@
{versia-server, ...}:
versia-server.overrideAttrs (oldAttrs: {
pname = "${oldAttrs.pname}-worker";
buildPhase = ''
runHook preBuild
bun run build:worker
runHook postBuild
'';
entrypointPath = "worker.js";
buildType = "worker";
meta =
oldAttrs.meta

View file

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

View file

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

View file

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

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 { ApiError } from "~/classes/errors/api-error";
import { config } from "~/config.ts";
export const agentBans = createMiddleware(async (context, next) => {
// Check for banned user agents (regex)

View file

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

View file

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

View file

@ -0,0 +1,26 @@
import { serverLogger } from "@versia-server/logging";
import { SHA256 } from "bun";
import chalk from "chalk";
import { createMiddleware } from "hono/factory";
export const logger = createMiddleware(async (context, next) => {
const body = await context.req.raw.clone().text();
const urlAndMethod = `${chalk.green(context.req.method)} ${chalk.blue(context.req.url)}`;
const hash = `${chalk.bold("Hash")}: ${chalk.yellow(
new SHA256().update(body).digest("hex"),
)}`;
const headers = `${chalk.bold("Headers")}:\n${Array.from(
context.req.raw.headers.entries(),
)
.map(([key, value]) => ` - ${chalk.cyan(key)}: ${chalk.white(value)}`)
.join("\n")}`;
const bodyLog = `${chalk.bold("Body")}: ${chalk.gray(body)}`;
serverLogger.debug`${urlAndMethod}\n${hash}\n${headers}\n${bodyLog}`;
await next();
});

View file

@ -1,8 +1,8 @@
import type { ApiError } from "@versia-server/kit";
import { env } from "bun";
import type { MiddlewareHandler } from "hono";
import { rateLimiter } from "hono-rate-limiter";
import type { z } from "zod";
import type { ApiError } from "~/classes/errors/api-error";
import type { z } from "zod/v4";
import type { HonoEnv } from "~/types/api";
// Not exported by hono-rate-limiter

View file

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

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

View file

@ -1,12 +1,11 @@
import { RolePermission } from "@versia/client/schemas";
import { Hooks, Plugin } from "@versia/kit";
import { User } from "@versia/kit/db";
import { keyPair, sensitiveString, url } from "@versia-server/config";
import { ApiError, Hooks, Plugin } from "@versia-server/kit";
import { User } from "@versia-server/kit/db";
import { getCookie } from "hono/cookie";
import { jwtVerify } from "jose";
import { JOSEError, JWTExpired } from "jose/errors";
import { z } from "zod";
import { keyPair, sensitiveString, url } from "~/classes/config/schema.ts";
import { ApiError } from "~/classes/errors/api-error.ts";
import { z } from "zod/v4";
import authorizeRoute from "./routes/authorize.ts";
import jwksRoute from "./routes/jwks.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",
"description": "OpenID authentication.",
"version": "0.1.0",

View file

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

View file

@ -1,12 +1,11 @@
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 { describeRoute } from "hono-openapi";
import { validator } from "hono-openapi/zod";
import { describeRoute, validator } from "hono-openapi";
import { type JWTPayload, jwtVerify, SignJWT } from "jose";
import { JOSEError } from "jose/errors";
import { z } from "zod";
import { auth, handleZodError, jsonOrForm } from "@/api";
import { z } from "zod/v4";
import { randomString } from "@/math";
import { errorRedirect, errors } from "../errors.ts";
import type { PluginType } from "../index.ts";
@ -50,7 +49,6 @@ export default (plugin: PluginType): void =>
.object({
scope: z.string().optional(),
redirect_uri: z
.string()
.url()
.optional()
.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(
context,
errors.InvalidSub,

View file

@ -1,7 +1,7 @@
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 { fakeRequest } from "~/tests/utils";
const application = await Application.insert({
id: randomUUIDv7(),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
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);

View file

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

View file

@ -1,5 +1,5 @@
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);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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