mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 16:38:19 +01:00
Compare commits
81 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6462669e9e | ||
|
|
d18f135fbd | ||
|
|
59ad71964b | ||
|
|
bf890aec15 | ||
|
|
9cf85e951e | ||
|
|
814d63554f | ||
|
|
ce650a69d4 | ||
|
|
5e84fb66f9 | ||
|
|
1430d6f7e7 | ||
|
|
f00ac1a590 | ||
|
|
f260064083 | ||
|
|
f2e9c862a6 | ||
|
|
82bb92768c | ||
|
|
c63b2b320b | ||
|
|
a9dbd2cc4e | ||
|
|
ae207c10b6 | ||
|
|
955a933fe9 | ||
|
|
45c3f6ae3f | ||
|
|
bfa7a06958 | ||
|
|
c93071666a | ||
|
|
0d53436f7e | ||
|
|
d8f9f47814 | ||
|
|
b46f7828a5 | ||
|
|
1a0a27bee1 | ||
|
|
6f97903f3b | ||
|
|
1bfc5fb013 | ||
|
|
4eae4cd062 | ||
|
|
a6c9d6cd4f | ||
|
|
b5e9e35427 | ||
|
|
278bf960cb | ||
|
|
0bf5f7c983 | ||
|
|
870b6dbe85 | ||
|
|
2fffbcbede | ||
|
|
551b9a94fe | ||
|
|
24d4150da4 | ||
|
|
add2429606 | ||
|
|
eb096c5991 | ||
|
|
30bb801f9f | ||
|
|
6d7c545c88 | ||
|
|
a1300466f4 | ||
|
|
90b6399407 | ||
|
|
7de4b573e3 | ||
|
|
dc802ff5f6 | ||
|
|
59cd519337 | ||
|
|
aff51b651c | ||
|
|
e1bd389bf1 | ||
|
|
2310e8b33d | ||
|
|
129bc97b09 | ||
|
|
1a666e8371 | ||
|
|
03940cd8fd | ||
|
|
1f03017327 | ||
|
|
3798e170d0 | ||
|
|
5cae547f8d | ||
|
|
fde70fa61a | ||
|
|
a211772309 | ||
|
|
a6d3ebbeef | ||
|
|
79742f47dc | ||
|
|
4cc6284eb4 | ||
|
|
13d43e8e71 | ||
|
|
0ae8f632b5 | ||
|
|
85aceb2e48 | ||
|
|
0692aa6efa | ||
|
|
15e291b487 | ||
|
|
343a507ecc | ||
|
|
fa1dd69e2d | ||
|
|
e0adaca2a2 | ||
|
|
1fba91f772 | ||
|
|
710f965144 | ||
|
|
c737aeba8e | ||
|
|
9eac364e01 | ||
|
|
d3f411915f | ||
|
|
7bd07801f2 | ||
|
|
287f428a83 | ||
|
|
8c0a20a743 | ||
|
|
6d85dbdfcb | ||
|
|
77cd27a458 | ||
|
|
e5e688a154 | ||
|
|
fa5be6bd6a | ||
|
|
bf9840bd14 | ||
|
|
0551b8e12d | ||
|
|
9722b94eae |
|
|
@ -1,16 +1,18 @@
|
|||
version = 1
|
||||
|
||||
test_patterns = ["**/*.test.ts"]
|
||||
|
||||
[[analyzers]]
|
||||
name = "shell"
|
||||
|
||||
[[analyzers]]
|
||||
name = "javascript"
|
||||
|
||||
[analyzers.meta]
|
||||
environment = ["nodejs"]
|
||||
[analyzers.meta]
|
||||
environment = ["nodejs"]
|
||||
|
||||
[[analyzers]]
|
||||
name = "docker"
|
||||
|
||||
[analyzers.meta]
|
||||
dockerfile_paths = ["Dockerfile"]
|
||||
[analyzers.meta]
|
||||
dockerfile_paths = ["Dockerfile"]
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
# Bun doesn't run well on Musl but this seems to work
|
||||
FROM oven/bun:1.2.13-alpine as base
|
||||
|
||||
# Switch to Bash by editing /etc/passwd
|
||||
RUN apk add --no-cache libstdc++ git bash curl openssh cloc && \
|
||||
sed -i -e 's|/bin/ash|/bin/bash|g' /etc/passwd
|
||||
|
||||
# Extract Node from its docker image (node:22-alpine)
|
||||
COPY --from=node:22-alpine /usr/local/bin/node /usr/local/bin/node
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
{
|
||||
"name": "versia Dev Container",
|
||||
"dockerFile": "Dockerfile",
|
||||
"runArgs": [
|
||||
"-v",
|
||||
"${localWorkspaceFolder}/config:/workspace/config",
|
||||
"-v",
|
||||
"${localWorkspaceFolder}/logs:/workspace/logs",
|
||||
"-v",
|
||||
"${localWorkspaceFolder}/uploads:/workspace/uploads",
|
||||
"--network=host"
|
||||
],
|
||||
"mounts": [
|
||||
"source=node_modules,target=/workspace/node_modules,type=bind,consistency=cached",
|
||||
"type=bind,source=/home/${localEnv:USER}/.ssh,target=/root/.ssh,readonly"
|
||||
],
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {
|
||||
"terminal.integrated.shell.linux": "/bin/bash"
|
||||
},
|
||||
"extensions": [
|
||||
"biomejs.biome",
|
||||
"ms-vscode-remote.remote-containers",
|
||||
"oven.bun-vscode",
|
||||
"vivaxy.vscode-conventional-commits",
|
||||
"EditorConfig.EditorConfig",
|
||||
"tamasfe.even-better-toml",
|
||||
"YoavBls.pretty-ts-errors",
|
||||
"eamodio.gitlens"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
51
.github/config.workflow.toml
vendored
51
.github/config.workflow.toml
vendored
|
|
@ -429,53 +429,32 @@ text = "No spam"
|
|||
|
||||
[logging]
|
||||
|
||||
# Available levels: debug, info, warning, error, fatal
|
||||
log_level = "debug"
|
||||
|
||||
log_file_path = "logs/versia.log"
|
||||
|
||||
[logging.types]
|
||||
# Either pass a boolean
|
||||
# requests = true
|
||||
# Or a table with the following keys:
|
||||
# requests_content = { level = "debug", log_file_path = "logs/requests.log" }
|
||||
# Available types are: requests, responses, requests_content, filters
|
||||
# Available levels: trace, debug, info, warning, error, fatal
|
||||
log_level = "info" # For console output
|
||||
|
||||
# [logging.file]
|
||||
# path = "logs/versia.log"
|
||||
# log_level = "info"
|
||||
#
|
||||
# [logging.file.rotation]
|
||||
# max_size = 10_000_000 # 10 MB
|
||||
# max_files = 10 # Keep 10 rotated files
|
||||
#
|
||||
# https://sentry.io support
|
||||
# Uncomment to enable
|
||||
# [logging.sentry]
|
||||
# Sentry DSN for error logging
|
||||
# dsn = "https://example.com"
|
||||
# debug = false
|
||||
|
||||
# sample_rate = 1.0
|
||||
# traces_sample_rate = 1.0
|
||||
# Can also be regex
|
||||
# trace_propagation_targets = []
|
||||
# max_breadcrumbs = 100
|
||||
# environment = "production"
|
||||
# log_level = "info"
|
||||
|
||||
[plugins]
|
||||
# Whether to automatically load all plugins in the plugins directory
|
||||
autoload = true
|
||||
|
||||
# Override for autoload
|
||||
[plugins.overrides]
|
||||
enabled = []
|
||||
disabled = []
|
||||
|
||||
[plugins.config."@versia/openid"]
|
||||
# If enabled, Versia will require users to log in with an OpenID provider
|
||||
forced = false
|
||||
|
||||
# Allow registration with OpenID providers
|
||||
# If signups.registration is false, it will only be possible to register with OpenID
|
||||
allow_registration = true
|
||||
|
||||
[plugins.config."@versia/openid".keys]
|
||||
# Run Versia Server with those values missing to generate a new key
|
||||
public = "MCowBQYDK2VwAyEAfyZx8r98gVHtdH5EF1NYrBeChOXkt50mqiwKO2TX0f8="
|
||||
private = "MC4CAQAwBQYDK2VwBCIEILDi1g7+bwNjBBvL4CRWHZpCFBR2m2OPCot62Wr+TCbq"
|
||||
[authentication]
|
||||
# Run Versia Server with this value missing to generate a new key
|
||||
key = "ZWcwanRaQAqY3ChUro/Jey9XGQjzsxEed5iqTp4yFr8W6vEnXdz91F/Pu/uf7HBMbNeIK7V6aHsM0lq9onrO8Q=="
|
||||
|
||||
# The provider MUST support OpenID Connect with .well-known discovery
|
||||
# Most notably, GitHub does not support this
|
||||
|
|
@ -484,7 +463,7 @@ private = "MC4CAQAwBQYDK2VwBCIEILDi1g7+bwNjBBvL4CRWHZpCFBR2m2OPCot62Wr+TCbq"
|
|||
# The asterisk is important, as it allows for any query parameters to be passed
|
||||
# Authentik for example uses regex so it can be set to (regex):
|
||||
# <base_url>/oauth/sso/<provider_id>/callback.*
|
||||
# [[plugins.config."@versia/openid".providers]]
|
||||
# [[authentication.openid_providers]]
|
||||
# name = "CPlusPatch ID"
|
||||
# id = "cpluspatch-id"
|
||||
# This MUST match the provider's issuer URI, including the trailing slash (or lack thereof)
|
||||
|
|
|
|||
8
.github/copilot-instructions.md
vendored
8
.github/copilot-instructions.md
vendored
|
|
@ -13,4 +13,10 @@ const add = (a: number, b: number): number => a + b;
|
|||
|
||||
We always write TypeScript with double quotes and four spaces for indentation, so when your responses include TypeScript code, please follow those conventions.
|
||||
|
||||
Our codebase uses Drizzle as an ORM, with custom abstractions in `classes/database/` for interacting with the database. The `@versia/kit/db` and `@versia/kit/tables` packages are aliases for these abstractions.
|
||||
Our codebase uses Drizzle as an ORM, which is exposed in the `@versia-server/kit/db` and `@versia-server/kit/tables` packages. This project uses a monorepo structure with Bun as the package manager.
|
||||
|
||||
The app has two modes: worker and API. The worker mode is used for background tasks, while the API mode serves HTTP requests. The entry point for the worker is `worker.ts`, and for the API, it is `api.ts`.
|
||||
|
||||
Run the typechecker with `bun run typecheck` to ensure that all TypeScript code is type-checked correctly. Run tests with `bun test` to ensure that all tests pass. Run the linter and formatter with `bun lint` to ensure that the code adheres to our style guidelines, and `bun lint --write` to automatically fix minor/formatting issues.
|
||||
|
||||
Cover all new functionality with tests, and ensure that all tests pass before submitting your code.
|
||||
|
|
|
|||
2
.github/workflows/check.yml
vendored
2
.github/workflows/check.yml
vendored
|
|
@ -24,4 +24,4 @@ jobs:
|
|||
|
||||
- name: Run typechecks
|
||||
run: |
|
||||
bun run check
|
||||
bun run typecheck
|
||||
|
|
|
|||
27
.github/workflows/circular-imports.yml
vendored
Normal file
27
.github/workflows/circular-imports.yml
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
name: Check Circular Imports
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install NPM packages
|
||||
run: |
|
||||
bun install
|
||||
|
||||
- name: Run typechecks
|
||||
run: |
|
||||
bun run detect-circular
|
||||
5
.github/workflows/docker.yml
vendored
5
.github/workflows/docker.yml
vendored
|
|
@ -18,9 +18,12 @@ jobs:
|
|||
tests:
|
||||
uses: ./.github/workflows/tests.yml
|
||||
|
||||
detect-circular:
|
||||
uses: ./.github/workflows/circular-imports.yml
|
||||
|
||||
build:
|
||||
if: ${{ success() }}
|
||||
needs: [lint, check, tests]
|
||||
needs: [lint, check, tests, detect-circular]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
|
|||
4
.github/workflows/docs.yml
vendored
4
.github/workflows/docs.yml
vendored
|
|
@ -35,12 +35,12 @@ jobs:
|
|||
run: bun install
|
||||
|
||||
- name: Build with VitePress
|
||||
run: bun run docs:build
|
||||
run: bun run --filter="@versia-server/api" docs:build
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: docs/.vitepress/dist
|
||||
path: packages/api/docs/.vitepress/dist
|
||||
|
||||
# Deployment job
|
||||
deploy:
|
||||
|
|
|
|||
48
.github/workflows/publish.yml
vendored
Normal file
48
.github/workflows/publish.yml
vendored
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
name: Build & Publish Packages
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
package:
|
||||
description: "Package to publish"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- client
|
||||
- sdk
|
||||
tag:
|
||||
description: "NPM tag to use"
|
||||
required: true
|
||||
type: choice
|
||||
default: nightly
|
||||
options:
|
||||
- latest
|
||||
- nightly
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# For provenance generation
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
environment: NPM Deploy
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Publish to NPM
|
||||
working-directory: packages/${{ inputs.package }}
|
||||
run: bun publish --provenance --tag ${{ inputs.tag }} --access public
|
||||
env:
|
||||
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish to JSR
|
||||
working-directory: packages/${{ inputs.package }}
|
||||
run: bunx jsr publish --allow-slow-types --allow-dirty
|
||||
11
.github/workflows/test-publish.yml
vendored
11
.github/workflows/test-publish.yml
vendored
|
|
@ -23,16 +23,13 @@ jobs:
|
|||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install
|
||||
run: bun install
|
||||
|
||||
- name: Configure .npmrc
|
||||
working-directory: packages/${{ matrix.package }}
|
||||
run: |
|
||||
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > .npmrc
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Publish to NPM
|
||||
working-directory: packages/${{ matrix.package }}
|
||||
run: bun publish --dry-run
|
||||
env:
|
||||
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: bun publish --dry-run --access public
|
||||
|
||||
- name: Publish to JSR
|
||||
working-directory: packages/${{ matrix.package }}
|
||||
|
|
|
|||
7
.madgerc
Normal file
7
.madgerc
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"detectiveOptions": {
|
||||
"ts": {
|
||||
"skipTypeImports": true
|
||||
}
|
||||
}
|
||||
}
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
|
|
@ -6,9 +6,10 @@
|
|||
"cli",
|
||||
"federation",
|
||||
"config",
|
||||
"plugin",
|
||||
"worker",
|
||||
"media"
|
||||
"media",
|
||||
"packages/client",
|
||||
"packages/sdk"
|
||||
],
|
||||
"languageToolLinter.languageTool.ignoredWordsInWorkspace": ["versia"]
|
||||
}
|
||||
|
|
|
|||
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -1,3 +1,16 @@
|
|||
# `0.9.0` (upcoming)
|
||||
|
||||
## Features
|
||||
|
||||
### API
|
||||
|
||||
- [x] 🥺 Emoji Reactions are now available! You can react to any note with custom emojis.
|
||||
- [x] 🔎 Added support for [batch account data API](https://docs.joinmastodon.org/methods/accounts/#index).
|
||||
|
||||
### Backend
|
||||
|
||||
- [x] 🚀 Upgraded Bun to `1.3.2`
|
||||
|
||||
# `0.8.0` • Federation 2: Electric Boogaloo
|
||||
|
||||
## Backwards Compatibility
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -153,4 +153,4 @@ If you find a bug, please open an issue on GitHub. Please make sure to include t
|
|||
|
||||
# License
|
||||
|
||||
Versia Server is licensed under the [AGPLv3 or later](https://www.gnu.org/licenses/agpl-3.0.en.html) license. By contributing to Versia, you agree to license your contributions under the same license.
|
||||
Versia Server is licensed under the [AGPLv3 or later](https://www.gnu.org/licenses/agpl-3.0.en.html) license. By contributing to Versia, you agree to license your contributions under the same license.
|
||||
|
|
|
|||
15
Dockerfile
15
Dockerfile
|
|
@ -1,7 +1,5 @@
|
|||
# Node is required for building the project
|
||||
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.13-alpine
|
||||
FROM oven/bun:1.3.2-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" ]
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ The following extensions are currently supported or being worked on:
|
|||
- `pub.versia:instance_messaging`: Instance Messaging
|
||||
- `pub.versia:likes`: Likes
|
||||
- `pub.versia:share`: Share
|
||||
- `pub.versia:reactions`: Reactions
|
||||
|
||||
## API
|
||||
|
||||
|
|
|
|||
|
|
@ -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.13-alpine
|
||||
FROM oven/bun:1.3.2-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" ]
|
||||
|
|
|
|||
19
api.ts
Normal file
19
api.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import process from "node:process";
|
||||
import { appFactory } from "@versia-server/api";
|
||||
import { config } from "@versia-server/config";
|
||||
import { Youch } from "youch";
|
||||
import { createServer } from "@/server.ts";
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
process.exit();
|
||||
});
|
||||
|
||||
process.on("uncaughtException", async (error) => {
|
||||
const youch = new Youch();
|
||||
|
||||
console.error(await youch.toANSI(error));
|
||||
});
|
||||
|
||||
await import("@versia-server/api/setup");
|
||||
|
||||
createServer(config, await appFactory());
|
||||
|
|
@ -1,229 +0,0 @@
|
|||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { Application } from "@versia/kit/db";
|
||||
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);
|
||||
|
||||
// Create application
|
||||
const application = await Application.insert({
|
||||
id: randomUUIDv7(),
|
||||
name: "Test Application",
|
||||
clientId: randomString(32, "hex"),
|
||||
secret: "test",
|
||||
redirectUri: "https://example.com",
|
||||
scopes: "read write",
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
await application.delete();
|
||||
});
|
||||
|
||||
// /api/auth/login
|
||||
describe("/api/auth/login", () => {
|
||||
test("should get a JWT with email", async () => {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append("identifier", users[0]?.data.email ?? "");
|
||||
formData.append("password", passwords[0]);
|
||||
|
||||
const response = await fakeRequest(
|
||||
`/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBeDefined();
|
||||
const locationHeader = new URL(
|
||||
response.headers.get("Location") ?? "",
|
||||
config.http.base_url,
|
||||
);
|
||||
|
||||
expect(locationHeader.pathname).toBe("/oauth/consent");
|
||||
expect(locationHeader.searchParams.get("client_id")).toBe(
|
||||
application.data.clientId,
|
||||
);
|
||||
expect(locationHeader.searchParams.get("redirect_uri")).toBe(
|
||||
"https://example.com",
|
||||
);
|
||||
expect(locationHeader.searchParams.get("response_type")).toBe("code");
|
||||
expect(locationHeader.searchParams.get("scope")).toBe("read write");
|
||||
|
||||
expect(response.headers.get("Set-Cookie")).toMatch(/jwt=[^;]+;/);
|
||||
});
|
||||
|
||||
test("should get a JWT with username", async () => {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append("identifier", users[0]?.data.username ?? "");
|
||||
formData.append("password", passwords[0]);
|
||||
|
||||
const response = await fakeRequest(
|
||||
`/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBeDefined();
|
||||
const locationHeader = new URL(
|
||||
response.headers.get("Location") ?? "",
|
||||
config.http.base_url,
|
||||
);
|
||||
|
||||
expect(locationHeader.pathname).toBe("/oauth/consent");
|
||||
expect(locationHeader.searchParams.get("client_id")).toBe(
|
||||
application.data.clientId,
|
||||
);
|
||||
expect(locationHeader.searchParams.get("redirect_uri")).toBe(
|
||||
"https://example.com",
|
||||
);
|
||||
expect(locationHeader.searchParams.get("response_type")).toBe("code");
|
||||
expect(locationHeader.searchParams.get("scope")).toBe("read write");
|
||||
|
||||
expect(response.headers.get("Set-Cookie")).toMatch(/jwt=[^;]+;/);
|
||||
});
|
||||
|
||||
test("should have state in the URL", async () => {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append("identifier", users[0]?.data.email ?? "");
|
||||
formData.append("password", passwords[0]);
|
||||
|
||||
const response = await fakeRequest(
|
||||
`/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write&state=abc`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBeDefined();
|
||||
const locationHeader = new URL(
|
||||
response.headers.get("Location") ?? "",
|
||||
config.http.base_url,
|
||||
);
|
||||
|
||||
expect(locationHeader.pathname).toBe("/oauth/consent");
|
||||
expect(locationHeader.searchParams.get("client_id")).toBe(
|
||||
application.data.clientId,
|
||||
);
|
||||
expect(locationHeader.searchParams.get("redirect_uri")).toBe(
|
||||
"https://example.com",
|
||||
);
|
||||
expect(locationHeader.searchParams.get("response_type")).toBe("code");
|
||||
expect(locationHeader.searchParams.get("scope")).toBe("read write");
|
||||
expect(locationHeader.searchParams.get("state")).toBe("abc");
|
||||
|
||||
expect(response.headers.get("Set-Cookie")).toMatch(/jwt=[^;]+;/);
|
||||
});
|
||||
|
||||
describe("should reject invalid credentials", () => {
|
||||
// Redirects to /oauth/authorize on invalid
|
||||
test("invalid email", async () => {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append("identifier", "ababa@gmail.com");
|
||||
formData.append("password", "password");
|
||||
|
||||
const response = await fakeRequest(
|
||||
`/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
|
||||
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBeDefined();
|
||||
const locationHeader = new URL(
|
||||
response.headers.get("Location") ?? "",
|
||||
"",
|
||||
);
|
||||
|
||||
expect(locationHeader.pathname).toBe("/oauth/authorize");
|
||||
expect(locationHeader.searchParams.get("error")).toBe(
|
||||
"invalid_grant",
|
||||
);
|
||||
expect(locationHeader.searchParams.get("error_description")).toBe(
|
||||
"Invalid identifier or password",
|
||||
);
|
||||
|
||||
expect(response.headers.get("Set-Cookie")).toBeNull();
|
||||
});
|
||||
|
||||
test("invalid username", async () => {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append("identifier", "ababa");
|
||||
formData.append("password", "password");
|
||||
|
||||
const response = await fakeRequest(
|
||||
`/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBeDefined();
|
||||
const locationHeader = new URL(
|
||||
response.headers.get("Location") ?? "",
|
||||
"",
|
||||
);
|
||||
|
||||
expect(locationHeader.pathname).toBe("/oauth/authorize");
|
||||
expect(locationHeader.searchParams.get("error")).toBe(
|
||||
"invalid_grant",
|
||||
);
|
||||
expect(locationHeader.searchParams.get("error_description")).toBe(
|
||||
"Invalid identifier or password",
|
||||
);
|
||||
|
||||
expect(response.headers.get("Set-Cookie")).toBeNull();
|
||||
});
|
||||
|
||||
test("invalid password", async () => {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append("identifier", users[0]?.data.email ?? "");
|
||||
formData.append("password", "password");
|
||||
|
||||
const response = await fakeRequest(
|
||||
`/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBeDefined();
|
||||
const locationHeader = new URL(
|
||||
response.headers.get("Location") ?? "",
|
||||
"",
|
||||
);
|
||||
|
||||
expect(locationHeader.pathname).toBe("/oauth/authorize");
|
||||
expect(locationHeader.searchParams.get("error")).toBe(
|
||||
"invalid_grant",
|
||||
);
|
||||
expect(locationHeader.searchParams.get("error_description")).toBe(
|
||||
"Invalid identifier or password",
|
||||
);
|
||||
|
||||
expect(response.headers.get("Set-Cookie")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,231 +0,0 @@
|
|||
import { Application, User } from "@versia/kit/db";
|
||||
import { Users } from "@versia/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 { SignJWT } from "jose";
|
||||
import { z } from "zod";
|
||||
import { apiRoute, handleZodError } from "@/api";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { config } from "~/config.ts";
|
||||
|
||||
const returnError = (
|
||||
context: Context,
|
||||
error: string,
|
||||
description: string,
|
||||
): Response => {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
// Add all data that is not undefined except email and password
|
||||
for (const [key, value] of Object.entries(context.req.query())) {
|
||||
if (key !== "email" && key !== "password" && value !== undefined) {
|
||||
searchParams.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
searchParams.append("error", error);
|
||||
searchParams.append("error_description", description);
|
||||
|
||||
return context.redirect(
|
||||
new URL(
|
||||
`${config.frontend.routes.login}?${searchParams.toString()}`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
);
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
"/api/auth/login",
|
||||
describeRoute({
|
||||
summary: "Login",
|
||||
description: "Login to the application",
|
||||
responses: {
|
||||
302: {
|
||||
description: "Redirect to OAuth authorize, or error",
|
||||
headers: {
|
||||
"Set-Cookie": {
|
||||
description: "JWT cookie",
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
scope: z.string().optional(),
|
||||
redirect_uri: z.string().url().optional(),
|
||||
response_type: z.enum([
|
||||
"code",
|
||||
"token",
|
||||
"none",
|
||||
"id_token",
|
||||
"code id_token",
|
||||
"code token",
|
||||
"token id_token",
|
||||
"code token id_token",
|
||||
]),
|
||||
client_id: z.string(),
|
||||
state: z.string().optional(),
|
||||
code_challenge: z.string().optional(),
|
||||
code_challenge_method: z.enum(["plain", "S256"]).optional(),
|
||||
prompt: z
|
||||
.enum(["none", "login", "consent", "select_account"])
|
||||
.optional()
|
||||
.default("none"),
|
||||
max_age: z
|
||||
.number()
|
||||
.int()
|
||||
.optional()
|
||||
.default(60 * 60 * 24 * 7),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
validator(
|
||||
"form",
|
||||
z.object({
|
||||
identifier: z
|
||||
.string()
|
||||
.email()
|
||||
.toLowerCase()
|
||||
.or(z.string().toLowerCase()),
|
||||
password: z.string().min(2).max(100),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const oidcConfig = config.plugins?.config?.["@versia/openid"] as
|
||||
| {
|
||||
forced: boolean;
|
||||
providers: {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
}[];
|
||||
keys: {
|
||||
private: string;
|
||||
public: string;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!oidcConfig) {
|
||||
return returnError(
|
||||
context,
|
||||
"invalid_request",
|
||||
"The OpenID Connect plugin is not enabled on this instance. Cannot process login request.",
|
||||
);
|
||||
}
|
||||
|
||||
if (oidcConfig?.forced) {
|
||||
return returnError(
|
||||
context,
|
||||
"invalid_request",
|
||||
"Logging in with a password is disabled by the administrator. Please use a valid OpenID Connect provider.",
|
||||
);
|
||||
}
|
||||
|
||||
const { identifier, password } = context.req.valid("form");
|
||||
const { client_id } = context.req.valid("query");
|
||||
|
||||
// Find user
|
||||
const user = await User.fromSql(
|
||||
or(
|
||||
eq(Users.email, identifier.toLowerCase()),
|
||||
eq(Users.username, identifier.toLowerCase()),
|
||||
),
|
||||
);
|
||||
|
||||
if (
|
||||
!(
|
||||
user &&
|
||||
(await bunPassword.verify(
|
||||
password,
|
||||
user.data.password || "",
|
||||
))
|
||||
)
|
||||
) {
|
||||
return returnError(
|
||||
context,
|
||||
"invalid_grant",
|
||||
"Invalid identifier or password",
|
||||
);
|
||||
}
|
||||
|
||||
if (user.data.passwordResetToken) {
|
||||
return context.redirect(
|
||||
`${config.frontend.routes.password_reset}?${new URLSearchParams(
|
||||
{
|
||||
token: user.data.passwordResetToken ?? "",
|
||||
login_reset: "true",
|
||||
},
|
||||
).toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Try and import the key
|
||||
const privateKey = await crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
Buffer.from(oidcConfig?.keys?.private ?? "", "base64"),
|
||||
"Ed25519",
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
|
||||
// Generate JWT
|
||||
const jwt = await new SignJWT({
|
||||
sub: user.id,
|
||||
iss: config.http.base_url.origin,
|
||||
aud: client_id,
|
||||
exp: Math.floor(Date.now() / 1000) + 60 * 60,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
nbf: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
.setProtectedHeader({ alg: "EdDSA" })
|
||||
.sign(privateKey);
|
||||
|
||||
const application = await Application.fromClientId(client_id);
|
||||
|
||||
if (!application) {
|
||||
throw new ApiError(400, "Invalid application");
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
application: application.data.name,
|
||||
});
|
||||
|
||||
if (application.data.website) {
|
||||
searchParams.append("website", application.data.website);
|
||||
}
|
||||
|
||||
// Add all data that is not undefined except email and password
|
||||
for (const [key, value] of Object.entries(context.req.query())) {
|
||||
if (
|
||||
key !== "email" &&
|
||||
key !== "password" &&
|
||||
value !== undefined
|
||||
) {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect to OAuth authorize with JWT
|
||||
setCookie(context, "jwt", jwt, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Strict",
|
||||
path: "/",
|
||||
// 2 weeks
|
||||
maxAge: 60 * 60 * 24 * 14,
|
||||
});
|
||||
return context.redirect(
|
||||
`${config.frontend.routes.consent}?${searchParams.toString()}`,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
import { db } from "@versia/kit/db";
|
||||
import { Applications, Tokens } from "@versia/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";
|
||||
|
||||
/**
|
||||
* OAuth Code flow
|
||||
*/
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/api/auth/redirect",
|
||||
describeRoute({
|
||||
summary: "OAuth Code flow",
|
||||
description:
|
||||
"Redirects to the application, or back to login if the code is invalid",
|
||||
tags: ["OpenID"],
|
||||
responses: {
|
||||
302: {
|
||||
description:
|
||||
"Redirects to the application, or back to login if the code is invalid",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
redirect_uri: z.string().url(),
|
||||
client_id: z.string(),
|
||||
code: z.string(),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { redirect_uri, client_id, code } =
|
||||
context.req.valid("query");
|
||||
|
||||
const redirectToLogin = (error: string): Response =>
|
||||
context.redirect(
|
||||
`${config.frontend.routes.login}?${new URLSearchParams({
|
||||
...context.req.query,
|
||||
error: encodeURIComponent(error),
|
||||
}).toString()}`,
|
||||
);
|
||||
|
||||
const foundToken = await db
|
||||
.select()
|
||||
.from(Tokens)
|
||||
.leftJoin(
|
||||
Applications,
|
||||
eq(Tokens.applicationId, Applications.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(Tokens.code, code),
|
||||
eq(Applications.clientId, client_id),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!foundToken || foundToken.length <= 0) {
|
||||
return redirectToLogin("Invalid code");
|
||||
}
|
||||
|
||||
// Redirect back to application
|
||||
return context.redirect(
|
||||
`${redirect_uri}?${new URLSearchParams({
|
||||
code,
|
||||
}).toString()}`,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { Application } from "@versia/kit/db";
|
||||
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");
|
||||
const newPassword = randomString(16, "hex");
|
||||
|
||||
// Create application
|
||||
const application = await Application.insert({
|
||||
id: randomUUIDv7(),
|
||||
name: "Test Application",
|
||||
clientId: randomString(32, "hex"),
|
||||
secret: "test",
|
||||
redirectUri: "https://example.com",
|
||||
scopes: "read write",
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
await application.delete();
|
||||
});
|
||||
|
||||
// /api/auth/reset
|
||||
describe("/api/auth/reset", () => {
|
||||
test("should login with normal password", async () => {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append("identifier", users[0]?.data.username ?? "");
|
||||
formData.append("password", passwords[0]);
|
||||
|
||||
const response = await fakeRequest(
|
||||
`/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
|
||||
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBeDefined();
|
||||
});
|
||||
|
||||
test("should reset password and refuse login with old password", async () => {
|
||||
await users[0]?.update({
|
||||
passwordResetToken: token,
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append("identifier", users[0]?.data.username ?? "");
|
||||
formData.append("password", passwords[0]);
|
||||
|
||||
const response = await fakeRequest(
|
||||
`/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBeDefined();
|
||||
const locationHeader = new URL(
|
||||
response.headers.get("Location") ?? "",
|
||||
config.http.base_url,
|
||||
);
|
||||
|
||||
expect(locationHeader.pathname).toBe("/oauth/reset");
|
||||
expect(locationHeader.searchParams.get("token")).toBe(token);
|
||||
});
|
||||
|
||||
test("should reset password and login with new password", async () => {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append("token", token);
|
||||
formData.append("password", newPassword);
|
||||
formData.append("password2", newPassword);
|
||||
|
||||
const response = await fakeRequest("/api/auth/reset", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBeDefined();
|
||||
|
||||
const loginFormData = new FormData();
|
||||
|
||||
loginFormData.append("identifier", users[0]?.data.username ?? "");
|
||||
loginFormData.append("password", newPassword);
|
||||
|
||||
const loginResponse = await fakeRequest(
|
||||
`/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
|
||||
{
|
||||
method: "POST",
|
||||
body: loginFormData,
|
||||
},
|
||||
);
|
||||
|
||||
expect(loginResponse.status).toBe(302);
|
||||
expect(loginResponse.headers.get("location")).toBeDefined();
|
||||
const locationHeader = new URL(
|
||||
loginResponse.headers.get("Location") ?? "",
|
||||
config.http.base_url,
|
||||
);
|
||||
|
||||
expect(locationHeader.pathname).toBe("/oauth/consent");
|
||||
expect(locationHeader.searchParams.get("client_id")).toBe(
|
||||
application.data.clientId,
|
||||
);
|
||||
expect(locationHeader.searchParams.get("redirect_uri")).toBe(
|
||||
"https://example.com",
|
||||
);
|
||||
expect(locationHeader.searchParams.get("response_type")).toBe("code");
|
||||
expect(locationHeader.searchParams.get("scope")).toBe("read write");
|
||||
|
||||
expect(loginResponse.headers.get("Set-Cookie")).toMatch(/jwt=[^;]+;/);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
import { User } from "@versia/kit/db";
|
||||
import { Users } from "@versia/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";
|
||||
|
||||
const returnError = (
|
||||
context: Context,
|
||||
token: string,
|
||||
error: string,
|
||||
description: string,
|
||||
): Response => {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
searchParams.append("error", error);
|
||||
searchParams.append("error_description", description);
|
||||
searchParams.append("token", token);
|
||||
|
||||
return context.redirect(
|
||||
new URL(
|
||||
`${
|
||||
config.frontend.routes.password_reset
|
||||
}?${searchParams.toString()}`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
);
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
"/api/auth/reset",
|
||||
describeRoute({
|
||||
summary: "Reset password",
|
||||
description: "Reset password",
|
||||
responses: {
|
||||
302: {
|
||||
description:
|
||||
"Redirect to the password reset page with a message",
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"form",
|
||||
z.object({
|
||||
token: z.string().min(1),
|
||||
password: z.string().min(3).max(100),
|
||||
}),
|
||||
handleZodError,
|
||||
),
|
||||
async (context) => {
|
||||
const { token, password } = context.req.valid("form");
|
||||
|
||||
const user = await User.fromSql(
|
||||
eq(Users.passwordResetToken, token),
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return returnError(
|
||||
context,
|
||||
token,
|
||||
"invalid_token",
|
||||
"Invalid token",
|
||||
);
|
||||
}
|
||||
|
||||
await user.update({
|
||||
password: await bunPassword.hash(password),
|
||||
passwordResetToken: null,
|
||||
});
|
||||
|
||||
return context.redirect(
|
||||
`${config.frontend.routes.password_reset}?success=true`,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { apiRoute } from "@/api";
|
||||
import { config } from "~/config.ts";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
"/.well-known/openid-configuration",
|
||||
describeRoute({
|
||||
summary: "OpenID Configuration",
|
||||
tags: ["OpenID"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "OpenID Configuration",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.object({
|
||||
issuer: z.string(),
|
||||
authorization_endpoint: z.string(),
|
||||
token_endpoint: z.string(),
|
||||
userinfo_endpoint: z.string(),
|
||||
jwks_uri: z.string(),
|
||||
response_types_supported: z.array(
|
||||
z.string(),
|
||||
),
|
||||
subject_types_supported: z.array(
|
||||
z.string(),
|
||||
),
|
||||
id_token_signing_alg_values_supported:
|
||||
z.array(z.string()),
|
||||
scopes_supported: z.array(z.string()),
|
||||
token_endpoint_auth_methods_supported:
|
||||
z.array(z.string()),
|
||||
claims_supported: z.array(z.string()),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
(context) => {
|
||||
const baseUrl = config.http.base_url;
|
||||
return context.json(
|
||||
{
|
||||
issuer: baseUrl.origin.toString(),
|
||||
authorization_endpoint: `${baseUrl.origin}/oauth/authorize`,
|
||||
token_endpoint: `${baseUrl.origin}/oauth/token`,
|
||||
userinfo_endpoint: `${baseUrl.origin}/api/v1/accounts/verify_credentials`,
|
||||
jwks_uri: `${baseUrl.origin}/.well-known/jwks`,
|
||||
response_types_supported: ["code"],
|
||||
subject_types_supported: ["public"],
|
||||
id_token_signing_alg_values_supported: ["EdDSA"],
|
||||
scopes_supported: ["openid", "profile", "email"],
|
||||
token_endpoint_auth_methods_supported: [
|
||||
"client_secret_basic",
|
||||
],
|
||||
claims_supported: ["sub"],
|
||||
},
|
||||
200,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -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);
|
||||
|
||||
const { users, tokens, deleteUsers } = await getTestUsers(5);
|
||||
await getTestStatuses(40, users[0]);
|
||||
|
|
|
|||
33
biome.json
33
biome.json
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.0.0-beta.5/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.4/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"
|
||||
|
|
@ -113,29 +119,20 @@
|
|||
"noUnusedPrivateClassMembers": "error"
|
||||
},
|
||||
"nursery": {
|
||||
"noBitwiseOperators": "error",
|
||||
"noConstantBinaryExpression": "error",
|
||||
"noFloatingPromises": "error",
|
||||
"noGlobalDirnameFilename": "error",
|
||||
"noProcessGlobal": "warn",
|
||||
"noTsIgnore": "warn",
|
||||
"useConsistentObjectDefinition": {
|
||||
"level": "warn",
|
||||
"options": {
|
||||
"syntax": "shorthand"
|
||||
}
|
||||
},
|
||||
"useParseIntRadix": "warn"
|
||||
"noFloatingPromises": "error"
|
||||
},
|
||||
"complexity": {
|
||||
"noForEach": "error",
|
||||
"noImportantStyles": "off",
|
||||
"noUselessStringConcat": "error",
|
||||
"useDateNow": "error",
|
||||
"noUselessStringRaw": "warn",
|
||||
"noUselessEscapeInRegex": "warn",
|
||||
"useSimplifiedLogicExpression": "error",
|
||||
"useWhile": "error",
|
||||
"useNumericLiterals": "error"
|
||||
"useNumericLiterals": "error",
|
||||
"noArguments": "error",
|
||||
"noCommaOperator": "error"
|
||||
},
|
||||
"suspicious": {
|
||||
"noDuplicateTestHooks": "error",
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
import { $, build } from "bun";
|
||||
|
||||
console.log("Building...");
|
||||
|
||||
await $`rm -rf dist && mkdir dist`;
|
||||
|
||||
await build({
|
||||
entrypoints: [
|
||||
"worker.ts",
|
||||
// HACK: Include to avoid cyclical import errors
|
||||
"config.ts",
|
||||
],
|
||||
outdir: "dist",
|
||||
target: "bun",
|
||||
splitting: true,
|
||||
minify: false,
|
||||
});
|
||||
|
||||
console.log("Copying files...");
|
||||
|
||||
// Copy Drizzle migrations to dist
|
||||
await $`cp -rL drizzle dist/drizzle`;
|
||||
|
||||
// Copy Sharp to dist
|
||||
await $`mkdir -p dist/node_modules/@img`;
|
||||
await $`cp -rL node_modules/@img/sharp-libvips-linux* dist/node_modules/@img`;
|
||||
await $`cp -rL node_modules/@img/sharp-linux* dist/node_modules/@img`;
|
||||
|
||||
await $`cp -rL node_modules/detect-libc dist/node_modules/`;
|
||||
|
||||
console.log("Build complete!");
|
||||
84
build.ts
84
build.ts
|
|
@ -1,63 +1,55 @@
|
|||
import { readdir } from "node:fs/promises";
|
||||
import { $, build } from "bun";
|
||||
import { routes } from "~/routes";
|
||||
import process from "node:process";
|
||||
import { $, build, file, write } from "bun";
|
||||
import manifest from "./package.json" with { type: "json" };
|
||||
|
||||
console.log("Building...");
|
||||
|
||||
await $`rm -rf dist && mkdir dist`;
|
||||
|
||||
// Get all directories under the plugins/ directory
|
||||
const pluginDirs = await readdir("plugins", { withFileTypes: true });
|
||||
const type = process.argv[2] as "api" | "worker";
|
||||
|
||||
if (type !== "api" && type !== "worker") {
|
||||
throw new Error("Invalid build type. Use 'api' or 'worker'.");
|
||||
}
|
||||
|
||||
const packages = Object.keys(manifest.dependencies)
|
||||
.filter((dep) => dep.startsWith("@versia"))
|
||||
.filter((dep) => dep !== "@versia-server/tests");
|
||||
|
||||
await build({
|
||||
entrypoints: [
|
||||
"index.ts",
|
||||
// HACK: Include to avoid cyclical import errors
|
||||
"config.ts",
|
||||
"cli/index.ts",
|
||||
// Force Bun to include endpoints
|
||||
...Object.values(routes),
|
||||
// Include all plugins
|
||||
...pluginDirs
|
||||
.filter((dir) => dir.isDirectory())
|
||||
.map((dir) => `plugins/${dir.name}/index.ts`),
|
||||
],
|
||||
entrypoints: [`./${type}.ts`],
|
||||
outdir: "dist",
|
||||
target: "bun",
|
||||
splitting: true,
|
||||
minify: false,
|
||||
external: ["acorn", "@bull-board/ui"],
|
||||
minify: true,
|
||||
external: [...packages],
|
||||
});
|
||||
|
||||
console.log("Copying files...");
|
||||
|
||||
// Copy Drizzle migrations to dist
|
||||
await $`cp -r drizzle dist/drizzle`;
|
||||
// Copy each package into dist/node_modules
|
||||
for (const pkg of packages) {
|
||||
const directory = pkg.split("/")[1] || pkg;
|
||||
await $`mkdir -p dist/node_modules/${pkg}`;
|
||||
// Copy the built package files
|
||||
await $`cp -rL packages/${directory}/{dist,package.json} dist/node_modules/${pkg}`;
|
||||
|
||||
// Copy plugin manifests
|
||||
await $`cp plugins/openid/manifest.json dist/plugins/openid/manifest.json`;
|
||||
|
||||
await $`mkdir -p dist/node_modules`;
|
||||
|
||||
// Copy Sharp to dist
|
||||
await $`mkdir -p dist/node_modules/@img`;
|
||||
await $`cp -rL node_modules/@img/sharp-libvips-linux* dist/node_modules/@img`;
|
||||
await $`cp -rL node_modules/@img/sharp-linux* dist/node_modules/@img`;
|
||||
|
||||
// Copy acorn to dist
|
||||
await $`cp -rL node_modules/acorn dist/node_modules/acorn`;
|
||||
|
||||
// Copy bull-board to dist
|
||||
await $`mkdir -p dist/node_modules/@bull-board`;
|
||||
await $`cp -rL node_modules/@bull-board/ui dist/node_modules/@bull-board/ui`;
|
||||
|
||||
// Copy the Bee Movie script from pages
|
||||
await $`cp beemovie.txt dist/beemovie.txt`;
|
||||
|
||||
// Copy package.json
|
||||
await $`cp package.json dist/package.json`;
|
||||
|
||||
// Fixes issues with sharp
|
||||
await $`cp -rL node_modules/detect-libc dist/node_modules/`;
|
||||
// Rewrite package.json "exports" field to point to the dist directory and use .js extension
|
||||
const packageJsonPath = `dist/node_modules/${pkg}/package.json`;
|
||||
const packageJson = await file(packageJsonPath).json();
|
||||
for (const [key, value] of Object.entries(packageJson.exports) as [
|
||||
string,
|
||||
{ import?: string },
|
||||
][]) {
|
||||
if (value.import) {
|
||||
packageJson.exports[key] = {
|
||||
import: value.import
|
||||
.replace("./", "./dist/")
|
||||
.replace(/\.ts$/, ".js"),
|
||||
};
|
||||
}
|
||||
}
|
||||
await write(packageJsonPath, JSON.stringify(packageJson, null, 4));
|
||||
}
|
||||
|
||||
console.log("Build complete!");
|
||||
|
|
|
|||
|
|
@ -2,4 +2,7 @@
|
|||
"@jsr" = "https://npm.jsr.io"
|
||||
|
||||
[test]
|
||||
preload = ["./tests/setup.ts"]
|
||||
preload = ["./packages/tests/setup.ts"]
|
||||
|
||||
[install]
|
||||
linker = "hoisted"
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
import { zodToJsonSchema } from "zod-to-json-schema";
|
||||
import { ConfigSchema } from "./schema.ts";
|
||||
|
||||
const jsonSchema = zodToJsonSchema(ConfigSchema, {});
|
||||
|
||||
console.write(`${JSON.stringify(jsonSchema, null, 4)}\n`);
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
import type {
|
||||
Application as ApplicationSchema,
|
||||
CredentialApplication,
|
||||
} from "@versia/client/schemas";
|
||||
import { db, Token } from "@versia/kit/db";
|
||||
import { Applications } from "@versia/kit/tables";
|
||||
import {
|
||||
desc,
|
||||
eq,
|
||||
type InferInsertModel,
|
||||
type InferSelectModel,
|
||||
inArray,
|
||||
type SQL,
|
||||
} from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
import { BaseInterface } from "./base.ts";
|
||||
|
||||
type ApplicationType = InferSelectModel<typeof Applications>;
|
||||
|
||||
export class Application extends BaseInterface<typeof Applications> {
|
||||
public static $type: ApplicationType;
|
||||
|
||||
public async reload(): Promise<void> {
|
||||
const reloaded = await Application.fromId(this.data.id);
|
||||
|
||||
if (!reloaded) {
|
||||
throw new Error("Failed to reload application");
|
||||
}
|
||||
|
||||
this.data = reloaded.data;
|
||||
}
|
||||
|
||||
public static async fromId(id: string | null): Promise<Application | null> {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await Application.fromSql(eq(Applications.id, id));
|
||||
}
|
||||
|
||||
public static async fromIds(ids: string[]): Promise<Application[]> {
|
||||
return await Application.manyFromSql(inArray(Applications.id, ids));
|
||||
}
|
||||
|
||||
public static async fromSql(
|
||||
sql: SQL<unknown> | undefined,
|
||||
orderBy: SQL<unknown> | undefined = desc(Applications.id),
|
||||
): Promise<Application | null> {
|
||||
const found = await db.query.Applications.findFirst({
|
||||
where: sql,
|
||||
orderBy,
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
return null;
|
||||
}
|
||||
return new Application(found);
|
||||
}
|
||||
|
||||
public static async manyFromSql(
|
||||
sql: SQL<unknown> | undefined,
|
||||
orderBy: SQL<unknown> | undefined = desc(Applications.id),
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
extra?: Parameters<typeof db.query.Applications.findMany>[0],
|
||||
): Promise<Application[]> {
|
||||
const found = await db.query.Applications.findMany({
|
||||
where: sql,
|
||||
orderBy,
|
||||
limit,
|
||||
offset,
|
||||
with: extra?.with,
|
||||
});
|
||||
|
||||
return found.map((s) => new Application(s));
|
||||
}
|
||||
|
||||
public static async getFromToken(
|
||||
token: string,
|
||||
): Promise<Application | null> {
|
||||
const result = await Token.fromAccessToken(token);
|
||||
|
||||
return result?.data.application
|
||||
? new Application(result.data.application)
|
||||
: null;
|
||||
}
|
||||
|
||||
public static fromClientId(clientId: string): Promise<Application | null> {
|
||||
return Application.fromSql(eq(Applications.clientId, clientId));
|
||||
}
|
||||
|
||||
public async update(
|
||||
newApplication: Partial<ApplicationType>,
|
||||
): Promise<ApplicationType> {
|
||||
await db
|
||||
.update(Applications)
|
||||
.set(newApplication)
|
||||
.where(eq(Applications.id, this.id));
|
||||
|
||||
const updated = await Application.fromId(this.data.id);
|
||||
|
||||
if (!updated) {
|
||||
throw new Error("Failed to update application");
|
||||
}
|
||||
|
||||
this.data = updated.data;
|
||||
return updated.data;
|
||||
}
|
||||
|
||||
public save(): Promise<ApplicationType> {
|
||||
return this.update(this.data);
|
||||
}
|
||||
|
||||
public async delete(ids?: string[]): Promise<void> {
|
||||
if (Array.isArray(ids)) {
|
||||
await db.delete(Applications).where(inArray(Applications.id, ids));
|
||||
} else {
|
||||
await db.delete(Applications).where(eq(Applications.id, this.id));
|
||||
}
|
||||
}
|
||||
|
||||
public static async insert(
|
||||
data: InferInsertModel<typeof Applications>,
|
||||
): Promise<Application> {
|
||||
const inserted = (
|
||||
await db.insert(Applications).values(data).returning()
|
||||
)[0];
|
||||
|
||||
const application = await Application.fromId(inserted.id);
|
||||
|
||||
if (!application) {
|
||||
throw new Error("Failed to insert application");
|
||||
}
|
||||
|
||||
return application;
|
||||
}
|
||||
|
||||
public get id(): string {
|
||||
return this.data.id;
|
||||
}
|
||||
|
||||
public toApi(): z.infer<typeof ApplicationSchema> {
|
||||
return {
|
||||
name: this.data.name,
|
||||
website: this.data.website,
|
||||
scopes: this.data.scopes.split(" "),
|
||||
redirect_uri: this.data.redirectUri,
|
||||
redirect_uris: this.data.redirectUri.split("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
public toApiCredential(): z.infer<typeof CredentialApplication> {
|
||||
return {
|
||||
name: this.data.name,
|
||||
website: this.data.website,
|
||||
client_id: this.data.clientId,
|
||||
client_secret: this.data.secret,
|
||||
client_secret_expires_at: "0",
|
||||
scopes: this.data.scopes.split(" "),
|
||||
redirect_uri: this.data.redirectUri,
|
||||
redirect_uris: this.data.redirectUri.split("\n"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,339 +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,
|
||||
},
|
||||
},
|
||||
emojis: {
|
||||
with: {
|
||||
emoji: {
|
||||
with: {
|
||||
instance: true,
|
||||
media: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
author: {
|
||||
with: {
|
||||
...userRelations,
|
||||
},
|
||||
},
|
||||
mentions: {
|
||||
with: {
|
||||
user: {
|
||||
with: {
|
||||
instance: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
reblog: {
|
||||
with: {
|
||||
attachments: {
|
||||
with: {
|
||||
media: true,
|
||||
},
|
||||
},
|
||||
emojis: {
|
||||
with: {
|
||||
emoji: {
|
||||
with: {
|
||||
instance: true,
|
||||
media: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
likes: true,
|
||||
application: true,
|
||||
mentions: {
|
||||
with: {
|
||||
user: {
|
||||
with: userRelations,
|
||||
},
|
||||
},
|
||||
},
|
||||
author: {
|
||||
with: {
|
||||
...userRelations,
|
||||
},
|
||||
},
|
||||
},
|
||||
extras: {
|
||||
pinned: userId
|
||||
? sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."noteId" = "Notes_reblog".id AND "UserToPinnedNotes"."userId" = ${userId})`.as(
|
||||
"pinned",
|
||||
)
|
||||
: sql`false`.as("pinned"),
|
||||
reblogged: userId
|
||||
? sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."authorId" = ${userId} AND "Notes"."reblogId" = "Notes_reblog".id)`.as(
|
||||
"reblogged",
|
||||
)
|
||||
: sql`false`.as("reblogged"),
|
||||
muted: userId
|
||||
? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."ownerId" = ${userId} AND "Relationships"."subjectId" = "Notes_reblog"."authorId" AND "Relationships"."muting" = true)`.as(
|
||||
"muted",
|
||||
)
|
||||
: sql`false`.as("muted"),
|
||||
liked: userId
|
||||
? sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = "Notes_reblog".id AND "Likes"."likerId" = ${userId})`.as(
|
||||
"liked",
|
||||
)
|
||||
: sql`false`.as("liked"),
|
||||
},
|
||||
},
|
||||
reply: true,
|
||||
quote: true,
|
||||
},
|
||||
extras: {
|
||||
pinned: userId
|
||||
? sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."noteId" = "Notes".id AND "UserToPinnedNotes"."userId" = ${userId})`.as(
|
||||
"pinned",
|
||||
)
|
||||
: sql`false`.as("pinned"),
|
||||
reblogged: userId
|
||||
? sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."authorId" = ${userId} AND "Notes"."reblogId" = "Notes".id)`.as(
|
||||
"reblogged",
|
||||
)
|
||||
: sql`false`.as("reblogged"),
|
||||
muted: userId
|
||||
? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."ownerId" = ${userId} AND "Relationships"."subjectId" = "Notes"."authorId" AND "Relationships"."muting" = true)`.as(
|
||||
"muted",
|
||||
)
|
||||
: sql`false`.as("muted"),
|
||||
liked: userId
|
||||
? sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = "Notes".id AND "Likes"."likerId" = ${userId})`.as(
|
||||
"liked",
|
||||
)
|
||||
: sql`false`.as("liked"),
|
||||
...query?.extras,
|
||||
},
|
||||
});
|
||||
|
||||
return output.map((post) => ({
|
||||
...post,
|
||||
author: transformOutputToUserWithRelations(post.author),
|
||||
mentions: post.mentions.map((mention) => ({
|
||||
...mention.user,
|
||||
endpoints: mention.user.endpoints,
|
||||
})),
|
||||
attachments: post.attachments.map((attachment) => attachment.media),
|
||||
emojis: (post.emojis ?? []).map((emoji) => emoji.emoji),
|
||||
reblog: post.reblog && {
|
||||
...post.reblog,
|
||||
author: transformOutputToUserWithRelations(post.reblog.author),
|
||||
mentions: post.reblog.mentions.map((mention) => ({
|
||||
...mention.user,
|
||||
endpoints: mention.user.endpoints,
|
||||
})),
|
||||
attachments: post.reblog.attachments.map(
|
||||
(attachment) => attachment.media,
|
||||
),
|
||||
emojis: (post.reblog.emojis ?? []).map((emoji) => emoji.emoji),
|
||||
pinned: Boolean(post.reblog.pinned),
|
||||
reblogged: Boolean(post.reblog.reblogged),
|
||||
muted: Boolean(post.reblog.muted),
|
||||
liked: Boolean(post.reblog.liked),
|
||||
},
|
||||
pinned: Boolean(post.pinned),
|
||||
reblogged: Boolean(post.reblogged),
|
||||
muted: Boolean(post.muted),
|
||||
liked: Boolean(post.liked),
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Get people mentioned in the content (match @username or @username@domain.com mentions)
|
||||
* @param text The text to parse mentions from.
|
||||
* @returns An array of users mentioned in the text.
|
||||
*/
|
||||
export const parseTextMentions = async (text: string): Promise<User[]> => {
|
||||
const mentionedPeople = [...text.matchAll(mentionValidator)];
|
||||
if (mentionedPeople.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const baseUrlHost = config.http.base_url.host;
|
||||
const isLocal = (host?: string): boolean => host === baseUrlHost || !host;
|
||||
|
||||
// Find local and matching users
|
||||
const foundUsers = await db
|
||||
.select({
|
||||
id: Users.id,
|
||||
username: Users.username,
|
||||
baseUrl: Instances.baseUrl,
|
||||
})
|
||||
.from(Users)
|
||||
.leftJoin(Instances, eq(Users.instanceId, Instances.id))
|
||||
.where(
|
||||
or(
|
||||
...mentionedPeople.map((person) =>
|
||||
and(
|
||||
eq(Users.username, person[1] ?? ""),
|
||||
isLocal(person[2])
|
||||
? isNull(Users.instanceId)
|
||||
: eq(Instances.baseUrl, person[2] ?? ""),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Separate found and unresolved users
|
||||
const finalList = await User.manyFromSql(
|
||||
inArray(
|
||||
Users.id,
|
||||
foundUsers.map((u) => u.id),
|
||||
),
|
||||
);
|
||||
|
||||
// Every remote user that isn't in database
|
||||
const notFoundRemoteUsers = mentionedPeople.filter(
|
||||
(p) =>
|
||||
!(
|
||||
foundUsers.some(
|
||||
(user) => user.username === p[1] && user.baseUrl === p[2],
|
||||
) || isLocal(p[2])
|
||||
),
|
||||
);
|
||||
|
||||
// Resolve remote mentions not in database
|
||||
for (const person of notFoundRemoteUsers) {
|
||||
const url = await FederationRequester.resolveWebFinger(
|
||||
person[1] ?? "",
|
||||
person[2] ?? "",
|
||||
);
|
||||
|
||||
if (url) {
|
||||
const user = await User.resolve(url);
|
||||
|
||||
if (user) {
|
||||
finalList.push(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return finalList;
|
||||
};
|
||||
|
||||
export const replaceTextMentions = (text: string, mentions: User[]): string => {
|
||||
return mentions.reduce((finalText, mention) => {
|
||||
const { username, instance } = mention.data;
|
||||
const { uri } = mention;
|
||||
const baseHost = config.http.base_url.host;
|
||||
const linkTemplate = (displayText: string): string =>
|
||||
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${uri}">${displayText}</a>`;
|
||||
|
||||
if (mention.remote) {
|
||||
return finalText.replaceAll(
|
||||
`@${username}@${instance?.baseUrl}`,
|
||||
linkTemplate(`@${username}@${instance?.baseUrl}`),
|
||||
);
|
||||
}
|
||||
|
||||
return finalText.replace(
|
||||
createRegExp(
|
||||
exactly(
|
||||
exactly(`@${username}`)
|
||||
.notBefore(anyOf(letter, digit, charIn("@")))
|
||||
.notAfter(anyOf(letter, digit, charIn("@"))),
|
||||
).or(exactly(`@${username}@${baseHost}`)),
|
||||
[global],
|
||||
),
|
||||
linkTemplate(`@${username}@${baseHost}`),
|
||||
);
|
||||
}, text);
|
||||
};
|
||||
|
||||
export const contentToHtml = async (
|
||||
content: VersiaEntities.TextContentFormat,
|
||||
mentions: User[] = [],
|
||||
inline = false,
|
||||
): Promise<string> => {
|
||||
const sanitizer = inline ? sanitizeHtmlInline : sanitizeHtml;
|
||||
let htmlContent = "";
|
||||
|
||||
if (content.data["text/html"]) {
|
||||
htmlContent = await sanitizer(content.data["text/html"].content);
|
||||
} else if (content.data["text/markdown"]) {
|
||||
htmlContent = await sanitizer(
|
||||
await markdownParse(content.data["text/markdown"].content),
|
||||
);
|
||||
} else if (content.data["text/plain"]?.content) {
|
||||
htmlContent = (await sanitizer(content.data["text/plain"].content))
|
||||
.split("\n")
|
||||
.map((line) => `<p>${line}</p>`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
htmlContent = replaceTextMentions(htmlContent, mentions);
|
||||
|
||||
return linkifyHtml(htmlContent, {
|
||||
defaultProtocol: "https",
|
||||
validate: { email: (): false => false },
|
||||
target: "_blank",
|
||||
rel: "nofollow noopener noreferrer",
|
||||
});
|
||||
};
|
||||
|
||||
export const markdownParse = async (content: string): Promise<string> => {
|
||||
return (await getMarkdownRenderer()).render(content);
|
||||
};
|
||||
|
||||
export const getMarkdownRenderer = (): MarkdownIt => {
|
||||
const renderer = MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
});
|
||||
|
||||
renderer.use(markdownItTocDoneRight, {
|
||||
containerClass: "toc",
|
||||
level: [1, 2, 3, 4],
|
||||
listType: "ul",
|
||||
listClass: "toc-list",
|
||||
itemClass: "toc-item",
|
||||
linkClass: "toc-link",
|
||||
});
|
||||
|
||||
renderer.use(markdownItTaskLists);
|
||||
|
||||
renderer.use(markdownItContainer);
|
||||
|
||||
return renderer;
|
||||
};
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
import {
|
||||
type Application,
|
||||
db,
|
||||
type Emoji,
|
||||
type Instance,
|
||||
type Media,
|
||||
type Role,
|
||||
type Token,
|
||||
type User,
|
||||
} from "@versia/kit/db";
|
||||
import type { Users } from "@versia/kit/tables";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
|
||||
export const userRelations = {
|
||||
instance: true,
|
||||
emojis: {
|
||||
with: {
|
||||
emoji: {
|
||||
with: {
|
||||
instance: true,
|
||||
media: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
avatar: true,
|
||||
header: true,
|
||||
roles: {
|
||||
with: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export interface AuthData {
|
||||
user: User | null;
|
||||
token: Token | null;
|
||||
application: Application | null;
|
||||
}
|
||||
|
||||
export const transformOutputToUserWithRelations = (
|
||||
user: Omit<InferSelectModel<typeof Users>, "endpoints"> & {
|
||||
followerCount: unknown;
|
||||
followingCount: unknown;
|
||||
statusCount: unknown;
|
||||
avatar: typeof Media.$type | null;
|
||||
header: typeof Media.$type | null;
|
||||
emojis: {
|
||||
userId: string;
|
||||
emojiId: string;
|
||||
emoji?: typeof Emoji.$type;
|
||||
}[];
|
||||
instance: typeof Instance.$type | null;
|
||||
roles: {
|
||||
userId: string;
|
||||
roleId: string;
|
||||
role?: typeof Role.$type;
|
||||
}[];
|
||||
endpoints: unknown;
|
||||
},
|
||||
): typeof User.$type => {
|
||||
return {
|
||||
...user,
|
||||
followerCount: Number(user.followerCount),
|
||||
followingCount: Number(user.followingCount),
|
||||
statusCount: Number(user.statusCount),
|
||||
endpoints:
|
||||
user.endpoints ??
|
||||
({} as Partial<{
|
||||
dislikes: string;
|
||||
featured: string;
|
||||
likes: string;
|
||||
followers: string;
|
||||
following: string;
|
||||
inbox: string;
|
||||
outbox: string;
|
||||
}>),
|
||||
emojis: user.emojis.map(
|
||||
(emoji) =>
|
||||
(emoji as unknown as Record<string, object>)
|
||||
.emoji as typeof Emoji.$type,
|
||||
),
|
||||
roles: user.roles
|
||||
.map((role) => role.role)
|
||||
.filter(Boolean) as (typeof Role.$type)[],
|
||||
};
|
||||
};
|
||||
|
||||
export const findManyUsers = async (
|
||||
query: Parameters<typeof db.query.Users.findMany>[0],
|
||||
): Promise<(typeof User.$type)[]> => {
|
||||
const output = await db.query.Users.findMany({
|
||||
...query,
|
||||
with: {
|
||||
...userRelations,
|
||||
...query?.with,
|
||||
},
|
||||
});
|
||||
|
||||
return output.map((user) => transformOutputToUserWithRelations(user));
|
||||
};
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from "bun:test";
|
||||
import { mockModule } from "@versia-server/tests";
|
||||
import sharp from "sharp";
|
||||
import { mockModule } from "~/tests/utils.ts";
|
||||
import { calculateBlurhash } from "./blurhash.ts";
|
||||
|
||||
describe("BlurhashPreprocessor", () => {
|
||||
|
|
@ -16,7 +16,7 @@ describe("BlurhashPreprocessor", () => {
|
|||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const inputFile = new File([inputBuffer], "test.png", {
|
||||
const inputFile = new File([inputBuffer as BlobPart], "test.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
const result = await calculateBlurhash(inputFile);
|
||||
|
|
@ -46,7 +46,7 @@ describe("BlurhashPreprocessor", () => {
|
|||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const inputFile = new File([inputBuffer], "test.png", {
|
||||
const inputFile = new File([inputBuffer as BlobPart], "test.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ describe("ImageConversionPreprocessor", () => {
|
|||
.jpeg()
|
||||
.toBuffer();
|
||||
|
||||
const inputFile = new File([inputBuffer], "test.jpg", {
|
||||
const inputFile = new File([inputBuffer as BlobPart], "test.jpg", {
|
||||
type: "image/jpeg",
|
||||
});
|
||||
const result = await convertImage(inputFile, "image/webp");
|
||||
|
|
@ -74,7 +74,7 @@ describe("ImageConversionPreprocessor", () => {
|
|||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const inputFile = new File([inputBuffer], "test.png", {
|
||||
const inputFile = new File([inputBuffer as BlobPart], "test.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
|
||||
|
|
@ -96,7 +96,7 @@ describe("ImageConversionPreprocessor", () => {
|
|||
.gif()
|
||||
.toBuffer();
|
||||
|
||||
const inputFile = new File([inputBuffer], "animated.gif", {
|
||||
const inputFile = new File([inputBuffer as BlobPart], "animated.gif", {
|
||||
type: "image/gif",
|
||||
});
|
||||
const result = await convertImage(inputFile, "image/webp");
|
||||
|
|
@ -122,7 +122,7 @@ describe("ImageConversionPreprocessor", () => {
|
|||
.toBuffer();
|
||||
|
||||
const inputFile = new File(
|
||||
[inputBuffer],
|
||||
[inputBuffer as BlobPart],
|
||||
"test image with spaces.png",
|
||||
{ type: "image/png" },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ export const convertImage = async (
|
|||
const convertedBuffer = await sharpCommand[commandName]().toBuffer();
|
||||
|
||||
return new File(
|
||||
[convertedBuffer],
|
||||
[convertedBuffer as BlobPart],
|
||||
getReplacedFileName(file.name, commandName),
|
||||
{
|
||||
type: targetFormat,
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
import { config } from "~/config.ts";
|
||||
|
||||
export class ProxiableUrl extends URL {
|
||||
private isAllowedOrigin(): boolean {
|
||||
const allowedOrigins: URL[] = [config.http.base_url].concat(
|
||||
config.s3?.public_url ?? [],
|
||||
);
|
||||
|
||||
return allowedOrigins.some((origin) =>
|
||||
this.hostname.endsWith(origin.hostname),
|
||||
);
|
||||
}
|
||||
|
||||
public get proxied(): string {
|
||||
// Don't proxy from CDN and self, since those sources are trusted
|
||||
if (this.isAllowedOrigin()) {
|
||||
return this.href;
|
||||
}
|
||||
|
||||
const urlAsBase64Url = Buffer.from(this.href).toString("base64url");
|
||||
|
||||
return new URL(`/media/proxy/${urlAsBase64Url}`, config.http.base_url)
|
||||
.href;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,221 +0,0 @@
|
|||
/* import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
jest,
|
||||
mock,
|
||||
test,
|
||||
} from "bun:test";
|
||||
import { ZodError, type ZodTypeAny, z } from "zod";
|
||||
import { Plugin } from "~/packages/plugin-kit";
|
||||
import { type Manifest, manifestSchema } from "~/packages/plugin-kit/schema";
|
||||
import { PluginLoader } from "./loader.ts";
|
||||
|
||||
const mockReaddir = jest.fn();
|
||||
const mockGetLogger = jest.fn(() => ({
|
||||
fatal: jest.fn(),
|
||||
}));
|
||||
const mockParseJSON5 = jest.fn();
|
||||
const mockParseJSONC = jest.fn();
|
||||
const mockFromZodError = jest.fn();
|
||||
|
||||
mock.module("node:fs/promises", () => ({
|
||||
readdir: mockReaddir,
|
||||
}));
|
||||
|
||||
mock.module("@logtape/logtape", () => ({
|
||||
getLogger: mockGetLogger,
|
||||
}));
|
||||
|
||||
mock.module("confbox", () => ({
|
||||
parseJSON5: mockParseJSON5,
|
||||
parseJSONC: mockParseJSONC,
|
||||
}));
|
||||
|
||||
mock.module("zod-validation-error", () => ({
|
||||
fromZodError: mockFromZodError,
|
||||
}));
|
||||
|
||||
describe("PluginLoader", () => {
|
||||
let pluginLoader: PluginLoader;
|
||||
|
||||
beforeEach(() => {
|
||||
pluginLoader = new PluginLoader();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test("getDirectories should return directories", async () => {
|
||||
mockReaddir.mockResolvedValue([
|
||||
{ name: "dir1", isDirectory: (): true => true },
|
||||
{ name: "file1", isDirectory: (): false => false },
|
||||
{ name: "dir2", isDirectory: (): true => true },
|
||||
]);
|
||||
|
||||
// biome-ignore lint/complexity/useLiteralKeys: Private method
|
||||
const directories = await PluginLoader["getDirectories"]("/some/path");
|
||||
expect(directories).toEqual(["dir1", "dir2"]);
|
||||
});
|
||||
|
||||
test("findManifestFile should return manifest file if found", async () => {
|
||||
mockReaddir.mockResolvedValue(["manifest.json", "otherfile.txt"]);
|
||||
|
||||
const manifestFile =
|
||||
// biome-ignore lint/complexity/useLiteralKeys: Private method
|
||||
await PluginLoader["findManifestFile"]("/some/path");
|
||||
expect(manifestFile).toBe("manifest.json");
|
||||
});
|
||||
|
||||
test("hasEntrypoint should return true if entrypoint file is found", async () => {
|
||||
mockReaddir.mockResolvedValue(["index.ts", "otherfile.txt"]);
|
||||
|
||||
// biome-ignore lint/complexity/useLiteralKeys: Private method
|
||||
const hasEntrypoint = await PluginLoader["hasEntrypoint"]("/some/path");
|
||||
expect(hasEntrypoint).toBe(true);
|
||||
});
|
||||
|
||||
test("parseManifestFile should parse JSON manifest", async () => {
|
||||
const manifestContent = { name: "test-plugin" };
|
||||
Bun.file = jest.fn().mockReturnValue({
|
||||
text: (): Promise<string> =>
|
||||
Promise.resolve(JSON.stringify(manifestContent)),
|
||||
});
|
||||
|
||||
// biome-ignore lint/complexity/useLiteralKeys: Private method
|
||||
const manifest = await pluginLoader["parseManifestFile"](
|
||||
"/some/path/manifest.json",
|
||||
"manifest.json",
|
||||
);
|
||||
expect(manifest).toEqual(manifestContent);
|
||||
});
|
||||
|
||||
test("findPlugins should return plugin directories with valid manifest and entrypoint", async () => {
|
||||
mockReaddir
|
||||
.mockResolvedValueOnce([
|
||||
{ name: "plugin1", isDirectory: (): true => true },
|
||||
{ name: "plugin2", isDirectory: (): true => true },
|
||||
])
|
||||
.mockResolvedValue(["manifest.json", "index.ts"]);
|
||||
|
||||
const plugins = await PluginLoader.findPlugins("/some/path");
|
||||
expect(plugins).toEqual(["plugin1", "plugin2"]);
|
||||
});
|
||||
|
||||
test("parseManifest should parse and validate manifest", async () => {
|
||||
const manifestContent: Manifest = {
|
||||
name: "test-plugin",
|
||||
version: "1.1.0",
|
||||
description: "Doobaee",
|
||||
};
|
||||
mockReaddir.mockResolvedValue(["manifest.json"]);
|
||||
Bun.file = jest.fn().mockReturnValue({
|
||||
text: (): Promise<string> =>
|
||||
Promise.resolve(JSON.stringify(manifestContent)),
|
||||
});
|
||||
manifestSchema.safeParseAsync = jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: manifestContent,
|
||||
});
|
||||
|
||||
const manifest = await pluginLoader.parseManifest(
|
||||
"/some/path",
|
||||
"plugin1",
|
||||
);
|
||||
expect(manifest).toEqual(manifestContent);
|
||||
});
|
||||
|
||||
test("parseManifest should throw error if manifest is missing", async () => {
|
||||
mockReaddir.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
pluginLoader.parseManifest("/some/path", "plugin1"),
|
||||
).rejects.toThrow("Plugin plugin1 is missing a manifest file");
|
||||
});
|
||||
|
||||
test("parseManifest should throw error if manifest is invalid", async () => {
|
||||
// @ts-expect-error trying to cause a type error here
|
||||
const manifestContent: Manifest = {
|
||||
name: "test-plugin",
|
||||
version: "1.1.0",
|
||||
};
|
||||
mockReaddir.mockResolvedValue(["manifest.json"]);
|
||||
Bun.file = jest.fn().mockReturnValue({
|
||||
text: (): Promise<string> =>
|
||||
Promise.resolve(JSON.stringify(manifestContent)),
|
||||
});
|
||||
manifestSchema.safeParseAsync = jest.fn().mockResolvedValue({
|
||||
success: false,
|
||||
error: new ZodError([]),
|
||||
});
|
||||
|
||||
await expect(
|
||||
pluginLoader.parseManifest("/some/path", "plugin1"),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("loadPlugin should load and return a Plugin instance", async () => {
|
||||
const mockPlugin = new Plugin(z.object({}));
|
||||
mock.module("/some/path/index.ts", () => ({
|
||||
default: mockPlugin,
|
||||
}));
|
||||
|
||||
const plugin = await pluginLoader.loadPlugin("/some/path", "index.ts");
|
||||
expect(plugin).toBeInstanceOf(Plugin);
|
||||
});
|
||||
|
||||
test("loadPlugin should throw error if default export is not a Plugin", async () => {
|
||||
mock.module("/some/path/index.ts", () => ({
|
||||
default: "cheese",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
pluginLoader.loadPlugin("/some/path", "index.ts"),
|
||||
).rejects.toThrow("Entrypoint is not a Plugin");
|
||||
});
|
||||
|
||||
test("loadPlugins should load all plugins in a directory", async () => {
|
||||
const manifestContent: Manifest = {
|
||||
name: "test-plugin",
|
||||
version: "1.1.0",
|
||||
description: "Doobaee",
|
||||
};
|
||||
const mockPlugin = new Plugin(z.object({}));
|
||||
|
||||
mockReaddir
|
||||
.mockResolvedValueOnce([
|
||||
{ name: "plugin1", isDirectory: (): true => true },
|
||||
{ name: "plugin2", isDirectory: (): true => true },
|
||||
])
|
||||
.mockResolvedValue(["manifest.json", "index.ts"]);
|
||||
Bun.file = jest.fn().mockReturnValue({
|
||||
text: (): Promise<string> =>
|
||||
Promise.resolve(JSON.stringify(manifestContent)),
|
||||
});
|
||||
manifestSchema.safeParseAsync = jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: manifestContent,
|
||||
});
|
||||
mock.module("/some/path/plugin1/index", () => ({
|
||||
default: mockPlugin,
|
||||
}));
|
||||
mock.module("/some/path/plugin2/index", () => ({
|
||||
default: mockPlugin,
|
||||
}));
|
||||
|
||||
const plugins = await pluginLoader.loadPlugins("/some/path", true);
|
||||
expect(plugins).toEqual([
|
||||
{
|
||||
manifest: manifestContent,
|
||||
plugin: mockPlugin as unknown as Plugin<ZodTypeAny>,
|
||||
},
|
||||
{
|
||||
manifest: manifestContent,
|
||||
plugin: mockPlugin as unknown as Plugin<ZodTypeAny>,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
*/
|
||||
|
|
@ -1,261 +0,0 @@
|
|||
import { readdir } from "node:fs/promises";
|
||||
import { getLogger, type Logger } from "@logtape/logtape";
|
||||
import { file, sleep } from "bun";
|
||||
import chalk from "chalk";
|
||||
import { parseJSON5, parseJSONC } from "confbox";
|
||||
import type { Hono } from "hono";
|
||||
import type { ZodTypeAny } from "zod";
|
||||
import { fromZodError, type ValidationError } from "zod-validation-error";
|
||||
import { config } from "~/config.ts";
|
||||
import { Plugin } from "~/packages/plugin-kit/plugin";
|
||||
import { type Manifest, manifestSchema } from "~/packages/plugin-kit/schema";
|
||||
import type { HonoEnv } from "~/types/api";
|
||||
|
||||
/**
|
||||
* Class to manage plugins.
|
||||
*/
|
||||
export class PluginLoader {
|
||||
private logger = getLogger("plugin");
|
||||
|
||||
/**
|
||||
* Get all directories in a given directory.
|
||||
* @param {string} dir - The directory to search.
|
||||
* @returns {Promise<string[]>} - An array of directory names.
|
||||
*/
|
||||
private static async getDirectories(dir: string): Promise<string[]> {
|
||||
const files = await readdir(dir, { withFileTypes: true });
|
||||
return files.filter((f) => f.isDirectory()).map((f) => f.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the manifest file in a given directory.
|
||||
* @param {string} dir - The directory to search.
|
||||
* @returns {Promise<string | undefined>} - The manifest file name if found, otherwise undefined.
|
||||
*/
|
||||
private static async findManifestFile(
|
||||
dir: string,
|
||||
): Promise<string | undefined> {
|
||||
const files = await readdir(dir);
|
||||
return files.find((f) => f.match(/^manifest\.(json|json5|jsonc)$/));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a directory has an entrypoint file (index.{ts,js}).
|
||||
* @param {string} dir - The directory to search.
|
||||
* @returns {Promise<boolean>} - True if the entrypoint file is found, otherwise false.
|
||||
*/
|
||||
private static async hasEntrypoint(dir: string): Promise<boolean> {
|
||||
const files = await readdir(dir);
|
||||
return files.includes("index.ts") || files.includes("index.js");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the manifest file based on its type.
|
||||
* @param {string} manifestPath - The path to the manifest file.
|
||||
* @param {string} manifestFile - The manifest file name.
|
||||
* @returns {Promise<unknown>} - The parsed manifest content.
|
||||
* @throws Will throw an error if the manifest file cannot be parsed.
|
||||
*/
|
||||
private async parseManifestFile(
|
||||
manifestPath: string,
|
||||
manifestFile: string,
|
||||
): Promise<unknown> {
|
||||
const manifestText = await file(manifestPath).text();
|
||||
|
||||
try {
|
||||
if (manifestFile.endsWith(".json")) {
|
||||
return JSON.parse(manifestText);
|
||||
}
|
||||
if (manifestFile.endsWith(".json5")) {
|
||||
return parseJSON5(manifestText);
|
||||
}
|
||||
if (manifestFile.endsWith(".jsonc")) {
|
||||
return parseJSONC(manifestText);
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported manifest file type: ${manifestFile}`);
|
||||
} catch (e) {
|
||||
this.logger
|
||||
.fatal`Could not parse plugin manifest ${chalk.blue(manifestPath)} as ${manifestFile.split(".").pop()?.toUpperCase()}.`;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all direct subdirectories with a valid manifest file and entrypoint (index.{ts,js}).
|
||||
* @param {string} dir - The directory to search.
|
||||
* @returns {Promise<string[]>} - An array of plugin directories.
|
||||
*/
|
||||
public static async findPlugins(dir: string): Promise<string[]> {
|
||||
const directories = await PluginLoader.getDirectories(dir);
|
||||
const plugins: string[] = [];
|
||||
|
||||
for (const directory of directories) {
|
||||
const manifestFile = await PluginLoader.findManifestFile(
|
||||
`${dir}/${directory}`,
|
||||
);
|
||||
if (
|
||||
manifestFile &&
|
||||
(await PluginLoader.hasEntrypoint(`${dir}/${directory}`))
|
||||
) {
|
||||
plugins.push(directory);
|
||||
}
|
||||
}
|
||||
|
||||
return plugins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the manifest file of a plugin.
|
||||
* @param {string} dir - The directory containing the plugin.
|
||||
* @param {string} plugin - The plugin directory name.
|
||||
* @returns {Promise<Manifest>} - The parsed manifest object.
|
||||
* @throws Will throw an error if the manifest file is missing or invalid.
|
||||
*/
|
||||
public async parseManifest(dir: string, plugin: string): Promise<Manifest> {
|
||||
const manifestFile = await PluginLoader.findManifestFile(
|
||||
`${dir}/${plugin}`,
|
||||
);
|
||||
|
||||
if (!manifestFile) {
|
||||
throw new Error(`Plugin ${plugin} is missing a manifest file`);
|
||||
}
|
||||
|
||||
const manifestPath = `${dir}/${plugin}/${manifestFile}`;
|
||||
const manifest = await this.parseManifestFile(
|
||||
manifestPath,
|
||||
manifestFile,
|
||||
);
|
||||
|
||||
const result = await manifestSchema.safeParseAsync(manifest);
|
||||
|
||||
if (!result.success) {
|
||||
this.logger
|
||||
.fatal`Plugin manifest ${chalk.blue(manifestPath)} is invalid.`;
|
||||
throw fromZodError(result.error);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an entrypoint's default export and check if it's a Plugin.
|
||||
* @param {string} dir - The directory containing the entrypoint.
|
||||
* @param {string} entrypoint - The entrypoint file name.
|
||||
* @returns {Promise<Plugin<ZodTypeAny>>} - The loaded Plugin instance.
|
||||
* @throws Will throw an error if the entrypoint's default export is not a Plugin.
|
||||
*/
|
||||
public async loadPlugin(
|
||||
dir: string,
|
||||
entrypoint: string,
|
||||
): Promise<Plugin<ZodTypeAny>> {
|
||||
const plugin = (await import(`${dir}/${entrypoint}`)).default;
|
||||
|
||||
if (plugin instanceof Plugin) {
|
||||
return plugin;
|
||||
}
|
||||
|
||||
this.logger
|
||||
.fatal`Default export of entrypoint ${chalk.blue(entrypoint)} at ${chalk.blue(dir)} is not a Plugin.`;
|
||||
|
||||
throw new Error("Entrypoint is not a Plugin");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all plugins in a given directory.
|
||||
* @param {string} dir - The directory to search.
|
||||
* @returns An array of objects containing the manifest and plugin instance.
|
||||
*/
|
||||
public async loadPlugins(
|
||||
dir: string,
|
||||
autoload: boolean,
|
||||
enabled?: string[],
|
||||
disabled?: string[],
|
||||
): Promise<{ manifest: Manifest; plugin: Plugin<ZodTypeAny> }[]> {
|
||||
const plugins = await PluginLoader.findPlugins(dir);
|
||||
|
||||
const enabledOn = (enabled?.length ?? 0) > 0;
|
||||
const disabledOn = (disabled?.length ?? 0) > 0;
|
||||
|
||||
if (enabledOn && disabledOn) {
|
||||
this.logger
|
||||
.fatal`Both enabled and disabled lists are specified. Only one of them can be used.`;
|
||||
throw new Error("Invalid configuration");
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
plugins.map(async (plugin) => {
|
||||
const manifest = await this.parseManifest(dir, plugin);
|
||||
|
||||
// If autoload is disabled, only load plugins explicitly enabled
|
||||
if (
|
||||
!(autoload || enabledOn || enabled?.includes(manifest.name))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If enabled is specified, only load plugins in the enabled list
|
||||
// If disabled is specified, only load plugins not in the disabled list
|
||||
if (enabledOn && !enabled?.includes(manifest.name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (disabled?.includes(manifest.name)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pluginInstance = await this.loadPlugin(
|
||||
dir,
|
||||
`${plugin}/index`,
|
||||
);
|
||||
|
||||
return { manifest, plugin: pluginInstance };
|
||||
}),
|
||||
).then((data) => data.filter((d) => d !== null));
|
||||
}
|
||||
|
||||
public static async addToApp(
|
||||
plugins: {
|
||||
manifest: Manifest;
|
||||
plugin: Plugin<ZodTypeAny>;
|
||||
}[],
|
||||
app: Hono<HonoEnv>,
|
||||
logger: Logger,
|
||||
): Promise<void> {
|
||||
for (const data of plugins) {
|
||||
logger.info`Loading plugin ${chalk.blueBright(data.manifest.name)} ${chalk.blueBright(data.manifest.version)} ${chalk.gray(`[${plugins.indexOf(data) + 1}/${plugins.length}]`)}`;
|
||||
|
||||
const time1 = performance.now();
|
||||
|
||||
try {
|
||||
// biome-ignore lint/complexity/useLiteralKeys: loadConfig is a private method
|
||||
await data.plugin["_loadConfig"](
|
||||
config.plugins?.config?.[data.manifest.name],
|
||||
);
|
||||
} catch (e) {
|
||||
logger.fatal`Error encountered while loading plugin ${chalk.blueBright(data.manifest.name)} ${chalk.blueBright(data.manifest.version)} configuration.`;
|
||||
logger.fatal`This is due to invalid, missing or incomplete configuration.`;
|
||||
logger.fatal`Put your configuration at ${chalk.blueBright(
|
||||
"plugins.config.<plugin-name>",
|
||||
)}`;
|
||||
logger.fatal`Here is the error message, please fix the configuration file accordingly:`;
|
||||
logger.fatal`${(e as ValidationError).message}`;
|
||||
|
||||
await sleep(Number.POSITIVE_INFINITY);
|
||||
}
|
||||
|
||||
const time2 = performance.now();
|
||||
|
||||
// biome-ignore lint/complexity/useLiteralKeys: AddToApp is a private method
|
||||
await data.plugin["_addToApp"](app);
|
||||
|
||||
const time3 = performance.now();
|
||||
|
||||
logger.info`Plugin ${chalk.blueBright(data.manifest.name)} ${chalk.blueBright(
|
||||
data.manifest.version,
|
||||
)} loaded in ${chalk.gray(
|
||||
`${(time2 - time1).toFixed(2)}ms`,
|
||||
)} and added to app in ${chalk.gray(`${(time3 - time2).toFixed(2)}ms`)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,317 +0,0 @@
|
|||
/**
|
||||
* @file search-manager.ts
|
||||
* @description Sonic search integration for indexing and searching accounts and statuses
|
||||
*/
|
||||
|
||||
import { getLogger } from "@logtape/logtape";
|
||||
import { db, Note, User } from "@versia/kit/db";
|
||||
import type { SQL, ValueOrArray } from "drizzle-orm";
|
||||
import {
|
||||
Ingest as SonicChannelIngest,
|
||||
Search as SonicChannelSearch,
|
||||
} from "sonic-channel";
|
||||
import { config } from "~/config.ts";
|
||||
|
||||
/**
|
||||
* Enum for Sonic index types
|
||||
*/
|
||||
export enum SonicIndexType {
|
||||
Accounts = "accounts",
|
||||
Statuses = "statuses",
|
||||
}
|
||||
|
||||
/**
|
||||
* Class for managing Sonic search operations
|
||||
*/
|
||||
export class SonicSearchManager {
|
||||
private searchChannel: SonicChannelSearch;
|
||||
private ingestChannel: SonicChannelIngest;
|
||||
private connected = false;
|
||||
private logger = getLogger("sonic");
|
||||
|
||||
/**
|
||||
* @param config Configuration for Sonic
|
||||
*/
|
||||
public constructor() {
|
||||
if (!config.search.sonic) {
|
||||
throw new Error("Sonic configuration is missing");
|
||||
}
|
||||
|
||||
this.searchChannel = new SonicChannelSearch({
|
||||
host: config.search.sonic.host,
|
||||
port: config.search.sonic.port,
|
||||
auth: config.search.sonic.password,
|
||||
});
|
||||
|
||||
this.ingestChannel = new SonicChannelIngest({
|
||||
host: config.search.sonic.host,
|
||||
port: config.search.sonic.port,
|
||||
auth: config.search.sonic.password,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to Sonic
|
||||
*/
|
||||
public async connect(silent = false): Promise<void> {
|
||||
if (!config.search.enabled) {
|
||||
!silent && this.logger.info`Sonic search is disabled`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
!silent && this.logger.info`Connecting to Sonic...`;
|
||||
|
||||
// Connect to Sonic
|
||||
await new Promise<boolean>((resolve, reject) => {
|
||||
this.searchChannel.connect({
|
||||
connected: (): void => {
|
||||
!silent &&
|
||||
this.logger.info`Connected to Sonic Search Channel`;
|
||||
resolve(true);
|
||||
},
|
||||
disconnected: (): void =>
|
||||
this.logger
|
||||
.error`Disconnected from Sonic Search Channel. You might be using an incorrect password.`,
|
||||
timeout: (): void =>
|
||||
this.logger
|
||||
.error`Sonic Search Channel connection timed out`,
|
||||
retrying: (): void =>
|
||||
this.logger
|
||||
.warn`Retrying connection to Sonic Search Channel`,
|
||||
error: (error): void => {
|
||||
this.logger
|
||||
.error`Failed to connect to Sonic Search Channel: ${error}`;
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<boolean>((resolve, reject) => {
|
||||
this.ingestChannel.connect({
|
||||
connected: (): void => {
|
||||
!silent &&
|
||||
this.logger.info`Connected to Sonic Ingest Channel`;
|
||||
resolve(true);
|
||||
},
|
||||
disconnected: (): void =>
|
||||
this.logger.error`Disconnected from Sonic Ingest Channel`,
|
||||
timeout: (): void =>
|
||||
this.logger
|
||||
.error`Sonic Ingest Channel connection timed out`,
|
||||
retrying: (): void =>
|
||||
this.logger
|
||||
.warn`Retrying connection to Sonic Ingest Channel`,
|
||||
error: (error): void => {
|
||||
this.logger
|
||||
.error`Failed to connect to Sonic Ingest Channel: ${error}`;
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
this.searchChannel.ping(),
|
||||
this.ingestChannel.ping(),
|
||||
]);
|
||||
this.connected = true;
|
||||
!silent && this.logger.info`Connected to Sonic`;
|
||||
} catch (error) {
|
||||
this.logger.fatal`Error while connecting to Sonic: ${error}`;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a user to Sonic
|
||||
* @param user User to add
|
||||
*/
|
||||
public async addUser(user: User): Promise<void> {
|
||||
if (!config.search.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.ingestChannel.push(
|
||||
SonicIndexType.Accounts,
|
||||
"users",
|
||||
user.id,
|
||||
`${user.data.username} ${user.data.displayName} ${user.data.note}`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error`Failed to add user to Sonic: ${error}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a batch of accounts from the database
|
||||
* @param n Batch number
|
||||
* @param batchSize Size of the batch
|
||||
*/
|
||||
private static getNthDatabaseAccountBatch(
|
||||
n: number,
|
||||
batchSize = 1000,
|
||||
): Promise<Record<string, string | null | Date>[]> {
|
||||
return db.query.Users.findMany({
|
||||
offset: n * batchSize,
|
||||
limit: batchSize,
|
||||
columns: {
|
||||
id: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
note: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: (user, { asc }): ValueOrArray<SQL> => asc(user.createdAt),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a batch of statuses from the database
|
||||
* @param n Batch number
|
||||
* @param batchSize Size of the batch
|
||||
*/
|
||||
private static getNthDatabaseStatusBatch(
|
||||
n: number,
|
||||
batchSize = 1000,
|
||||
): Promise<Record<string, string | Date>[]> {
|
||||
return db.query.Notes.findMany({
|
||||
offset: n * batchSize,
|
||||
limit: batchSize,
|
||||
columns: {
|
||||
id: true,
|
||||
content: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: (status, { asc }): ValueOrArray<SQL> =>
|
||||
asc(status.createdAt),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild search indexes
|
||||
* @param indexes Indexes to rebuild
|
||||
* @param batchSize Size of each batch
|
||||
* @param progressCallback Callback for progress updates
|
||||
*/
|
||||
public async rebuildSearchIndexes(
|
||||
indexes: SonicIndexType[],
|
||||
batchSize = 100,
|
||||
progressCallback?: (progress: number) => void,
|
||||
): Promise<void> {
|
||||
for (const index of indexes) {
|
||||
if (index === SonicIndexType.Accounts) {
|
||||
await this.rebuildAccountsIndex(batchSize, progressCallback);
|
||||
} else if (index === SonicIndexType.Statuses) {
|
||||
await this.rebuildStatusesIndex(batchSize, progressCallback);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild accounts index
|
||||
* @param batchSize Size of each batch
|
||||
* @param progressCallback Callback for progress updates
|
||||
*/
|
||||
private async rebuildAccountsIndex(
|
||||
batchSize: number,
|
||||
progressCallback?: (progress: number) => void,
|
||||
): Promise<void> {
|
||||
const accountCount = await User.getCount();
|
||||
const batchCount = Math.ceil(accountCount / batchSize);
|
||||
|
||||
for (let i = 0; i < batchCount; i++) {
|
||||
const accounts =
|
||||
await SonicSearchManager.getNthDatabaseAccountBatch(
|
||||
i,
|
||||
batchSize,
|
||||
);
|
||||
await Promise.all(
|
||||
accounts.map((account) =>
|
||||
this.ingestChannel.push(
|
||||
SonicIndexType.Accounts,
|
||||
"users",
|
||||
account.id as string,
|
||||
`${account.username} ${account.displayName} ${account.note}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
progressCallback?.((i + 1) / batchCount);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild statuses index
|
||||
* @param batchSize Size of each batch
|
||||
* @param progressCallback Callback for progress updates
|
||||
*/
|
||||
private async rebuildStatusesIndex(
|
||||
batchSize: number,
|
||||
progressCallback?: (progress: number) => void,
|
||||
): Promise<void> {
|
||||
const statusCount = await Note.getCount();
|
||||
const batchCount = Math.ceil(statusCount / batchSize);
|
||||
|
||||
for (let i = 0; i < batchCount; i++) {
|
||||
const statuses = await SonicSearchManager.getNthDatabaseStatusBatch(
|
||||
i,
|
||||
batchSize,
|
||||
);
|
||||
await Promise.all(
|
||||
statuses.map((status) =>
|
||||
this.ingestChannel.push(
|
||||
SonicIndexType.Statuses,
|
||||
"notes",
|
||||
status.id as string,
|
||||
status.content as string,
|
||||
),
|
||||
),
|
||||
);
|
||||
progressCallback?.((i + 1) / batchCount);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for accounts
|
||||
* @param query Search query
|
||||
* @param limit Maximum number of results
|
||||
* @param offset Offset for pagination
|
||||
*/
|
||||
public searchAccounts(
|
||||
query: string,
|
||||
limit = 10,
|
||||
offset = 0,
|
||||
): Promise<string[]> {
|
||||
return this.searchChannel.query(
|
||||
SonicIndexType.Accounts,
|
||||
"users",
|
||||
query,
|
||||
{ limit, offset },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for statuses
|
||||
* @param query Search query
|
||||
* @param limit Maximum number of results
|
||||
* @param offset Offset for pagination
|
||||
*/
|
||||
public searchStatuses(
|
||||
query: string,
|
||||
limit = 10,
|
||||
offset = 0,
|
||||
): Promise<string[]> {
|
||||
return this.searchChannel.query(
|
||||
SonicIndexType.Statuses,
|
||||
"notes",
|
||||
query,
|
||||
{ limit, offset },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const searchManager = new SonicSearchManager();
|
||||
|
|
@ -3,10 +3,10 @@ import { friendlyErrorPlugin } from "@clerc/plugin-friendly-error";
|
|||
import { helpPlugin } from "@clerc/plugin-help";
|
||||
import { notFoundPlugin } from "@clerc/plugin-not-found";
|
||||
import { versionPlugin } from "@clerc/plugin-version";
|
||||
import { setupDatabase } from "@versia-server/kit/db";
|
||||
import { searchManager } from "@versia-server/kit/search";
|
||||
import { Clerc } from "clerc";
|
||||
import { searchManager } from "~/classes/search/search-manager.ts";
|
||||
import { setupDatabase } from "~/drizzle/db.ts";
|
||||
import pkg from "~/package.json";
|
||||
import pkg from "../package.json" with { type: "json" };
|
||||
import { rebuildIndexCommand } from "./index/rebuild.ts";
|
||||
import { refetchInstanceCommand } from "./instance/refetch.ts";
|
||||
import { createUserCommand } from "./user/create.ts";
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
import { config } from "@versia-server/config";
|
||||
import { SonicIndexType, searchManager } from "@versia-server/kit/search";
|
||||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||
import { defineCommand, type Root } from "clerc";
|
||||
import ora from "ora";
|
||||
import {
|
||||
SonicIndexType,
|
||||
searchManager,
|
||||
} from "~/classes/search/search-manager.ts";
|
||||
import { config } from "~/config.ts";
|
||||
|
||||
export const rebuildIndexCommand = defineCommand(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { Instance } from "@versia-server/kit/db";
|
||||
import { FetchJobType, fetchQueue } from "@versia-server/kit/queues/fetch";
|
||||
import { Instances } from "@versia-server/kit/tables";
|
||||
import chalk from "chalk";
|
||||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||
import { defineCommand, type Root } from "clerc";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Instance } from "~/classes/database/instance.ts";
|
||||
import { FetchJobType, fetchQueue } from "~/classes/queues/fetch.ts";
|
||||
import { Instances } from "~/drizzle/schema.ts";
|
||||
|
||||
export const refetchInstanceCommand = defineCommand(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { config } from "@versia-server/config";
|
||||
import { User } from "@versia-server/kit/db";
|
||||
import { searchManager } from "@versia-server/kit/search";
|
||||
import { Users } from "@versia-server/kit/tables";
|
||||
import chalk from "chalk";
|
||||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||
import { defineCommand, type Root } from "clerc";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { renderUnicodeCompact } from "uqr";
|
||||
import { User } from "~/classes/database/user";
|
||||
import { config } from "~/config";
|
||||
import { Users } from "~/drizzle/schema";
|
||||
|
||||
export const createUserCommand = defineCommand(
|
||||
{
|
||||
|
|
@ -54,6 +55,9 @@ export const createUserCommand = defineCommand(
|
|||
isAdmin: admin,
|
||||
});
|
||||
|
||||
// Add to search index
|
||||
await searchManager.addUser(user);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("Failed to create user.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { User } from "@versia-server/kit/db";
|
||||
import chalk from "chalk";
|
||||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||
import { defineCommand, type Root } from "clerc";
|
||||
import ora from "ora";
|
||||
import { User } from "~/classes/database/user.ts";
|
||||
import { retrieveUser } from "../utils.ts";
|
||||
|
||||
export const refetchUserCommand = defineCommand(
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { Client, 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(
|
||||
|
|
@ -22,13 +22,24 @@ export const generateTokenCommand = defineCommand(
|
|||
throw new Error(`User ${chalk.gray(username)} not found.`);
|
||||
}
|
||||
|
||||
const application = await Client.insert({
|
||||
id:
|
||||
user.id +
|
||||
Buffer.from(
|
||||
crypto.getRandomValues(new Uint8Array(32)),
|
||||
).toString("base64"),
|
||||
name: "Versia",
|
||||
redirectUris: [],
|
||||
scopes: ["openid", "profile", "email"],
|
||||
secret: "",
|
||||
});
|
||||
|
||||
const token = await Token.insert({
|
||||
id: randomUUIDv7(),
|
||||
accessToken: randomString(64, "base64url"),
|
||||
code: null,
|
||||
scope: "read write follow",
|
||||
tokenType: "Bearer",
|
||||
scopes: ["read", "write", "follow"],
|
||||
userId: user.id,
|
||||
clientId: application.id,
|
||||
});
|
||||
|
||||
console.info(
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { Instance, User } from "@versia-server/kit/db";
|
||||
import { parseUserAddress } from "@versia-server/kit/parsers";
|
||||
import { Users } from "@versia-server/kit/tables";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { parseUserAddress } from "@/api";
|
||||
import { Instance } from "~/classes/database/instance";
|
||||
import { User } from "~/classes/database/user";
|
||||
import { Users } from "~/drizzle/schema";
|
||||
|
||||
export const retrieveUser = async (
|
||||
usernameOrHandle: string,
|
||||
|
|
|
|||
48
config.ts
48
config.ts
|
|
@ -1,48 +0,0 @@
|
|||
/**
|
||||
* @file config.ts
|
||||
* @summary Config system to retrieve and modify system configuration
|
||||
* @description Can read from a hand-written file, config.toml, or from a machine-saved file, config.internal.toml
|
||||
* Fuses both and provides a way to retrieve individual values
|
||||
*/
|
||||
|
||||
import { env, file } from "bun";
|
||||
import chalk from "chalk";
|
||||
import { parseTOML } from "confbox";
|
||||
import type { z } from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { ConfigSchema } from "./classes/config/schema.ts";
|
||||
|
||||
const CONFIG_LOCATION = env.CONFIG_LOCATION ?? "./config/config.toml";
|
||||
const configFile = file(CONFIG_LOCATION);
|
||||
|
||||
if (!(await configFile.exists())) {
|
||||
throw new Error(
|
||||
`config file at "${CONFIG_LOCATION}" does not exist or is not accessible.`,
|
||||
);
|
||||
}
|
||||
|
||||
const configText = await configFile.text();
|
||||
const config = await parseTOML<z.infer<typeof ConfigSchema>>(configText);
|
||||
|
||||
const parsed = await ConfigSchema.safeParseAsync(config);
|
||||
|
||||
if (!parsed.success) {
|
||||
console.error(
|
||||
`⚠ Error encountered while loading ${chalk.gray(CONFIG_LOCATION)}.`,
|
||||
);
|
||||
console.error(
|
||||
"⚠ This is due to invalid, missing or incorrect values in the configuration file.",
|
||||
);
|
||||
console.error(
|
||||
"⚠ Here is the error message, please fix the configuration file accordingly:",
|
||||
);
|
||||
const errorMessage = fromZodError(parsed.error).message;
|
||||
|
||||
console.info(errorMessage);
|
||||
|
||||
throw new Error("Configuration file is invalid.");
|
||||
}
|
||||
|
||||
const exportedConfig = parsed.data;
|
||||
|
||||
export { exportedConfig as config };
|
||||
1
config/config
Symbolic link
1
config/config
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../config
|
||||
|
|
@ -435,53 +435,32 @@ text = "No spam"
|
|||
|
||||
[logging]
|
||||
|
||||
# Available levels: debug, info, warning, error, fatal
|
||||
log_level = "debug"
|
||||
|
||||
log_file_path = "logs/versia.log"
|
||||
|
||||
[logging.types]
|
||||
# Either pass a boolean
|
||||
# requests = true
|
||||
# Or a table with the following keys:
|
||||
# requests_content = { level = "debug", log_file_path = "logs/requests.log" }
|
||||
# Available types are: requests, responses, requests_content, filters
|
||||
# Available levels: trace, debug, info, warning, error, fatal
|
||||
log_level = "info" # For console output
|
||||
|
||||
# [logging.file]
|
||||
# path = "logs/versia.log"
|
||||
# log_level = "info"
|
||||
#
|
||||
# [logging.file.rotation]
|
||||
# max_size = 10_000_000 # 10 MB
|
||||
# max_files = 10 # Keep 10 rotated files
|
||||
#
|
||||
# https://sentry.io support
|
||||
# Uncomment to enable
|
||||
# [logging.sentry]
|
||||
# Sentry DSN for error logging
|
||||
# dsn = "https://example.com"
|
||||
# debug = false
|
||||
|
||||
# sample_rate = 1.0
|
||||
# traces_sample_rate = 1.0
|
||||
# Can also be regex
|
||||
# trace_propagation_targets = []
|
||||
# max_breadcrumbs = 100
|
||||
# environment = "production"
|
||||
# log_level = "info"
|
||||
|
||||
[plugins]
|
||||
# Whether to automatically load all plugins in the plugins directory
|
||||
autoload = true
|
||||
|
||||
# Override for autoload
|
||||
[plugins.overrides]
|
||||
enabled = []
|
||||
disabled = []
|
||||
|
||||
[plugins.config."@versia/openid"]
|
||||
# If enabled, Versia will require users to log in with an OpenID provider
|
||||
forced = false
|
||||
|
||||
# Allow registration with OpenID providers
|
||||
# If signups.registration is false, it will only be possible to register with OpenID
|
||||
allow_registration = true
|
||||
|
||||
# [plugins.config."@versia/openid".keys]
|
||||
# Run Versia Server with those values missing to generate a new key
|
||||
# public = ""
|
||||
# private = ""
|
||||
[authentication]
|
||||
# Run Versia Server with this value missing to generate a new key
|
||||
# key = ""
|
||||
|
||||
# The provider MUST support OpenID Connect with .well-known discovery
|
||||
# Most notably, GitHub does not support this
|
||||
|
|
@ -490,7 +469,7 @@ allow_registration = true
|
|||
# The asterisk is important, as it allows for any query parameters to be passed
|
||||
# Authentik for example uses regex so it can be set to (regex):
|
||||
# <base_url>/oauth/sso/<provider_id>/callback.*
|
||||
# [[plugins.config."@versia/openid".providers]]
|
||||
# [[authentication.openid_providers]]
|
||||
# name = "CPlusPatch ID"
|
||||
# id = "cpluspatch-id"
|
||||
# This MUST match the provider's issuer URI, including the trailing slash (or lack thereof)
|
||||
|
|
|
|||
|
|
@ -57,27 +57,14 @@
|
|||
"default": "versia"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"host",
|
||||
"port",
|
||||
"username",
|
||||
"password",
|
||||
"database"
|
||||
],
|
||||
"required": ["host", "username"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"description": "Additional read-only replicas",
|
||||
"default": []
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"host",
|
||||
"port",
|
||||
"username",
|
||||
"password",
|
||||
"database",
|
||||
"replicas"
|
||||
],
|
||||
"required": ["username"],
|
||||
"additionalProperties": false,
|
||||
"description": "PostgreSQL database configuration"
|
||||
},
|
||||
|
|
@ -106,7 +93,6 @@
|
|||
"default": 0
|
||||
}
|
||||
},
|
||||
"required": ["host", "port", "password", "database"],
|
||||
"additionalProperties": false,
|
||||
"description": "A Redis database used for managing queues."
|
||||
},
|
||||
|
|
@ -132,12 +118,11 @@
|
|||
"default": 1
|
||||
}
|
||||
},
|
||||
"required": ["host", "port", "password", "database"],
|
||||
"additionalProperties": false,
|
||||
"description": "A Redis database used for caching SQL queries. Optional."
|
||||
}
|
||||
},
|
||||
"required": ["queue", "cache"],
|
||||
"required": ["queue"],
|
||||
"additionalProperties": false,
|
||||
"description": "Redis configuration. Used for queues and caching."
|
||||
},
|
||||
|
|
@ -165,12 +150,11 @@
|
|||
"$ref": "#/properties/postgres/properties/password"
|
||||
}
|
||||
},
|
||||
"required": ["host", "port", "password"],
|
||||
"required": ["password"],
|
||||
"additionalProperties": false,
|
||||
"description": "Sonic database configuration"
|
||||
}
|
||||
},
|
||||
"required": ["enabled", "sonic"],
|
||||
"additionalProperties": false,
|
||||
"description": "Search and indexing configuration"
|
||||
},
|
||||
|
|
@ -191,7 +175,6 @@
|
|||
"description": "Message to show to users when registration is disabled"
|
||||
}
|
||||
},
|
||||
"required": ["allow", "require_approval", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"http": {
|
||||
|
|
@ -250,20 +233,12 @@
|
|||
"description": "This value must be a file path"
|
||||
}
|
||||
},
|
||||
"required": ["key", "cert", "passphrase", "ca"],
|
||||
"required": ["key", "cert"],
|
||||
"additionalProperties": false,
|
||||
"description": "TLS configuration. You should probably be using a reverse proxy instead of this"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"base_url",
|
||||
"bind",
|
||||
"bind_port",
|
||||
"banned_ips",
|
||||
"banned_user_agents",
|
||||
"proxy_address",
|
||||
"tls"
|
||||
],
|
||||
"required": ["base_url"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"frontend": {
|
||||
|
|
@ -275,7 +250,7 @@
|
|||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"default": "/home/jessew/Dev/versia-server"
|
||||
"default": "frontend"
|
||||
},
|
||||
"routes": {
|
||||
"type": "object",
|
||||
|
|
@ -302,13 +277,6 @@
|
|||
"default": "/oauth/reset"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"home",
|
||||
"login",
|
||||
"consent",
|
||||
"register",
|
||||
"password_reset"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"settings": {
|
||||
|
|
@ -317,7 +285,7 @@
|
|||
"default": {}
|
||||
}
|
||||
},
|
||||
"required": ["enabled", "path", "routes", "settings"],
|
||||
"required": ["routes"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"email": {
|
||||
|
|
@ -351,17 +319,10 @@
|
|||
"default": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"server",
|
||||
"port",
|
||||
"username",
|
||||
"password",
|
||||
"tls"
|
||||
],
|
||||
"required": ["server", "username"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["send_emails", "smtp"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"media": {
|
||||
|
|
@ -393,15 +354,10 @@
|
|||
"default": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"convert_images",
|
||||
"convert_to",
|
||||
"convert_vectors"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["backend", "uploads_path", "conversion"],
|
||||
"required": ["conversion"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"s3": {
|
||||
|
|
@ -425,14 +381,19 @@
|
|||
"public_url": {
|
||||
"$ref": "#/properties/http/properties/base_url",
|
||||
"description": "Public URL that uploaded media will be accessible at"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"path_style": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"endpoint",
|
||||
"access_key",
|
||||
"secret_access_key",
|
||||
"region",
|
||||
"bucket_name",
|
||||
"public_url"
|
||||
],
|
||||
"additionalProperties": false
|
||||
|
|
@ -518,18 +479,6 @@
|
|||
"default": 20
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"max_displayname_characters",
|
||||
"max_username_characters",
|
||||
"max_bio_characters",
|
||||
"max_avatar_bytes",
|
||||
"max_header_bytes",
|
||||
"disallowed_usernames",
|
||||
"max_field_count",
|
||||
"max_field_name_characters",
|
||||
"max_field_value_characters",
|
||||
"max_pinned_notes"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"notes": {
|
||||
|
|
@ -570,11 +519,6 @@
|
|||
"default": 16
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"max_characters",
|
||||
"allowed_url_schemes",
|
||||
"max_attachments"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"media": {
|
||||
|
|
@ -1838,11 +1782,6 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"max_bytes",
|
||||
"max_description_characters",
|
||||
"allowed_mime_types"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"emojis": {
|
||||
|
|
@ -1864,11 +1803,6 @@
|
|||
"default": 1000
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"max_bytes",
|
||||
"max_shortcode_characters",
|
||||
"max_description_characters"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"polls": {
|
||||
|
|
@ -1895,12 +1829,6 @@
|
|||
"default": 8640000
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"max_options",
|
||||
"max_option_characters",
|
||||
"min_duration_seconds",
|
||||
"max_duration_seconds"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"emails": {
|
||||
|
|
@ -1919,7 +1847,6 @@
|
|||
"default": []
|
||||
}
|
||||
},
|
||||
"required": ["disallow_tempmail", "disallowed_domains"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"challenges": {
|
||||
|
|
@ -1940,7 +1867,7 @@
|
|||
"description": "You can use PATH:/path/to/file to load this value from a file"
|
||||
}
|
||||
},
|
||||
"required": ["difficulty", "expiration", "key"],
|
||||
"required": ["key"],
|
||||
"additionalProperties": false,
|
||||
"description": "CAPTCHA challenge configuration. Challenges are disabled if not provided."
|
||||
},
|
||||
|
|
@ -1983,13 +1910,6 @@
|
|||
"default": []
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"note_content",
|
||||
"emoji_shortcode",
|
||||
"username",
|
||||
"displayname",
|
||||
"bio"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"description": "Block content that matches these regular expressions"
|
||||
}
|
||||
|
|
@ -2001,7 +1921,6 @@
|
|||
"emojis",
|
||||
"polls",
|
||||
"emails",
|
||||
"challenges",
|
||||
"filters"
|
||||
],
|
||||
"additionalProperties": false
|
||||
|
|
@ -2016,13 +1935,14 @@
|
|||
"type": "object",
|
||||
"properties": {
|
||||
"public": {
|
||||
"$ref": "#/properties/postgres/properties/password"
|
||||
"$ref": "#/properties/postgres/properties/password",
|
||||
"description": "You can use PATH:/path/to/file to load this value from a file"
|
||||
},
|
||||
"private": {
|
||||
"$ref": "#/properties/postgres/properties/password"
|
||||
"$ref": "#/properties/postgres/properties/password",
|
||||
"description": "You can use PATH:/path/to/file to load this value from a file"
|
||||
}
|
||||
},
|
||||
"required": ["public", "private"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"subject": {
|
||||
|
|
@ -2030,12 +1950,11 @@
|
|||
"description": "Subject field embedded in the push notification. Example: 'mailto:contact@example.com'"
|
||||
}
|
||||
},
|
||||
"required": ["vapid_keys", "subject"],
|
||||
"required": ["vapid_keys"],
|
||||
"additionalProperties": false,
|
||||
"description": "Web Push Notifications configuration. Leave out to disable."
|
||||
}
|
||||
},
|
||||
"required": ["push"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"defaults": {
|
||||
|
|
@ -2062,13 +1981,6 @@
|
|||
"description": "A style name from https://www.dicebear.com/styles"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"visibility",
|
||||
"language",
|
||||
"avatar",
|
||||
"header",
|
||||
"placeholder_style"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"federation": {
|
||||
|
|
@ -2155,17 +2067,6 @@
|
|||
"default": []
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"reports",
|
||||
"deletes",
|
||||
"updates",
|
||||
"media",
|
||||
"follows",
|
||||
"likes",
|
||||
"reactions",
|
||||
"banners",
|
||||
"avatars"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"bridge": {
|
||||
|
|
@ -2196,11 +2097,11 @@
|
|||
"$ref": "#/properties/http/properties/proxy_address"
|
||||
}
|
||||
},
|
||||
"required": ["software", "allowed_ips", "token", "url"],
|
||||
"required": ["software", "token", "url"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["blocked", "followers_only", "discard", "bridge"],
|
||||
"required": ["discard"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"queues": {
|
||||
|
|
@ -2219,10 +2120,6 @@
|
|||
"default": 31536000
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"remove_after_complete_seconds",
|
||||
"remove_after_failure_seconds"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"propertyNames": {
|
||||
|
|
@ -2264,7 +2161,6 @@
|
|||
"$ref": "#/properties/http/properties/proxy_address"
|
||||
}
|
||||
},
|
||||
"required": ["logo", "banner"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"languages": {
|
||||
|
|
@ -2489,7 +2385,7 @@
|
|||
"description": "Longer version of the rule with additional information"
|
||||
}
|
||||
},
|
||||
"required": ["text", "hint"],
|
||||
"required": ["text"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"default": []
|
||||
|
|
@ -2498,28 +2394,18 @@
|
|||
"type": "object",
|
||||
"properties": {
|
||||
"public": {
|
||||
"$ref": "#/properties/postgres/properties/password"
|
||||
"$ref": "#/properties/postgres/properties/password",
|
||||
"description": "You can use PATH:/path/to/file to load this value from a file"
|
||||
},
|
||||
"private": {
|
||||
"$ref": "#/properties/postgres/properties/password"
|
||||
"$ref": "#/properties/postgres/properties/password",
|
||||
"description": "You can use PATH:/path/to/file to load this value from a file"
|
||||
}
|
||||
},
|
||||
"required": ["public", "private"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"description",
|
||||
"extended_description_path",
|
||||
"tos_path",
|
||||
"privacy_policy_path",
|
||||
"branding",
|
||||
"languages",
|
||||
"contact",
|
||||
"rules",
|
||||
"keys"
|
||||
],
|
||||
"required": ["branding", "languages", "contact"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"permissions": {
|
||||
|
|
@ -2798,7 +2684,6 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"required": ["anonymous", "default", "admin"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"logging": {
|
||||
|
|
@ -2830,7 +2715,6 @@
|
|||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["level", "log_file_path"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
|
|
@ -2886,15 +2770,7 @@
|
|||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"dsn",
|
||||
"debug",
|
||||
"sample_rate",
|
||||
"traces_sample_rate",
|
||||
"trace_propagation_targets",
|
||||
"max_breadcrumbs",
|
||||
"environment"
|
||||
],
|
||||
"required": ["dsn"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"log_file_path": {
|
||||
|
|
@ -2902,7 +2778,7 @@
|
|||
"default": "logs/versia.log"
|
||||
}
|
||||
},
|
||||
"required": ["types", "log_level", "sentry", "log_file_path"],
|
||||
"required": ["types"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"debug": {
|
||||
|
|
@ -2913,7 +2789,6 @@
|
|||
"default": false
|
||||
}
|
||||
},
|
||||
"required": ["federation"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"plugins": {
|
||||
|
|
@ -2941,7 +2816,6 @@
|
|||
"default": []
|
||||
}
|
||||
},
|
||||
"required": ["enabled", "disabled"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"config": {
|
||||
|
|
@ -2949,7 +2823,7 @@
|
|||
"additionalProperties": {}
|
||||
}
|
||||
},
|
||||
"required": ["autoload", "overrides", "config"],
|
||||
"required": ["overrides"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
|
|
@ -2962,7 +2836,6 @@
|
|||
"frontend",
|
||||
"email",
|
||||
"media",
|
||||
"s3",
|
||||
"validation",
|
||||
"notifications",
|
||||
"defaults",
|
||||
|
|
@ -2971,7 +2844,6 @@
|
|||
"instance",
|
||||
"permissions",
|
||||
"logging",
|
||||
"debug",
|
||||
"plugins"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Multiple API routes are exposed for authentication, to be used by frontend developers.
|
||||
|
||||
> [!INFO]
|
||||
>
|
||||
>
|
||||
> These are different from the Client API routes, which are used by clients to interact with the Mastodon API.
|
||||
|
||||
A frontend is a web application that is designed to be the primary user interface for an instance. It is used also used by clients to perform authentication.
|
||||
|
|
@ -48,58 +48,6 @@ Frontend configuration.
|
|||
}
|
||||
```
|
||||
|
||||
## Sign In
|
||||
|
||||
```http
|
||||
POST /api/auth/login
|
||||
```
|
||||
|
||||
Allows users to sign in to the instance. This is the first step in the authentication process.
|
||||
|
||||
- **Returns**: `302 Found` with a `Location` header to redirect the user to the next step, as well as a `Set-Cookie` header with the session JWT.
|
||||
- **Authentication**: Not required
|
||||
- **Permissions**: None
|
||||
- **Version History**:
|
||||
- `0.7.0`: First documented.
|
||||
|
||||
### Request
|
||||
|
||||
- `identifier` (string, required): The username or email of the user. Case-insensitive.
|
||||
- `password` (string, required): The password of the user.
|
||||
|
||||
#### Query Parameters
|
||||
|
||||
- `client_id` (string, required): Client ID of the [application](https://docs.joinmastodon.org/entities/Application/) that is making the request.
|
||||
- `redirect_uri` (string, required): Redirect URI of the [application](https://docs.joinmastodon.org/entities/Application/) that is making the request. Must match the saved value.
|
||||
- `response_type` (string, required): Must be `code`.
|
||||
- `scope` (string, required): OAuth2 scopes. Must match the value indicated in the [application](https://docs.joinmastodon.org/entities/Application/).
|
||||
|
||||
#### Example
|
||||
|
||||
```http
|
||||
POST /api/auth/login?client_id=123&redirect_uri=https%3A%2F%2Fexample.com%2Fauth&response_type=code&scope=read%20write
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"identifier": "bobjones@gmail.com",
|
||||
"password": "hunter2"
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
#### `302 Found`
|
||||
|
||||
Redirects the user to the consent page with some query parameters. The frontend should redirect the user to this URL.
|
||||
|
||||
This response also has a `Set-Cookie` header with a [JSON Web Token](https://jwt.io/) that contains the user's session information. This JWT is signed with the instance's secret key, and must be included in all subsequent authentication requests.
|
||||
|
||||
```http
|
||||
HTTP/2.0 302 Found
|
||||
Location: /oauth/consent?client_id=123&redirect_uri=https%3A%2F%2Fexample.com%2Fauth&response_type=code&scope=read%20write
|
||||
Set-Cookie: jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=3600
|
||||
```
|
||||
|
||||
## SSO Sign In
|
||||
|
||||
```http
|
||||
|
|
@ -136,4 +84,4 @@ Redirects the user to the OpenID Connect provider's login page.
|
|||
```http
|
||||
HTTP/2.0 302 Found
|
||||
Location: https://accounts.google.com/o/oauth2/auth?client_id=123&redirect_uri=https%3A%2F%2Fexample.com%2Fauth&response_type=code&scope=openid%20email&state=123
|
||||
```
|
||||
```
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ GET /oauth/authorize
|
|||
|
||||
This route should display a login form for the user to enter their username and password, as well as a list of OpenID providers to use if available.
|
||||
|
||||
The form should submit to [`POST /api/auth/login`](./auth.md#sign-in), or to the OpenID Connect flow.
|
||||
The form should submit to the OpenID Connect flow.
|
||||
|
||||
Configurable in the Versia Server configuration at `frontend.routes.login`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -7,8 +7,8 @@ import { config } from "~/config.ts";
|
|||
*/
|
||||
export default {
|
||||
dialect: "postgresql",
|
||||
out: "./drizzle/migrations",
|
||||
schema: "./drizzle/schema.ts",
|
||||
out: "./packages/kit/tables/migrations",
|
||||
schema: "./packages/kit/tables/schema.ts",
|
||||
dbCredentials: {
|
||||
/* host: "localhost",
|
||||
port: 40000,
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
import cluster from "node:cluster";
|
||||
import process from "node:process";
|
||||
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();
|
||||
});
|
||||
|
||||
process.on("uncaughtException", async (error) => {
|
||||
const youch = new Youch();
|
||||
|
||||
console.error(await youch.toANSI(error));
|
||||
});
|
||||
|
||||
if (cluster.isPrimary) {
|
||||
for (let i = 0; i < Number(process.env.NUM_CPUS ?? 1); i++) {
|
||||
cluster.fork();
|
||||
}
|
||||
|
||||
await import("~/entrypoints/api/setup.ts");
|
||||
sentry?.captureMessage("Server started", "info");
|
||||
} else {
|
||||
createServer(config, await appFactory());
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import process from "node:process";
|
||||
import { getLogger } from "@logtape/logtape";
|
||||
import chalk from "chalk";
|
||||
import { sentry } from "@/sentry";
|
||||
import { getDeliveryWorker } from "~/classes/queues/delivery";
|
||||
import { getFetchWorker } from "~/classes/queues/fetch";
|
||||
import { getInboxWorker } from "~/classes/queues/inbox";
|
||||
import { getMediaWorker } from "~/classes/queues/media";
|
||||
import { getPushWorker } from "~/classes/queues/push";
|
||||
import { getRelationshipWorker } from "~/classes/queues/relationships";
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
process.exit();
|
||||
});
|
||||
|
||||
await import("~/entrypoints/worker/setup.ts");
|
||||
sentry?.captureMessage("Server started", "info");
|
||||
|
||||
const serverLogger = getLogger("server");
|
||||
|
||||
serverLogger.info`Starting Fetch Worker...`;
|
||||
getFetchWorker();
|
||||
serverLogger.info`${chalk.green("✔")} Fetch Worker started`;
|
||||
|
||||
serverLogger.info`Starting Delivery Worker...`;
|
||||
getDeliveryWorker();
|
||||
serverLogger.info`${chalk.green("✔")} Delivery Worker started`;
|
||||
|
||||
serverLogger.info`Starting Inbox Worker...`;
|
||||
getInboxWorker();
|
||||
serverLogger.info`${chalk.green("✔")} Inbox Worker started`;
|
||||
|
||||
serverLogger.info`Starting Push Worker...`;
|
||||
getPushWorker();
|
||||
serverLogger.info`${chalk.green("✔")} Push Worker started`;
|
||||
|
||||
serverLogger.info`Starting Media Worker...`;
|
||||
getMediaWorker();
|
||||
serverLogger.info`${chalk.green("✔")} Media Worker started`;
|
||||
|
||||
serverLogger.info`Starting Relationship Worker...`;
|
||||
getRelationshipWorker();
|
||||
serverLogger.info`${chalk.green("✔")} Relationship Worker started`;
|
||||
|
||||
serverLogger.info`${chalk.green("✔✔✔✔✔✔")} All workers started`;
|
||||
12
flake.lock
12
flake.lock
|
|
@ -20,16 +20,16 @@
|
|||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1744536153,
|
||||
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
|
||||
"owner": "nixos",
|
||||
"lastModified": 1763421233,
|
||||
"narHash": "sha256-Stk9ZYRkGrnnpyJ4eqt9eQtdFWRRIvMxpNRf4sIegnw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
|
||||
"rev": "89c2b2330e733d6cdb5eae7b899326930c2c0648",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
description = "Versia Server";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
|
@ -15,7 +15,9 @@
|
|||
}:
|
||||
{
|
||||
overlays.default = final: prev: rec {
|
||||
versia-server = final.callPackage ./nix/package.nix {};
|
||||
versia-server =
|
||||
final.callPackage ./nix/package.nix {
|
||||
};
|
||||
versia-server-worker = final.callPackage ./nix/package-worker.nix {
|
||||
inherit versia-server;
|
||||
};
|
||||
|
|
@ -54,7 +56,6 @@
|
|||
buildInputs = with pkgs; [
|
||||
bun
|
||||
vips
|
||||
pnpm
|
||||
nodePackages.typescript
|
||||
nodePackages.typescript-language-server
|
||||
nix-ld
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
import { getLogger } from "@logtape/logtape";
|
||||
import { SHA256 } from "bun";
|
||||
import chalk from "chalk";
|
||||
import { createMiddleware } from "hono/factory";
|
||||
import { config } from "~/config.ts";
|
||||
|
||||
export const logger = createMiddleware(async (context, next) => {
|
||||
if (config.logging.types.requests) {
|
||||
const serverLogger = getLogger("server");
|
||||
const body = await context.req.raw.clone().text();
|
||||
|
||||
const urlAndMethod = `${chalk.green(context.req.method)} ${chalk.blue(context.req.url)}`;
|
||||
|
||||
const hash = `${chalk.bold("Hash")}: ${chalk.yellow(
|
||||
new SHA256().update(body).digest("hex"),
|
||||
)}`;
|
||||
|
||||
const headers = `${chalk.bold("Headers")}:\n${Array.from(
|
||||
context.req.raw.headers.entries(),
|
||||
)
|
||||
.map(
|
||||
([key, value]) =>
|
||||
` - ${chalk.cyan(key)}: ${chalk.white(value)}`,
|
||||
)
|
||||
.join("\n")}`;
|
||||
|
||||
const bodyLog = `${chalk.bold("Body")}: ${chalk.gray(body)}`;
|
||||
|
||||
if (config.logging.types.requests_content) {
|
||||
serverLogger.debug`${urlAndMethod}\n${hash}\n${headers}\n${bodyLog}`;
|
||||
} else {
|
||||
serverLogger.debug`${urlAndMethod}`;
|
||||
}
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
|
@ -123,6 +123,28 @@ in {
|
|||
StandardError = "journal";
|
||||
SyslogIdentifier = "${name}";
|
||||
|
||||
# Hardening
|
||||
CapabilityBoundingSet = [""];
|
||||
LockPersonality = true;
|
||||
PrivateMounts = true;
|
||||
PrivateTmp = true;
|
||||
ProcSubset = "pid";
|
||||
ProtectClock = true;
|
||||
ProtectControlGroups = true;
|
||||
ProtectHome = true;
|
||||
ProtectHostname = true;
|
||||
ProtectKernelLogs = true;
|
||||
ProtectKernelModules = true;
|
||||
ProtectKernelTunables = true;
|
||||
ProtectProc = "invisible";
|
||||
ProtectSystem = "strict";
|
||||
RestrictNamespaces = true;
|
||||
RestrictRealtime = true;
|
||||
RestrictSUIDSGID = true;
|
||||
SystemCallArchitectures = "native";
|
||||
RemoveIPC = true;
|
||||
NoNewPrivileges = true;
|
||||
|
||||
Environment = [
|
||||
"CONFIG_LOCATION=${configFile}"
|
||||
];
|
||||
|
|
|
|||
|
|
@ -2,14 +2,8 @@
|
|||
{versia-server, ...}:
|
||||
versia-server.overrideAttrs (oldAttrs: {
|
||||
pname = "${oldAttrs.pname}-worker";
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
bun run build:worker
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
entrypointPath = "worker.js";
|
||||
buildType = "worker";
|
||||
|
||||
meta =
|
||||
oldAttrs.meta
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
{
|
||||
lib,
|
||||
stdenv,
|
||||
pnpm,
|
||||
bun,
|
||||
nodejs,
|
||||
vips,
|
||||
makeWrapper,
|
||||
stdenvNoCC,
|
||||
writableTmpDirAsHomeHook,
|
||||
...
|
||||
}: let
|
||||
packageJson = builtins.fromJSON (builtins.readFile ../package.json);
|
||||
|
|
@ -16,35 +17,70 @@ in
|
|||
|
||||
src = ../.;
|
||||
|
||||
# Fixes the build script mv usage
|
||||
pnpmInstallFlags = ["--shamefully-hoist"];
|
||||
node_modules = stdenvNoCC.mkDerivation {
|
||||
pname = "${finalAttrs.pname}-node_modules";
|
||||
inherit (finalAttrs) version src;
|
||||
|
||||
pnpmDeps = pnpm.fetchDeps {
|
||||
inherit (finalAttrs) pname version src pnpmInstallFlags;
|
||||
hash = "sha256-bY0QfLYREeKn8ROupQdjOUv9t4+6HKLsTXOolzNCuU4=";
|
||||
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-gr4R+S4OusBtQtlskzjS+FEtT2mKCXcr6jk7EInXXMo=";
|
||||
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} \
|
||||
|
|
|
|||
266
package.json
266
package.json
|
|
@ -2,7 +2,7 @@
|
|||
"name": "versia-server",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0-alpha.0",
|
||||
"description": "Powerful, configurable and modular federated server using the Versia Protocol.",
|
||||
"homepage": "https://versia.pub",
|
||||
"author": {
|
||||
|
|
@ -20,9 +20,102 @@
|
|||
"activitypub",
|
||||
"bun"
|
||||
],
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"lint": "biome check .",
|
||||
"cloc": "cloc . --exclude-dir node_modules,dist,.output,.nuxt,meta,logs --exclude-ext sql,log,pem",
|
||||
"cli": "bun run cli/index.ts",
|
||||
"typecheck": "bunx tsc -p .",
|
||||
"test": "bun test",
|
||||
"build": "bun run --filter \"*\" build && bun run build.ts",
|
||||
"detect-circular": "bunx madge --circular --extensions ts ./",
|
||||
"update-nix-hashes": "bash scripts/update-nix.sh",
|
||||
"schema:generate": "bun run packages/config/to-json-schema.ts > config/config.schema.json",
|
||||
"run-api": "bun run build api && cd dist && ln -s ../config config && bun run api.js",
|
||||
"run-worker": "bun run build worker && cd dist && ln -s ../config config && bun run worker.js",
|
||||
"dev": "bun run --hot api.ts",
|
||||
"worker:dev": "bun run --hot worker.ts"
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"packages/*"
|
||||
],
|
||||
"catalog": {
|
||||
"@biomejs/biome": "2.3.4",
|
||||
"@types/bun": "~1.3.2",
|
||||
"@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.7",
|
||||
"mitt": "~3.0.1",
|
||||
"markdown-it-image-figures": "~2.1.1",
|
||||
"ts-prune": "~0.10.3",
|
||||
"typescript": "~5.9.3",
|
||||
"vitepress": "~1.6.4",
|
||||
"vitepress-plugin-tabs": "~0.7.3",
|
||||
"vitepress-sidebar": "~1.33.0",
|
||||
"vue": "~3.5.24",
|
||||
"@bull-board/api": "~6.14.2",
|
||||
"@bull-board/hono": "~6.14.2",
|
||||
"@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.2.0",
|
||||
"@inquirer/confirm": "~6.0.1",
|
||||
"@logtape/file": "~1.2.0",
|
||||
"@logtape/logtape": "~1.2.0",
|
||||
"@logtape/sentry": "~1.2.0",
|
||||
"@logtape/otel": "~1.2.0",
|
||||
"@scalar/hono-api-reference": "~0.9.25",
|
||||
"@sentry/bun": "~10.26.0",
|
||||
"openid-client": "~6.8.1",
|
||||
"altcha-lib": "~1.3.0",
|
||||
"blurhash": "~2.0.5",
|
||||
"bullmq": "~5.64.1",
|
||||
"chalk": "~5.6.2",
|
||||
"clerc": "~0.44.0",
|
||||
"confbox": "~0.2.2",
|
||||
"drizzle-orm": "~0.44.7",
|
||||
"feed": "~5.1.0",
|
||||
"hono": "~4.10.6",
|
||||
"hono-openapi": "~1.1.1",
|
||||
"hono-rate-limiter": "~0.4.2",
|
||||
"html-to-text": "~9.0.5",
|
||||
"ioredis": "~5.8.2",
|
||||
"ip-matching": "~2.1.2",
|
||||
"iso-639-1": "~3.1.5",
|
||||
"linkify-html": "~4.3.2",
|
||||
"linkify-string": "~4.3.2",
|
||||
"linkifyjs": "~4.3.2",
|
||||
"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": "~5.2.0",
|
||||
"markdown-it-toc-done-right": "~4.2.0",
|
||||
"mime-types": "~3.0.2",
|
||||
"mitata": "~1.0.34",
|
||||
"ora": "~9.0.0",
|
||||
"qs": "~6.14.0",
|
||||
"sharp": "~0.34.5",
|
||||
"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.13",
|
||||
"zod": "~4.1.12",
|
||||
"zod-openapi": "~5.4.3",
|
||||
"zod-validation-error": "~5.0.0"
|
||||
}
|
||||
},
|
||||
"maintainers": [
|
||||
{
|
||||
"email": "contact@cpluspatch.com",
|
||||
|
|
@ -35,107 +128,92 @@
|
|||
"url": "git+https://github.com/versia-pub/server.git"
|
||||
},
|
||||
"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 .",
|
||||
"test": "bun test",
|
||||
"docs:dev": "vitepress dev docs",
|
||||
"docs:build": "vitepress build docs",
|
||||
"docs:preview": "vitepress preview docs"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@biomejs/biome",
|
||||
"es5-ext",
|
||||
"esbuild",
|
||||
"msgpackr-extract",
|
||||
"protobufjs",
|
||||
"sharp"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.0.0-beta.5",
|
||||
"@types/bun": "^1.2.14",
|
||||
"@types/html-to-text": "^9.0.4",
|
||||
"@types/markdown-it-container": "^2.0.10",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@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.14",
|
||||
"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.9.6",
|
||||
"@bull-board/hono": "^6.9.6",
|
||||
"@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.5.0",
|
||||
"@inquirer/confirm": "^5.1.10",
|
||||
"@logtape/file": "^0.10.0",
|
||||
"@logtape/logtape": "^0.10.0",
|
||||
"@scalar/hono-api-reference": "^0.9.1",
|
||||
"@sentry/bun": "^9.22.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.2.0",
|
||||
"blurhash": "^2.0.5",
|
||||
"bullmq": "^5.53.0",
|
||||
"chalk": "^5.4.1",
|
||||
"clerc": "^0.44.0",
|
||||
"confbox": "^0.2.2",
|
||||
"drizzle-orm": "^0.43.1",
|
||||
"feed": "^5.0.1",
|
||||
"hono": "^4.7.10",
|
||||
"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.1",
|
||||
"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",
|
||||
"uqr": "^0.1.2",
|
||||
"web-push": "^3.6.7",
|
||||
"xss": "^1.0.15",
|
||||
"youch": "^4.1.0-beta.7",
|
||||
"zod": "^3.25.23",
|
||||
"zod-openapi": "^4.2.4",
|
||||
"zod-validation-error": "^3.4.1"
|
||||
"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:",
|
||||
"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:",
|
||||
"ora": "catalog:",
|
||||
"qs": "catalog:",
|
||||
"sharp": "catalog:",
|
||||
"sonic-channel": "catalog:",
|
||||
"string-comparison": "catalog:",
|
||||
"stringify-entities": "catalog:",
|
||||
"unicode-emoji-json": "catalog:",
|
||||
"uqr": "catalog:",
|
||||
"web-push": "catalog:",
|
||||
"xss": "catalog:",
|
||||
"youch": "catalog:",
|
||||
"zod": "catalog:",
|
||||
"zod-openapi": "catalog:",
|
||||
"zod-validation-error": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +1,25 @@
|
|||
import { resolve } from "node:path";
|
||||
import { getLogger } from "@logtape/logtape";
|
||||
import { Scalar } from "@scalar/hono-api-reference";
|
||||
import chalk from "chalk";
|
||||
import { config } from "@versia-server/config";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import { Hono } from "hono";
|
||||
import { serveStatic } from "hono/bun";
|
||||
import { cors } from "hono/cors";
|
||||
import { createMiddleware } from "hono/factory";
|
||||
import { prettyJSON } from "hono/pretty-json";
|
||||
import { secureHeaders } from "hono/secure-headers";
|
||||
import { openAPISpecs } from "hono-openapi";
|
||||
import { generateSpecs } from "hono-openapi";
|
||||
import { Youch } from "youch";
|
||||
import { applyToHono } from "@/bull-board.ts";
|
||||
import { configureLoggers } from "@/loggers";
|
||||
import { sentry } from "@/sentry";
|
||||
import { config } from "~/config.ts";
|
||||
import pkg from "~/package.json" with { type: "application/json" };
|
||||
import { ApiError } from "./classes/errors/api-error.ts";
|
||||
import { PluginLoader } from "./classes/plugin/loader.ts";
|
||||
import pkg from "../../package.json" with { type: "application/json" };
|
||||
import type { ApiRouteExports, HonoEnv } from "../../types/api.ts";
|
||||
import { agentBans } from "./middlewares/agent-bans.ts";
|
||||
import { boundaryCheck } from "./middlewares/boundary-check.ts";
|
||||
import { ipBans } from "./middlewares/ip-bans.ts";
|
||||
import { logger } from "./middlewares/logger.ts";
|
||||
import { rateLimit } from "./middlewares/rate-limit.ts";
|
||||
import { routes } from "./routes.ts";
|
||||
import type { ApiRouteExports, HonoEnv } from "./types/api.ts";
|
||||
// Extends Zod with OpenAPI schema generation
|
||||
import "zod-openapi/extend";
|
||||
|
||||
export const appFactory = async (): Promise<Hono<HonoEnv>> => {
|
||||
await configureLoggers();
|
||||
const serverLogger = getLogger("server");
|
||||
|
||||
const app = new Hono<HonoEnv>({
|
||||
strict: false,
|
||||
});
|
||||
|
|
@ -111,43 +100,23 @@ export const appFactory = async (): Promise<Hono<HonoEnv>> => {
|
|||
route.default(app);
|
||||
}
|
||||
|
||||
serverLogger.info`Loading plugins`;
|
||||
|
||||
const time1 = performance.now();
|
||||
|
||||
const loader = new PluginLoader();
|
||||
|
||||
const plugins = await loader.loadPlugins(
|
||||
resolve("./plugins"),
|
||||
config.plugins?.autoload ?? true,
|
||||
config.plugins?.overrides.enabled,
|
||||
config.plugins?.overrides.disabled,
|
||||
);
|
||||
|
||||
await PluginLoader.addToApp(plugins, app, serverLogger);
|
||||
|
||||
const time2 = performance.now();
|
||||
|
||||
serverLogger.info`Plugins loaded in ${`${chalk.gray(
|
||||
(time2 - time1).toFixed(2),
|
||||
)}ms`}`;
|
||||
|
||||
app.get(
|
||||
"/openapi.json",
|
||||
openAPISpecs(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: "Versia Server API",
|
||||
version: pkg.version,
|
||||
license: {
|
||||
name: "AGPL-3.0",
|
||||
url: "https://www.gnu.org/licenses/agpl-3.0.html",
|
||||
},
|
||||
contact: pkg.author,
|
||||
const openApiSpecs = await generateSpecs(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: "Versia Server API",
|
||||
version: pkg.version,
|
||||
license: {
|
||||
name: "AGPL-3.0",
|
||||
url: "https://www.gnu.org/licenses/agpl-3.0.html",
|
||||
},
|
||||
contact: pkg.author,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
app.get("/openapi.json", (context) => {
|
||||
return context.json(openApiSpecs, 200);
|
||||
});
|
||||
|
||||
app.get(
|
||||
"/docs",
|
||||
|
|
@ -193,7 +162,6 @@ export const appFactory = async (): Promise<Hono<HonoEnv>> => {
|
|||
const youch = new Youch();
|
||||
console.error(await youch.toANSI(error));
|
||||
|
||||
sentry?.captureException(error);
|
||||
return c.json(
|
||||
{
|
||||
error: "A server error occured",
|
||||
38
packages/api/build.ts
Normal file
38
packages/api/build.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { $, build } from "bun";
|
||||
import manifest from "./package.json" with { type: "json" };
|
||||
import { routes } from "./routes.ts";
|
||||
|
||||
console.log("Building...");
|
||||
|
||||
await $`rm -rf dist && mkdir dist`;
|
||||
|
||||
await build({
|
||||
entrypoints: [
|
||||
...Object.values(manifest.exports).map((entry) => entry.import),
|
||||
// Force Bun to include endpoints
|
||||
...Object.values(routes),
|
||||
],
|
||||
outdir: "dist",
|
||||
target: "bun",
|
||||
splitting: true,
|
||||
minify: true,
|
||||
external: [
|
||||
...Object.keys(manifest.dependencies).filter((dep) =>
|
||||
dep.startsWith("@versia"),
|
||||
),
|
||||
"@bull-board/ui",
|
||||
// Excluded because Standard Schema imports those, but the code is never executed
|
||||
"@valibot/to-json-schema",
|
||||
"effect",
|
||||
],
|
||||
});
|
||||
|
||||
console.log("Copying files...");
|
||||
|
||||
await $`mkdir -p dist/node_modules`;
|
||||
|
||||
// Copy bull-board to dist
|
||||
await $`mkdir -p dist/node_modules/@bull-board`;
|
||||
await $`cp -rL ../../node_modules/@bull-board/ui dist/node_modules/@bull-board/ui`;
|
||||
|
||||
console.log("Build complete!");
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { config } from "@versia-server/config";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import { createMiddleware } from "hono/factory";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { config } from "~/config.ts";
|
||||
|
||||
export const agentBans = createMiddleware(async (context, next) => {
|
||||
// Check for banned user agents (regex)
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { ApiError } from "@versia-server/kit";
|
||||
import { createMiddleware } from "hono/factory";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export const boundaryCheck = createMiddleware(async (context, next) => {
|
||||
// Checks that FormData boundary is present
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
import { getLogger } from "@logtape/logtape";
|
||||
import { config } from "@versia-server/config";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import { serverLogger } from "@versia-server/logging";
|
||||
import type { SocketAddress } from "bun";
|
||||
import { createMiddleware } from "hono/factory";
|
||||
import { matches } from "ip-matching";
|
||||
import { sentry } from "@/sentry";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { config } from "~/config.ts";
|
||||
|
||||
export const ipBans = createMiddleware(async (context, next) => {
|
||||
// Check for banned IPs
|
||||
|
|
@ -22,11 +21,8 @@ export const ipBans = createMiddleware(async (context, next) => {
|
|||
throw new ApiError(403, "Forbidden");
|
||||
}
|
||||
} catch (e) {
|
||||
const logger = getLogger("server");
|
||||
|
||||
logger.error`Error while parsing banned IP "${ip}" `;
|
||||
logger.error`${e}`;
|
||||
sentry?.captureException(e);
|
||||
serverLogger.error`Error while parsing banned IP "${ip}" `;
|
||||
serverLogger.error`${e}`;
|
||||
|
||||
return context.json(
|
||||
{ error: `A server error occured: ${(e as Error).message}` },
|
||||
26
packages/api/middlewares/logger.ts
Normal file
26
packages/api/middlewares/logger.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { serverLogger } from "@versia-server/logging";
|
||||
import { SHA256 } from "bun";
|
||||
import chalk from "chalk";
|
||||
import { createMiddleware } from "hono/factory";
|
||||
|
||||
export const logger = createMiddleware(async (context, next) => {
|
||||
const body = await context.req.raw.clone().text();
|
||||
|
||||
const urlAndMethod = `${chalk.green(context.req.method)} ${chalk.blue(context.req.url)}`;
|
||||
|
||||
const hash = `${chalk.bold("Hash")}: ${chalk.yellow(
|
||||
new SHA256().update(body).digest("hex"),
|
||||
)}`;
|
||||
|
||||
const headers = `${chalk.bold("Headers")}:\n${Array.from(
|
||||
context.req.raw.headers.entries(),
|
||||
)
|
||||
.map(([key, value]) => ` - ${chalk.cyan(key)}: ${chalk.white(value)}`)
|
||||
.join("\n")}`;
|
||||
|
||||
const bodyLog = `${chalk.bold("Body")}: ${chalk.gray(body)}`;
|
||||
|
||||
serverLogger.debug`${urlAndMethod}\n${hash}\n${headers}\n${bodyLog}`;
|
||||
|
||||
await next();
|
||||
});
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import type { ApiError } from "@versia-server/kit";
|
||||
import { env } from "bun";
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { rateLimiter } from "hono-rate-limiter";
|
||||
import type { z } from "zod";
|
||||
import type { ApiError } from "~/classes/errors/api-error";
|
||||
import type { HonoEnv } from "~/types/api";
|
||||
|
||||
// Not exported by hono-rate-limiter
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { config } from "@versia-server/config";
|
||||
import { createMiddleware } from "hono/factory";
|
||||
import { config } from "~/config.ts";
|
||||
|
||||
export const urlCheck = createMiddleware(async (context, next) => {
|
||||
// Check that request URL matches base_url
|
||||
80
packages/api/package.json
Normal file
80
packages/api/package.json
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
{
|
||||
"name": "@versia-server/api",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"version": "0.9.0-alpha.0",
|
||||
"description": "Powerful, configurable and modular federated server using the Versia Protocol.",
|
||||
"homepage": "https://versia.pub",
|
||||
"author": {
|
||||
"email": "contact@cpluspatch.com",
|
||||
"name": "Jesse Wierzbinski",
|
||||
"url": "https://cpluspatch.com"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/versia-pub/server/issues"
|
||||
},
|
||||
"icon": "https://cdn.versia.pub/branding/icon.svg",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"keywords": [
|
||||
"federated",
|
||||
"activitypub",
|
||||
"bun"
|
||||
],
|
||||
"maintainers": [
|
||||
{
|
||||
"email": "contact@cpluspatch.com",
|
||||
"name": "Jesse Wierzbinski",
|
||||
"url": "https://cpluspatch.com"
|
||||
}
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/versia-pub/server.git",
|
||||
"directory": "packages/api"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "bun run --hot index.ts",
|
||||
"build": "bun run build.ts",
|
||||
"docs:dev": "vitepress dev docs",
|
||||
"docs:build": "vitepress build docs",
|
||||
"docs:preview": "vitepress preview docs"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./app.ts"
|
||||
},
|
||||
"./setup": {
|
||||
"import": "./setup.ts"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@versia-server/config": "workspace:*",
|
||||
"@versia-server/tests": "workspace:*",
|
||||
"@versia-server/kit": "workspace:*",
|
||||
"@versia-server/logging": "workspace:*",
|
||||
"@versia/client": "workspace:*",
|
||||
"@versia/sdk": "workspace:*",
|
||||
"openid-client": "catalog:",
|
||||
"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:",
|
||||
"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:"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
import { join } from "node:path";
|
||||
import { FileSystemRouter } from "bun";
|
||||
|
||||
// Returns the route filesystem path when given a URL
|
||||
export const routeMatcher = new FileSystemRouter({
|
||||
style: "nextjs",
|
||||
dir: "api",
|
||||
dir: join(import.meta.dir, "routes"),
|
||||
fileExtensions: [".ts", ".js"],
|
||||
});
|
||||
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
||||
import { generateClient, getTestUsers } from "@versia-server/tests";
|
||||
|
||||
const { users, deleteUsers } = await getTestUsers(2);
|
||||
|
||||
|
|
@ -2,11 +2,10 @@ import {
|
|||
Relationship as RelationshipSchema,
|
||||
RolePermission,
|
||||
} from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import { apiRoute, auth, withUserParam } from "@versia-server/kit/api";
|
||||
import { Relationship } from "@versia-server/kit/db";
|
||||
import { describeRoute, resolver } from "hono-openapi";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
|
|
@ -1,10 +1,14 @@
|
|||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { 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";
|
||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
||||
import { getFeed } from "@/rss";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
|
|
@ -34,12 +38,13 @@ export default apiRoute((app) =>
|
|||
RolePermission.ViewNotes,
|
||||
RolePermission.ViewAccounts,
|
||||
],
|
||||
|
||||
scopes: ["read:statuses"],
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
page: z.coerce.number().default(0).openapi({
|
||||
page: z.coerce.number().default(0).meta({
|
||||
description: "Page number to fetch. Defaults to 0.",
|
||||
example: 2,
|
||||
}),
|
||||
|
|
@ -1,10 +1,14 @@
|
|||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { 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";
|
||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
||||
import { getFeed } from "@/rss";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
|
|
@ -33,12 +37,13 @@ export default apiRoute((app) =>
|
|||
RolePermission.ViewNotes,
|
||||
RolePermission.ViewAccounts,
|
||||
],
|
||||
|
||||
scopes: ["read:statuses"],
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
page: z.coerce.number().default(0).openapi({
|
||||
page: z.coerce.number().default(0).meta({
|
||||
description: "Page number to fetch. Defaults to 0.",
|
||||
example: 2,
|
||||
}),
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
||||
import { generateClient, getTestUsers } from "@versia-server/tests";
|
||||
|
||||
const { users, deleteUsers } = await getTestUsers(3);
|
||||
|
||||
|
|
@ -3,12 +3,16 @@ import {
|
|||
Relationship as RelationshipSchema,
|
||||
RolePermission,
|
||||
} from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { 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";
|
||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
|
|
@ -57,12 +61,12 @@ export default apiRoute((app) =>
|
|||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
reblogs: z.boolean().default(true).openapi({
|
||||
reblogs: z.boolean().default(true).meta({
|
||||
description:
|
||||
"Receive this account’s reblogs in home timeline?",
|
||||
example: true,
|
||||
}),
|
||||
notify: z.boolean().default(false).openapi({
|
||||
notify: z.boolean().default(false).meta({
|
||||
description:
|
||||
"Receive notifications when this account posts a status?",
|
||||
example: false,
|
||||
|
|
@ -70,7 +74,7 @@ export default apiRoute((app) =>
|
|||
languages: z
|
||||
.array(iso631)
|
||||
.default([])
|
||||
.openapi({
|
||||
.meta({
|
||||
description:
|
||||
"Array of String (ISO 639-1 language two-letter code). Filter received statuses for these languages. If not provided, you will receive this account’s posts in all languages.",
|
||||
example: ["en", "fr"],
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
||||
import { generateClient, getTestUsers } from "@versia-server/tests";
|
||||
|
||||
const { users, deleteUsers } = await getTestUsers(5);
|
||||
|
||||
|
|
@ -2,14 +2,18 @@ import {
|
|||
Account as AccountSchema,
|
||||
RolePermission,
|
||||
} from "@versia/client/schemas";
|
||||
import { Timeline } from "@versia/kit/db";
|
||||
import { Users } from "@versia/kit/tables";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import {
|
||||
apiRoute,
|
||||
auth,
|
||||
handleZodError,
|
||||
withUserParam,
|
||||
} from "@versia-server/kit/api";
|
||||
import { Timeline } from "@versia-server/kit/db";
|
||||
import { Users } from "@versia-server/kit/tables";
|
||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||
import { z } from "zod";
|
||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
|
|
@ -34,7 +38,7 @@ export default apiRoute((app) =>
|
|||
link: z
|
||||
.string()
|
||||
.optional()
|
||||
.openapi({
|
||||
.meta({
|
||||
description:
|
||||
"Links to the next and previous pages",
|
||||
example:
|
||||
|
|
@ -61,22 +65,22 @@ export default apiRoute((app) =>
|
|||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
max_id: AccountSchema.shape.id.optional().openapi({
|
||||
max_id: AccountSchema.shape.id.optional().meta({
|
||||
description:
|
||||
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
|
||||
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
|
||||
}),
|
||||
since_id: AccountSchema.shape.id.optional().openapi({
|
||||
since_id: AccountSchema.shape.id.optional().meta({
|
||||
description:
|
||||
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
|
||||
example: undefined,
|
||||
}),
|
||||
min_id: AccountSchema.shape.id.optional().openapi({
|
||||
min_id: AccountSchema.shape.id.optional().meta({
|
||||
description:
|
||||
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
|
||||
example: undefined,
|
||||
}),
|
||||
limit: z.number().int().min(1).max(40).default(20).openapi({
|
||||
limit: z.number().int().min(1).max(40).default(20).meta({
|
||||
description: "Maximum number of results to return.",
|
||||
}),
|
||||
}),
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
||||
import { generateClient, getTestUsers } from "@versia-server/tests";
|
||||
|
||||
const { users, deleteUsers } = await getTestUsers(5);
|
||||
|
||||
|
|
@ -2,14 +2,18 @@ import {
|
|||
Account as AccountSchema,
|
||||
RolePermission,
|
||||
} from "@versia/client/schemas";
|
||||
import { Timeline } from "@versia/kit/db";
|
||||
import { Users } from "@versia/kit/tables";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import {
|
||||
apiRoute,
|
||||
auth,
|
||||
handleZodError,
|
||||
withUserParam,
|
||||
} from "@versia-server/kit/api";
|
||||
import { Timeline } from "@versia-server/kit/db";
|
||||
import { Users } from "@versia-server/kit/tables";
|
||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||
import { z } from "zod";
|
||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
|
|
@ -35,7 +39,7 @@ export default apiRoute((app) =>
|
|||
link: z
|
||||
.string()
|
||||
.optional()
|
||||
.openapi({
|
||||
.meta({
|
||||
description:
|
||||
"Links to the next and previous pages",
|
||||
example:
|
||||
|
|
@ -62,22 +66,22 @@ export default apiRoute((app) =>
|
|||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
max_id: AccountSchema.shape.id.optional().openapi({
|
||||
max_id: AccountSchema.shape.id.optional().meta({
|
||||
description:
|
||||
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
|
||||
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
|
||||
}),
|
||||
since_id: AccountSchema.shape.id.optional().openapi({
|
||||
since_id: AccountSchema.shape.id.optional().meta({
|
||||
description:
|
||||
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
|
||||
example: undefined,
|
||||
}),
|
||||
min_id: AccountSchema.shape.id.optional().openapi({
|
||||
min_id: AccountSchema.shape.id.optional().meta({
|
||||
description:
|
||||
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
|
||||
example: undefined,
|
||||
}),
|
||||
limit: z.number().int().min(1).max(40).default(20).openapi({
|
||||
limit: z.number().int().min(1).max(40).default(20).meta({
|
||||
description: "Maximum number of results to return.",
|
||||
}),
|
||||
}),
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { generateClient, getTestStatuses, getTestUsers } from "~/tests/utils";
|
||||
import {
|
||||
generateClient,
|
||||
getTestStatuses,
|
||||
getTestUsers,
|
||||
} from "@versia-server/tests";
|
||||
|
||||
const { users, deleteUsers } = await getTestUsers(5);
|
||||
const timeline = (await getTestStatuses(5, users[0])).toReversed();
|
||||
|
|
@ -2,10 +2,9 @@ import {
|
|||
Account as AccountSchema,
|
||||
RolePermission,
|
||||
} from "@versia/client/schemas";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import { apiRoute, auth, withUserParam } from "@versia-server/kit/api";
|
||||
import { describeRoute, resolver } from "hono-openapi";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.get(
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
||||
import { generateClient, getTestUsers } from "@versia-server/tests";
|
||||
|
||||
const { users, deleteUsers } = await getTestUsers(2);
|
||||
|
||||
|
|
@ -2,16 +2,20 @@ import {
|
|||
Relationship as RelationshipSchema,
|
||||
RolePermission,
|
||||
} from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { z } from "zod";
|
||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import {
|
||||
apiRoute,
|
||||
auth,
|
||||
handleZodError,
|
||||
withUserParam,
|
||||
} from "@versia-server/kit/api";
|
||||
import { Relationship } from "@versia-server/kit/db";
|
||||
import {
|
||||
RelationshipJobType,
|
||||
relationshipQueue,
|
||||
} from "~/classes/queues/relationships";
|
||||
} from "@versia-server/kit/queues/relationships";
|
||||
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||
import { z } from "zod";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
|
|
@ -51,7 +55,7 @@ export default apiRoute((app) =>
|
|||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
notifications: z.boolean().default(true).openapi({
|
||||
notifications: z.boolean().default(true).meta({
|
||||
description: "Mute notifications in addition to statuses?",
|
||||
}),
|
||||
duration: z
|
||||
|
|
@ -60,7 +64,7 @@ export default apiRoute((app) =>
|
|||
.min(0)
|
||||
.max(60 * 60 * 24 * 365 * 5)
|
||||
.default(0)
|
||||
.openapi({
|
||||
.meta({
|
||||
description:
|
||||
"How long the mute should last, in seconds. 0 means indefinite.",
|
||||
}),
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
||||
import { generateClient, getTestUsers } from "@versia-server/tests";
|
||||
|
||||
const { users, deleteUsers } = await getTestUsers(2);
|
||||
|
||||
|
|
@ -2,12 +2,16 @@ import {
|
|||
Relationship as RelationshipSchema,
|
||||
RolePermission,
|
||||
} from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver, validator } from "hono-openapi/zod";
|
||||
import { 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";
|
||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
|
|
@ -45,7 +49,7 @@ export default apiRoute((app) =>
|
|||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
comment: RelationshipSchema.shape.note.optional().openapi({
|
||||
comment: RelationshipSchema.shape.note.optional().meta({
|
||||
description:
|
||||
"The comment to be set on that user. Provide an empty string or leave out this parameter to clear the currently set note.",
|
||||
}),
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
||||
import { generateClient, getTestUsers } from "@versia-server/tests";
|
||||
|
||||
const { users, deleteUsers } = await getTestUsers(2);
|
||||
|
||||
|
|
@ -2,10 +2,9 @@ import {
|
|||
Relationship as RelationshipSchema,
|
||||
RolePermission,
|
||||
} from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { apiRoute, auth, withUserParam } from "@versia-server/kit/api";
|
||||
import { Relationship } from "@versia-server/kit/db";
|
||||
import { describeRoute, resolver } from "hono-openapi";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
|
|
@ -2,11 +2,10 @@ import {
|
|||
Account as AccountSchema,
|
||||
RolePermission,
|
||||
} from "@versia/client/schemas";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { User } from "~/classes/database/user";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import { apiRoute, auth, withUserParam } from "@versia-server/kit/api";
|
||||
import { User } from "@versia-server/kit/db";
|
||||
import { describeRoute, resolver } from "hono-openapi";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
||||
import { generateClient, getTestUsers } from "@versia-server/tests";
|
||||
|
||||
const { users, deleteUsers } = await getTestUsers(2);
|
||||
|
||||
|
|
@ -2,11 +2,10 @@ import {
|
|||
Relationship as RelationshipSchema,
|
||||
RolePermission,
|
||||
} from "@versia/client/schemas";
|
||||
import { Relationship } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { apiRoute, auth, withUserParam } from "@/api";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import { apiRoute, auth, withUserParam } from "@versia-server/kit/api";
|
||||
import { Relationship } from "@versia-server/kit/db";
|
||||
import { describeRoute, resolver } from "hono-openapi";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.post(
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Role } from "@versia/kit/db";
|
||||
import { Role } from "@versia-server/kit/db";
|
||||
import { generateClient, getTestUsers } from "@versia-server/tests";
|
||||
import { randomUUIDv7 } from "bun";
|
||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
||||
|
||||
const { users, deleteUsers } = await getTestUsers(2);
|
||||
let role: Role;
|
||||
|
|
@ -3,12 +3,16 @@ import {
|
|||
RolePermission,
|
||||
Role as RoleSchema,
|
||||
} from "@versia/client/schemas";
|
||||
import { Role } from "@versia/kit/db";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { validator } from "hono-openapi/zod";
|
||||
import { ApiError } from "@versia-server/kit";
|
||||
import {
|
||||
apiRoute,
|
||||
auth,
|
||||
handleZodError,
|
||||
withUserParam,
|
||||
} from "@versia-server/kit/api";
|
||||
import { Role } from "@versia-server/kit/db";
|
||||
import { describeRoute, validator } from "hono-openapi";
|
||||
import { z } from "zod";
|
||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
|
||||
export default apiRoute((app) => {
|
||||
app.post(
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { RolePermission } from "@versia/client/schemas";
|
||||
import { Role } from "@versia/kit/db";
|
||||
import { Role } from "@versia-server/kit/db";
|
||||
import { generateClient, getTestUsers } from "@versia-server/tests";
|
||||
import { randomUUIDv7 } from "bun";
|
||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
||||
|
||||
const { users, deleteUsers } = await getTestUsers(2);
|
||||
let role: Role;
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue