mirror of
https://github.com/versia-pub/server.git
synced 2025-12-08 09:18:19 +01:00
Compare commits
83 commits
v0.8.0-rc.
...
main
| 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 | ||
|
|
70974d3c35 | ||
|
|
99a7658956 |
|
|
@ -1,16 +1,18 @@
|
||||||
version = 1
|
version = 1
|
||||||
|
|
||||||
|
test_patterns = ["**/*.test.ts"]
|
||||||
|
|
||||||
[[analyzers]]
|
[[analyzers]]
|
||||||
name = "shell"
|
name = "shell"
|
||||||
|
|
||||||
[[analyzers]]
|
[[analyzers]]
|
||||||
name = "javascript"
|
name = "javascript"
|
||||||
|
|
||||||
[analyzers.meta]
|
[analyzers.meta]
|
||||||
environment = ["nodejs"]
|
environment = ["nodejs"]
|
||||||
|
|
||||||
[[analyzers]]
|
[[analyzers]]
|
||||||
name = "docker"
|
name = "docker"
|
||||||
|
|
||||||
[analyzers.meta]
|
[analyzers.meta]
|
||||||
dockerfile_paths = ["Dockerfile"]
|
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]
|
[logging]
|
||||||
|
|
||||||
# Available levels: debug, info, warning, error, fatal
|
# Available levels: trace, debug, info, warning, error, fatal
|
||||||
log_level = "debug"
|
log_level = "info" # For console output
|
||||||
|
|
||||||
log_file_path = "logs/versia.log"
|
|
||||||
|
|
||||||
[logging.types]
|
|
||||||
# Either pass a boolean
|
|
||||||
# requests = true
|
|
||||||
# Or a table with the following keys:
|
|
||||||
# requests_content = { level = "debug", log_file_path = "logs/requests.log" }
|
|
||||||
# Available types are: requests, responses, requests_content, filters
|
|
||||||
|
|
||||||
|
# [logging.file]
|
||||||
|
# path = "logs/versia.log"
|
||||||
|
# log_level = "info"
|
||||||
|
#
|
||||||
|
# [logging.file.rotation]
|
||||||
|
# max_size = 10_000_000 # 10 MB
|
||||||
|
# max_files = 10 # Keep 10 rotated files
|
||||||
|
#
|
||||||
# https://sentry.io support
|
# https://sentry.io support
|
||||||
# Uncomment to enable
|
|
||||||
# [logging.sentry]
|
# [logging.sentry]
|
||||||
# Sentry DSN for error logging
|
|
||||||
# dsn = "https://example.com"
|
# dsn = "https://example.com"
|
||||||
# debug = false
|
# debug = false
|
||||||
|
|
||||||
# sample_rate = 1.0
|
# sample_rate = 1.0
|
||||||
# traces_sample_rate = 1.0
|
# traces_sample_rate = 1.0
|
||||||
# Can also be regex
|
# Can also be regex
|
||||||
# trace_propagation_targets = []
|
# trace_propagation_targets = []
|
||||||
# max_breadcrumbs = 100
|
# max_breadcrumbs = 100
|
||||||
# environment = "production"
|
# environment = "production"
|
||||||
|
# log_level = "info"
|
||||||
|
|
||||||
[plugins]
|
[authentication]
|
||||||
# Whether to automatically load all plugins in the plugins directory
|
# Run Versia Server with this value missing to generate a new key
|
||||||
autoload = true
|
key = "ZWcwanRaQAqY3ChUro/Jey9XGQjzsxEed5iqTp4yFr8W6vEnXdz91F/Pu/uf7HBMbNeIK7V6aHsM0lq9onrO8Q=="
|
||||||
|
|
||||||
# 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"
|
|
||||||
|
|
||||||
# The provider MUST support OpenID Connect with .well-known discovery
|
# The provider MUST support OpenID Connect with .well-known discovery
|
||||||
# Most notably, GitHub does not support this
|
# 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
|
# 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):
|
# Authentik for example uses regex so it can be set to (regex):
|
||||||
# <base_url>/oauth/sso/<provider_id>/callback.*
|
# <base_url>/oauth/sso/<provider_id>/callback.*
|
||||||
# [[plugins.config."@versia/openid".providers]]
|
# [[authentication.openid_providers]]
|
||||||
# name = "CPlusPatch ID"
|
# name = "CPlusPatch ID"
|
||||||
# id = "cpluspatch-id"
|
# id = "cpluspatch-id"
|
||||||
# This MUST match the provider's issuer URI, including the trailing slash (or lack thereof)
|
# 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.
|
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
|
- name: Run typechecks
|
||||||
run: |
|
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:
|
tests:
|
||||||
uses: ./.github/workflows/tests.yml
|
uses: ./.github/workflows/tests.yml
|
||||||
|
|
||||||
|
detect-circular:
|
||||||
|
uses: ./.github/workflows/circular-imports.yml
|
||||||
|
|
||||||
build:
|
build:
|
||||||
if: ${{ success() }}
|
if: ${{ success() }}
|
||||||
needs: [lint, check, tests]
|
needs: [lint, check, tests, detect-circular]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
|
||||||
4
.github/workflows/docs.yml
vendored
4
.github/workflows/docs.yml
vendored
|
|
@ -35,12 +35,12 @@ jobs:
|
||||||
run: bun install
|
run: bun install
|
||||||
|
|
||||||
- name: Build with VitePress
|
- name: Build with VitePress
|
||||||
run: bun run docs:build
|
run: bun run --filter="@versia-server/api" docs:build
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-pages-artifact@v3
|
uses: actions/upload-pages-artifact@v3
|
||||||
with:
|
with:
|
||||||
path: docs/.vitepress/dist
|
path: packages/api/docs/.vitepress/dist
|
||||||
|
|
||||||
# Deployment job
|
# Deployment job
|
||||||
deploy:
|
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
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
- name: Install
|
- name: Install
|
||||||
run: bun install
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
- name: Configure .npmrc
|
|
||||||
working-directory: packages/${{ matrix.package }}
|
|
||||||
run: |
|
|
||||||
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > .npmrc
|
|
||||||
|
|
||||||
- name: Publish to NPM
|
- name: Publish to NPM
|
||||||
working-directory: packages/${{ matrix.package }}
|
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
|
- name: Publish to JSR
|
||||||
working-directory: packages/${{ matrix.package }}
|
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",
|
"cli",
|
||||||
"federation",
|
"federation",
|
||||||
"config",
|
"config",
|
||||||
"plugin",
|
|
||||||
"worker",
|
"worker",
|
||||||
"media"
|
"media",
|
||||||
|
"packages/client",
|
||||||
|
"packages/sdk"
|
||||||
],
|
],
|
||||||
"languageToolLinter.languageTool.ignoredWordsInWorkspace": ["versia"]
|
"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
|
# `0.8.0` • Federation 2: Electric Boogaloo
|
||||||
|
|
||||||
## Backwards Compatibility
|
## 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:
|
To scan for all TypeScript errors, run:
|
||||||
```sh
|
```sh
|
||||||
bun check
|
bun typecheck
|
||||||
```
|
```
|
||||||
|
|
||||||
### Commit messages
|
### Commit messages
|
||||||
|
|
@ -153,4 +153,4 @@ If you find a bug, please open an issue on GitHub. Please make sure to include t
|
||||||
|
|
||||||
# License
|
# License
|
||||||
|
|
||||||
Versia Server is licensed under the [AGPLv3 or later](https://www.gnu.org/licenses/agpl-3.0.en.html) license. By contributing to Versia, you agree to license your contributions under the same license.
|
Versia Server is licensed under the [AGPLv3 or later](https://www.gnu.org/licenses/agpl-3.0.en.html) license. By contributing to Versia, you agree to license your contributions under the same license.
|
||||||
|
|
|
||||||
15
Dockerfile
15
Dockerfile
|
|
@ -1,7 +1,5 @@
|
||||||
# Node is required for building the project
|
# Node is required for building the project
|
||||||
FROM imbios/bun-node:1-20-alpine AS base
|
FROM imbios/bun-node:latest-23-alpine AS base
|
||||||
|
|
||||||
RUN apk add --no-cache libstdc++
|
|
||||||
|
|
||||||
# Install dependencies into temp directory
|
# Install dependencies into temp directory
|
||||||
# This will cache them and speed up future builds
|
# This will cache them and speed up future builds
|
||||||
|
|
@ -22,20 +20,19 @@ COPY --from=install /temp/node_modules /temp/node_modules
|
||||||
|
|
||||||
# Build the project
|
# Build the project
|
||||||
WORKDIR /temp
|
WORKDIR /temp
|
||||||
RUN bun run build
|
RUN bun run build api
|
||||||
WORKDIR /temp/dist
|
WORKDIR /temp/dist
|
||||||
|
|
||||||
# Copy production dependencies and source code into final image
|
# Copy production dependencies and source code into final image
|
||||||
FROM oven/bun:1.2.13-alpine
|
FROM oven/bun:1.3.2-alpine
|
||||||
|
|
||||||
# Install libstdc++ for Bun and create app directory
|
# Install libstdc++ for Bun and create app directory
|
||||||
RUN apk add --no-cache libstdc++ && \
|
RUN mkdir -p /app
|
||||||
mkdir -p /app
|
|
||||||
|
|
||||||
COPY --from=build /temp/dist /app/dist
|
COPY --from=build /temp/dist /app/dist
|
||||||
COPY entrypoint.sh /app
|
COPY entrypoint.sh /app
|
||||||
|
|
||||||
LABEL org.opencontainers.image.authors="Gaspard Wierzbinski (https://cpluspatch.dev)"
|
LABEL org.opencontainers.image.authors="Gaspard Wierzbinski (https://cpluspatch.com)"
|
||||||
LABEL org.opencontainers.image.source="https://github.com/versia-pub/server"
|
LABEL org.opencontainers.image.source="https://github.com/versia-pub/server"
|
||||||
LABEL org.opencontainers.image.vendor="Versia Pub"
|
LABEL org.opencontainers.image.vendor="Versia Pub"
|
||||||
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
|
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
|
||||||
|
|
@ -51,4 +48,4 @@ WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENTRYPOINT [ "/bin/sh", "/app/entrypoint.sh" ]
|
ENTRYPOINT [ "/bin/sh", "/app/entrypoint.sh" ]
|
||||||
# Run migrations and start the server
|
# Run migrations and start the server
|
||||||
CMD [ "bun", "run", "index.js" ]
|
CMD [ "bun", "run", "api.js" ]
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@ The following extensions are currently supported or being worked on:
|
||||||
- `pub.versia:instance_messaging`: Instance Messaging
|
- `pub.versia:instance_messaging`: Instance Messaging
|
||||||
- `pub.versia:likes`: Likes
|
- `pub.versia:likes`: Likes
|
||||||
- `pub.versia:share`: Share
|
- `pub.versia:share`: Share
|
||||||
|
- `pub.versia:reactions`: Reactions
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
# Node is required for building the project
|
# Node is required for building the project
|
||||||
FROM imbios/bun-node:1-20-alpine AS base
|
FROM imbios/bun-node:latest-23-alpine AS base
|
||||||
|
|
||||||
RUN apk add --no-cache libstdc++
|
|
||||||
|
|
||||||
# Install dependencies into temp directory
|
# Install dependencies into temp directory
|
||||||
# This will cache them and speed up future builds
|
# This will cache them and speed up future builds
|
||||||
|
|
@ -22,20 +20,19 @@ COPY --from=install /temp/node_modules /temp/node_modules
|
||||||
|
|
||||||
# Build the project
|
# Build the project
|
||||||
WORKDIR /temp
|
WORKDIR /temp
|
||||||
RUN bun run build:worker
|
RUN bun run build worker
|
||||||
WORKDIR /temp/dist
|
WORKDIR /temp/dist
|
||||||
|
|
||||||
# Copy production dependencies and source code into final image
|
# Copy production dependencies and source code into final image
|
||||||
FROM oven/bun:1.2.13-alpine
|
FROM oven/bun:1.3.2-alpine
|
||||||
|
|
||||||
# Install libstdc++ for Bun and create app directory
|
# Install libstdc++ for Bun and create app directory
|
||||||
RUN apk add --no-cache libstdc++ && \
|
RUN mkdir -p /app
|
||||||
mkdir -p /app
|
|
||||||
|
|
||||||
COPY --from=build /temp/dist /app/dist
|
COPY --from=build /temp/dist /app/dist
|
||||||
COPY entrypoint.sh /app
|
COPY entrypoint.sh /app
|
||||||
|
|
||||||
LABEL org.opencontainers.image.authors="Gaspard Wierzbinski (https://cpluspatch.dev)"
|
LABEL org.opencontainers.image.authors="Gaspard Wierzbinski (https://cpluspatch.com)"
|
||||||
LABEL org.opencontainers.image.source="https://github.com/versia-pub/server"
|
LABEL org.opencontainers.image.source="https://github.com/versia-pub/server"
|
||||||
LABEL org.opencontainers.image.vendor="Versia Pub"
|
LABEL org.opencontainers.image.vendor="Versia Pub"
|
||||||
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
|
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
|
||||||
|
|
@ -47,7 +44,8 @@ ARG GIT_COMMIT
|
||||||
ENV GIT_COMMIT=$GIT_COMMIT
|
ENV GIT_COMMIT=$GIT_COMMIT
|
||||||
|
|
||||||
# CD to app
|
# CD to app
|
||||||
WORKDIR /app/dist
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
ENTRYPOINT [ "/bin/sh", "/app/entrypoint.sh" ]
|
||||||
# Run migrations and start the server
|
# Run migrations and start the server
|
||||||
CMD [ "bun", "run", "worker.js" ]
|
CMD [ "bun", "run", "worker.js" ]
|
||||||
|
|
|
||||||
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 type { Status } from "@versia/client/schemas";
|
||||||
|
import {
|
||||||
|
fakeRequest,
|
||||||
|
getTestStatuses,
|
||||||
|
getTestUsers,
|
||||||
|
} from "@versia-server/tests";
|
||||||
import { bench, run } from "mitata";
|
import { bench, run } from "mitata";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { configureLoggers } from "@/loggers";
|
|
||||||
import { fakeRequest, getTestStatuses, getTestUsers } from "~/tests/utils";
|
|
||||||
|
|
||||||
await configureLoggers(true);
|
|
||||||
|
|
||||||
const { users, tokens, deleteUsers } = await getTestUsers(5);
|
const { users, tokens, deleteUsers } = await getTestUsers(5);
|
||||||
await getTestStatuses(40, users[0]);
|
await getTestStatuses(40, users[0]);
|
||||||
|
|
|
||||||
99
biome.json
99
biome.json
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.0.0-beta.1/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.3.4/schema.json",
|
||||||
"assist": {
|
"assist": {
|
||||||
"actions": {
|
"actions": {
|
||||||
"source": {
|
"source": {
|
||||||
|
|
@ -7,6 +7,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"vcs": {
|
||||||
|
"clientKind": "git",
|
||||||
|
"enabled": true,
|
||||||
|
"useIgnoreFile": true
|
||||||
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
|
|
@ -44,7 +49,6 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"useLiteralEnumMembers": "error",
|
"useLiteralEnumMembers": "error",
|
||||||
"noCommaOperator": "error",
|
|
||||||
"noNegationElse": "error",
|
"noNegationElse": "error",
|
||||||
"noYodaExpression": "error",
|
"noYodaExpression": "error",
|
||||||
"useBlockStatements": "error",
|
"useBlockStatements": "error",
|
||||||
|
|
@ -64,24 +68,47 @@
|
||||||
"useThrowOnlyError": "error",
|
"useThrowOnlyError": "error",
|
||||||
"useNodejsImportProtocol": "error",
|
"useNodejsImportProtocol": "error",
|
||||||
"useAsConstAssertion": "error",
|
"useAsConstAssertion": "error",
|
||||||
"useNumericLiterals": "error",
|
|
||||||
"useEnumInitializers": "error",
|
"useEnumInitializers": "error",
|
||||||
"useSelfClosingElements": "error",
|
"useSelfClosingElements": "error",
|
||||||
"useConst": "error",
|
"useConst": "error",
|
||||||
"useSingleVarDeclarator": "error",
|
"useSingleVarDeclarator": "error",
|
||||||
"noUnusedTemplateLiteral": "error",
|
"noUnusedTemplateLiteral": "error",
|
||||||
"useNumberNamespace": "error",
|
"useNumberNamespace": "error",
|
||||||
|
"useAtIndex": "warn",
|
||||||
"noInferrableTypes": "error",
|
"noInferrableTypes": "error",
|
||||||
|
"useCollapsedIf": "warn",
|
||||||
"useExponentiationOperator": "error",
|
"useExponentiationOperator": "error",
|
||||||
"useTemplate": "error",
|
"useTemplate": "error",
|
||||||
"noParameterAssign": "error",
|
"noParameterAssign": "error",
|
||||||
"noNonNullAssertion": "error",
|
"noNonNullAssertion": "error",
|
||||||
"useDefaultParameterLast": "error",
|
"useDefaultParameterLast": "error",
|
||||||
"noArguments": "error",
|
"useConsistentMemberAccessibility": {
|
||||||
|
"level": "warn",
|
||||||
|
"options": {
|
||||||
|
"accessibility": "explicit"
|
||||||
|
}
|
||||||
|
},
|
||||||
"useImportType": "error",
|
"useImportType": "error",
|
||||||
"useExportType": "error",
|
"useExportType": "error",
|
||||||
"noUselessElse": "error",
|
"noUselessElse": "error",
|
||||||
"useShorthandFunctionType": "error"
|
"noProcessEnv": "error",
|
||||||
|
"useShorthandFunctionType": "error",
|
||||||
|
"useArrayLiterals": "error",
|
||||||
|
"noCommonJs": "warn",
|
||||||
|
"noExportedImports": "warn",
|
||||||
|
"noSubstr": "warn",
|
||||||
|
"useTrimStartEnd": "warn",
|
||||||
|
"noRestrictedImports": {
|
||||||
|
"options": {
|
||||||
|
"paths": {
|
||||||
|
"~/packages/": "Use the appropriate package instead of importing from the packages directory directly."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"level": "error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"performance": {
|
||||||
|
"noDynamicNamespaceImportAccess": "warn"
|
||||||
},
|
},
|
||||||
"correctness": {
|
"correctness": {
|
||||||
"useImportExtensions": "error",
|
"useImportExtensions": "error",
|
||||||
|
|
@ -89,57 +116,34 @@
|
||||||
"noUndeclaredDependencies": "error",
|
"noUndeclaredDependencies": "error",
|
||||||
"noUnusedFunctionParameters": "error",
|
"noUnusedFunctionParameters": "error",
|
||||||
"noUnusedImports": "error",
|
"noUnusedImports": "error",
|
||||||
"noUnusedPrivateClassMembers": "error",
|
"noUnusedPrivateClassMembers": "error"
|
||||||
"useArrayLiterals": "error"
|
|
||||||
},
|
},
|
||||||
"nursery": {
|
"nursery": {
|
||||||
"noBitwiseOperators": "error",
|
"noFloatingPromises": "error"
|
||||||
"noConstantBinaryExpression": "error",
|
|
||||||
"noFloatingPromises": "error",
|
|
||||||
"noGlobalDirnameFilename": "error",
|
|
||||||
"noOctalEscape": "error",
|
|
||||||
"noProcessEnv": "error",
|
|
||||||
"noDuplicateElseIf": "warn",
|
|
||||||
"noProcessGlobal": "warn",
|
|
||||||
"noTsIgnore": "warn",
|
|
||||||
"useAtIndex": "warn",
|
|
||||||
"useCollapsedIf": "warn",
|
|
||||||
"useConsistentObjectDefinition": {
|
|
||||||
"level": "warn",
|
|
||||||
"options": {
|
|
||||||
"syntax": "shorthand"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"useConsistentMemberAccessibility": {
|
|
||||||
"level": "warn",
|
|
||||||
"options": {
|
|
||||||
"accessibility": "explicit"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"useParseIntRadix": "warn",
|
|
||||||
"noCommonJs": "warn",
|
|
||||||
"noDynamicNamespaceImportAccess": "warn",
|
|
||||||
"noExportedImports": "warn",
|
|
||||||
"noIrregularWhitespace": "warn",
|
|
||||||
"noSubstr": "warn",
|
|
||||||
"noTemplateCurlyInString": "warn",
|
|
||||||
"noUselessEscapeInRegex": "warn",
|
|
||||||
"noUselessStringRaw": "warn",
|
|
||||||
"useAdjacentOverloadSignatures": "warn",
|
|
||||||
"useGuardForIn": "warn",
|
|
||||||
"useTrimStartEnd": "warn"
|
|
||||||
},
|
},
|
||||||
"complexity": {
|
"complexity": {
|
||||||
"noForEach": "error",
|
"noForEach": "error",
|
||||||
|
"noImportantStyles": "off",
|
||||||
"noUselessStringConcat": "error",
|
"noUselessStringConcat": "error",
|
||||||
"useDateNow": "error",
|
"useDateNow": "error",
|
||||||
|
"noUselessStringRaw": "warn",
|
||||||
|
"noUselessEscapeInRegex": "warn",
|
||||||
"useSimplifiedLogicExpression": "error",
|
"useSimplifiedLogicExpression": "error",
|
||||||
"useWhile": "error"
|
"useWhile": "error",
|
||||||
|
"useNumericLiterals": "error",
|
||||||
|
"noArguments": "error",
|
||||||
|
"noCommaOperator": "error"
|
||||||
},
|
},
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
"noDuplicateTestHooks": "error",
|
"noDuplicateTestHooks": "error",
|
||||||
|
"noOctalEscape": "error",
|
||||||
|
"noTemplateCurlyInString": "warn",
|
||||||
"noEmptyBlockStatements": "error",
|
"noEmptyBlockStatements": "error",
|
||||||
|
"useAdjacentOverloadSignatures": "warn",
|
||||||
|
"useGuardForIn": "warn",
|
||||||
|
"noDuplicateElseIf": "warn",
|
||||||
"noEvolvingTypes": "error",
|
"noEvolvingTypes": "error",
|
||||||
|
"noIrregularWhitespace": "warn",
|
||||||
"noExportsInTest": "error",
|
"noExportsInTest": "error",
|
||||||
"noVar": "error",
|
"noVar": "error",
|
||||||
"useAwait": "error",
|
"useAwait": "error",
|
||||||
|
|
@ -169,13 +173,6 @@
|
||||||
"globals": ["HTMLRewriter", "BufferEncoding"]
|
"globals": ["HTMLRewriter", "BufferEncoding"]
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"includes": [
|
"includes": ["**"]
|
||||||
"**",
|
|
||||||
"!**/node_modules",
|
|
||||||
"!**/dist",
|
|
||||||
"!**/cache",
|
|
||||||
"!**/build",
|
|
||||||
"!**/result"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 process from "node:process";
|
||||||
import { $, build } from "bun";
|
import { $, build, file, write } from "bun";
|
||||||
import { routes } from "~/routes";
|
import manifest from "./package.json" with { type: "json" };
|
||||||
|
|
||||||
console.log("Building...");
|
console.log("Building...");
|
||||||
|
|
||||||
await $`rm -rf dist && mkdir dist`;
|
await $`rm -rf dist && mkdir dist`;
|
||||||
|
|
||||||
// Get all directories under the plugins/ directory
|
const type = process.argv[2] as "api" | "worker";
|
||||||
const pluginDirs = await readdir("plugins", { withFileTypes: true });
|
|
||||||
|
if (type !== "api" && type !== "worker") {
|
||||||
|
throw new Error("Invalid build type. Use 'api' or 'worker'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const packages = Object.keys(manifest.dependencies)
|
||||||
|
.filter((dep) => dep.startsWith("@versia"))
|
||||||
|
.filter((dep) => dep !== "@versia-server/tests");
|
||||||
|
|
||||||
await build({
|
await build({
|
||||||
entrypoints: [
|
entrypoints: [`./${type}.ts`],
|
||||||
"index.ts",
|
|
||||||
// HACK: Include to avoid cyclical import errors
|
|
||||||
"config.ts",
|
|
||||||
"cli/index.ts",
|
|
||||||
// Force Bun to include endpoints
|
|
||||||
...Object.values(routes),
|
|
||||||
// Include all plugins
|
|
||||||
...pluginDirs
|
|
||||||
.filter((dir) => dir.isDirectory())
|
|
||||||
.map((dir) => `plugins/${dir.name}/index.ts`),
|
|
||||||
],
|
|
||||||
outdir: "dist",
|
outdir: "dist",
|
||||||
target: "bun",
|
target: "bun",
|
||||||
splitting: true,
|
splitting: true,
|
||||||
minify: false,
|
minify: true,
|
||||||
external: ["acorn", "@bull-board/ui"],
|
external: [...packages],
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("Copying files...");
|
console.log("Copying files...");
|
||||||
|
|
||||||
// Copy Drizzle migrations to dist
|
// Copy each package into dist/node_modules
|
||||||
await $`cp -r drizzle dist/drizzle`;
|
for (const pkg of packages) {
|
||||||
|
const directory = pkg.split("/")[1] || pkg;
|
||||||
|
await $`mkdir -p dist/node_modules/${pkg}`;
|
||||||
|
// Copy the built package files
|
||||||
|
await $`cp -rL packages/${directory}/{dist,package.json} dist/node_modules/${pkg}`;
|
||||||
|
|
||||||
// Copy plugin manifests
|
// Rewrite package.json "exports" field to point to the dist directory and use .js extension
|
||||||
await $`cp plugins/openid/manifest.json dist/plugins/openid/manifest.json`;
|
const packageJsonPath = `dist/node_modules/${pkg}/package.json`;
|
||||||
|
const packageJson = await file(packageJsonPath).json();
|
||||||
await $`mkdir -p dist/node_modules`;
|
for (const [key, value] of Object.entries(packageJson.exports) as [
|
||||||
|
string,
|
||||||
// Copy Sharp to dist
|
{ import?: string },
|
||||||
await $`mkdir -p dist/node_modules/@img`;
|
][]) {
|
||||||
await $`cp -rL node_modules/@img/sharp-libvips-linux* dist/node_modules/@img`;
|
if (value.import) {
|
||||||
await $`cp -rL node_modules/@img/sharp-linux* dist/node_modules/@img`;
|
packageJson.exports[key] = {
|
||||||
|
import: value.import
|
||||||
// Copy acorn to dist
|
.replace("./", "./dist/")
|
||||||
await $`cp -rL node_modules/acorn dist/node_modules/acorn`;
|
.replace(/\.ts$/, ".js"),
|
||||||
|
};
|
||||||
// Copy bull-board to dist
|
}
|
||||||
await $`mkdir -p dist/node_modules/@bull-board`;
|
}
|
||||||
await $`cp -rL node_modules/@bull-board/ui dist/node_modules/@bull-board/ui`;
|
await write(packageJsonPath, JSON.stringify(packageJson, null, 4));
|
||||||
|
}
|
||||||
// Copy the Bee Movie script from pages
|
|
||||||
await $`cp beemovie.txt dist/beemovie.txt`;
|
|
||||||
|
|
||||||
// Copy package.json
|
|
||||||
await $`cp package.json dist/package.json`;
|
|
||||||
|
|
||||||
// Fixes issues with sharp
|
|
||||||
await $`cp -rL node_modules/detect-libc dist/node_modules/`;
|
|
||||||
|
|
||||||
console.log("Build complete!");
|
console.log("Build complete!");
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,7 @@
|
||||||
"@jsr" = "https://npm.jsr.io"
|
"@jsr" = "https://npm.jsr.io"
|
||||||
|
|
||||||
[test]
|
[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 { describe, expect, it } from "bun:test";
|
||||||
|
import { mockModule } from "@versia-server/tests";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { mockModule } from "~/tests/utils.ts";
|
|
||||||
import { calculateBlurhash } from "./blurhash.ts";
|
import { calculateBlurhash } from "./blurhash.ts";
|
||||||
|
|
||||||
describe("BlurhashPreprocessor", () => {
|
describe("BlurhashPreprocessor", () => {
|
||||||
|
|
@ -16,7 +16,7 @@ describe("BlurhashPreprocessor", () => {
|
||||||
.png()
|
.png()
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|
||||||
const inputFile = new File([inputBuffer], "test.png", {
|
const inputFile = new File([inputBuffer as BlobPart], "test.png", {
|
||||||
type: "image/png",
|
type: "image/png",
|
||||||
});
|
});
|
||||||
const result = await calculateBlurhash(inputFile);
|
const result = await calculateBlurhash(inputFile);
|
||||||
|
|
@ -46,7 +46,7 @@ describe("BlurhashPreprocessor", () => {
|
||||||
.png()
|
.png()
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|
||||||
const inputFile = new File([inputBuffer], "test.png", {
|
const inputFile = new File([inputBuffer as BlobPart], "test.png", {
|
||||||
type: "image/png",
|
type: "image/png",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ describe("ImageConversionPreprocessor", () => {
|
||||||
.jpeg()
|
.jpeg()
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|
||||||
const inputFile = new File([inputBuffer], "test.jpg", {
|
const inputFile = new File([inputBuffer as BlobPart], "test.jpg", {
|
||||||
type: "image/jpeg",
|
type: "image/jpeg",
|
||||||
});
|
});
|
||||||
const result = await convertImage(inputFile, "image/webp");
|
const result = await convertImage(inputFile, "image/webp");
|
||||||
|
|
@ -74,7 +74,7 @@ describe("ImageConversionPreprocessor", () => {
|
||||||
.png()
|
.png()
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|
||||||
const inputFile = new File([inputBuffer], "test.png", {
|
const inputFile = new File([inputBuffer as BlobPart], "test.png", {
|
||||||
type: "image/png",
|
type: "image/png",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -96,7 +96,7 @@ describe("ImageConversionPreprocessor", () => {
|
||||||
.gif()
|
.gif()
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|
||||||
const inputFile = new File([inputBuffer], "animated.gif", {
|
const inputFile = new File([inputBuffer as BlobPart], "animated.gif", {
|
||||||
type: "image/gif",
|
type: "image/gif",
|
||||||
});
|
});
|
||||||
const result = await convertImage(inputFile, "image/webp");
|
const result = await convertImage(inputFile, "image/webp");
|
||||||
|
|
@ -122,7 +122,7 @@ describe("ImageConversionPreprocessor", () => {
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|
||||||
const inputFile = new File(
|
const inputFile = new File(
|
||||||
[inputBuffer],
|
[inputBuffer as BlobPart],
|
||||||
"test image with spaces.png",
|
"test image with spaces.png",
|
||||||
{ type: "image/png" },
|
{ type: "image/png" },
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ export const convertImage = async (
|
||||||
const convertedBuffer = await sharpCommand[commandName]().toBuffer();
|
const convertedBuffer = await sharpCommand[commandName]().toBuffer();
|
||||||
|
|
||||||
return new File(
|
return new File(
|
||||||
[convertedBuffer],
|
[convertedBuffer as BlobPart],
|
||||||
getReplacedFileName(file.name, commandName),
|
getReplacedFileName(file.name, commandName),
|
||||||
{
|
{
|
||||||
type: targetFormat,
|
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,259 +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);
|
|
||||||
}
|
|
||||||
} 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 { helpPlugin } from "@clerc/plugin-help";
|
||||||
import { notFoundPlugin } from "@clerc/plugin-not-found";
|
import { notFoundPlugin } from "@clerc/plugin-not-found";
|
||||||
import { versionPlugin } from "@clerc/plugin-version";
|
import { versionPlugin } from "@clerc/plugin-version";
|
||||||
|
import { setupDatabase } from "@versia-server/kit/db";
|
||||||
|
import { searchManager } from "@versia-server/kit/search";
|
||||||
import { Clerc } from "clerc";
|
import { Clerc } from "clerc";
|
||||||
import { searchManager } from "~/classes/search/search-manager.ts";
|
import pkg from "../package.json" with { type: "json" };
|
||||||
import { setupDatabase } from "~/drizzle/db.ts";
|
|
||||||
import pkg from "~/package.json";
|
|
||||||
import { rebuildIndexCommand } from "./index/rebuild.ts";
|
import { rebuildIndexCommand } from "./index/rebuild.ts";
|
||||||
import { refetchInstanceCommand } from "./instance/refetch.ts";
|
import { refetchInstanceCommand } from "./instance/refetch.ts";
|
||||||
import { createUserCommand } from "./user/create.ts";
|
import { createUserCommand } from "./user/create.ts";
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
|
import { config } from "@versia-server/config";
|
||||||
|
import { SonicIndexType, searchManager } from "@versia-server/kit/search";
|
||||||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||||
import { defineCommand, type Root } from "clerc";
|
import { defineCommand, type Root } from "clerc";
|
||||||
import ora from "ora";
|
import ora from "ora";
|
||||||
import {
|
|
||||||
SonicIndexType,
|
|
||||||
searchManager,
|
|
||||||
} from "~/classes/search/search-manager.ts";
|
|
||||||
import { config } from "~/config.ts";
|
|
||||||
|
|
||||||
export const rebuildIndexCommand = defineCommand(
|
export const rebuildIndexCommand = defineCommand(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
|
import { Instance } from "@versia-server/kit/db";
|
||||||
|
import { FetchJobType, fetchQueue } from "@versia-server/kit/queues/fetch";
|
||||||
|
import { Instances } from "@versia-server/kit/tables";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||||
import { defineCommand, type Root } from "clerc";
|
import { defineCommand, type Root } from "clerc";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { Instance } from "~/classes/database/instance.ts";
|
|
||||||
import { FetchJobType, fetchQueue } from "~/classes/queues/fetch.ts";
|
|
||||||
import { Instances } from "~/drizzle/schema.ts";
|
|
||||||
|
|
||||||
export const refetchInstanceCommand = defineCommand(
|
export const refetchInstanceCommand = defineCommand(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
|
import { config } from "@versia-server/config";
|
||||||
|
import { User } from "@versia-server/kit/db";
|
||||||
|
import { searchManager } from "@versia-server/kit/search";
|
||||||
|
import { Users } from "@versia-server/kit/tables";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||||
import { defineCommand, type Root } from "clerc";
|
import { defineCommand, type Root } from "clerc";
|
||||||
import { and, eq, isNull } from "drizzle-orm";
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
import { renderUnicodeCompact } from "uqr";
|
import { renderUnicodeCompact } from "uqr";
|
||||||
import { User } from "~/classes/database/user";
|
|
||||||
import { config } from "~/config";
|
|
||||||
import { Users } from "~/drizzle/schema";
|
|
||||||
|
|
||||||
export const createUserCommand = defineCommand(
|
export const createUserCommand = defineCommand(
|
||||||
{
|
{
|
||||||
|
|
@ -54,6 +55,9 @@ export const createUserCommand = defineCommand(
|
||||||
isAdmin: admin,
|
isAdmin: admin,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add to search index
|
||||||
|
await searchManager.addUser(user);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error("Failed to create user.");
|
throw new Error("Failed to create user.");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
|
import { User } from "@versia-server/kit/db";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||||
import { defineCommand, type Root } from "clerc";
|
import { defineCommand, type Root } from "clerc";
|
||||||
import ora from "ora";
|
import ora from "ora";
|
||||||
import { User } from "~/classes/database/user.ts";
|
|
||||||
import { retrieveUser } from "../utils.ts";
|
import { retrieveUser } from "../utils.ts";
|
||||||
|
|
||||||
export const refetchUserCommand = defineCommand(
|
export const refetchUserCommand = defineCommand(
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
|
import { Client, Token } from "@versia-server/kit/db";
|
||||||
import { randomUUIDv7 } from "bun";
|
import { randomUUIDv7 } from "bun";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||||
import { defineCommand, type Root } from "clerc";
|
import { defineCommand, type Root } from "clerc";
|
||||||
import { randomString } from "@/math.ts";
|
import { randomString } from "@/math.ts";
|
||||||
import { Token } from "~/classes/database/token.ts";
|
|
||||||
import { retrieveUser } from "../utils.ts";
|
import { retrieveUser } from "../utils.ts";
|
||||||
|
|
||||||
export const generateTokenCommand = defineCommand(
|
export const generateTokenCommand = defineCommand(
|
||||||
|
|
@ -22,13 +22,24 @@ export const generateTokenCommand = defineCommand(
|
||||||
throw new Error(`User ${chalk.gray(username)} not found.`);
|
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({
|
const token = await Token.insert({
|
||||||
id: randomUUIDv7(),
|
id: randomUUIDv7(),
|
||||||
accessToken: randomString(64, "base64url"),
|
accessToken: randomString(64, "base64url"),
|
||||||
code: null,
|
scopes: ["read", "write", "follow"],
|
||||||
scope: "read write follow",
|
|
||||||
tokenType: "Bearer",
|
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
clientId: application.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.info(
|
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 { and, eq, isNull } from "drizzle-orm";
|
||||||
import { parseUserAddress } from "@/api";
|
|
||||||
import { Instance } from "~/classes/database/instance";
|
|
||||||
import { User } from "~/classes/database/user";
|
|
||||||
import { Users } from "~/drizzle/schema";
|
|
||||||
|
|
||||||
export const retrieveUser = async (
|
export const retrieveUser = async (
|
||||||
usernameOrHandle: string,
|
usernameOrHandle: string,
|
||||||
|
|
|
||||||
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]
|
[logging]
|
||||||
|
|
||||||
# Available levels: debug, info, warning, error, fatal
|
# Available levels: trace, debug, info, warning, error, fatal
|
||||||
log_level = "debug"
|
log_level = "info" # For console output
|
||||||
|
|
||||||
log_file_path = "logs/versia.log"
|
|
||||||
|
|
||||||
[logging.types]
|
|
||||||
# Either pass a boolean
|
|
||||||
# requests = true
|
|
||||||
# Or a table with the following keys:
|
|
||||||
# requests_content = { level = "debug", log_file_path = "logs/requests.log" }
|
|
||||||
# Available types are: requests, responses, requests_content, filters
|
|
||||||
|
|
||||||
|
# [logging.file]
|
||||||
|
# path = "logs/versia.log"
|
||||||
|
# log_level = "info"
|
||||||
|
#
|
||||||
|
# [logging.file.rotation]
|
||||||
|
# max_size = 10_000_000 # 10 MB
|
||||||
|
# max_files = 10 # Keep 10 rotated files
|
||||||
|
#
|
||||||
# https://sentry.io support
|
# https://sentry.io support
|
||||||
# Uncomment to enable
|
|
||||||
# [logging.sentry]
|
# [logging.sentry]
|
||||||
# Sentry DSN for error logging
|
|
||||||
# dsn = "https://example.com"
|
# dsn = "https://example.com"
|
||||||
# debug = false
|
# debug = false
|
||||||
|
|
||||||
# sample_rate = 1.0
|
# sample_rate = 1.0
|
||||||
# traces_sample_rate = 1.0
|
# traces_sample_rate = 1.0
|
||||||
# Can also be regex
|
# Can also be regex
|
||||||
# trace_propagation_targets = []
|
# trace_propagation_targets = []
|
||||||
# max_breadcrumbs = 100
|
# max_breadcrumbs = 100
|
||||||
# environment = "production"
|
# environment = "production"
|
||||||
|
# log_level = "info"
|
||||||
|
|
||||||
[plugins]
|
[authentication]
|
||||||
# Whether to automatically load all plugins in the plugins directory
|
# Run Versia Server with this value missing to generate a new key
|
||||||
autoload = true
|
# key = ""
|
||||||
|
|
||||||
# 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 = ""
|
|
||||||
|
|
||||||
# The provider MUST support OpenID Connect with .well-known discovery
|
# The provider MUST support OpenID Connect with .well-known discovery
|
||||||
# Most notably, GitHub does not support this
|
# 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
|
# 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):
|
# Authentik for example uses regex so it can be set to (regex):
|
||||||
# <base_url>/oauth/sso/<provider_id>/callback.*
|
# <base_url>/oauth/sso/<provider_id>/callback.*
|
||||||
# [[plugins.config."@versia/openid".providers]]
|
# [[authentication.openid_providers]]
|
||||||
# name = "CPlusPatch ID"
|
# name = "CPlusPatch ID"
|
||||||
# id = "cpluspatch-id"
|
# id = "cpluspatch-id"
|
||||||
# This MUST match the provider's issuer URI, including the trailing slash (or lack thereof)
|
# This MUST match the provider's issuer URI, including the trailing slash (or lack thereof)
|
||||||
|
|
|
||||||
|
|
@ -57,27 +57,14 @@
|
||||||
"default": "versia"
|
"default": "versia"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["host", "username"],
|
||||||
"host",
|
|
||||||
"port",
|
|
||||||
"username",
|
|
||||||
"password",
|
|
||||||
"database"
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"description": "Additional read-only replicas",
|
"description": "Additional read-only replicas",
|
||||||
"default": []
|
"default": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["username"],
|
||||||
"host",
|
|
||||||
"port",
|
|
||||||
"username",
|
|
||||||
"password",
|
|
||||||
"database",
|
|
||||||
"replicas"
|
|
||||||
],
|
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"description": "PostgreSQL database configuration"
|
"description": "PostgreSQL database configuration"
|
||||||
},
|
},
|
||||||
|
|
@ -106,7 +93,6 @@
|
||||||
"default": 0
|
"default": 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["host", "port", "password", "database"],
|
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"description": "A Redis database used for managing queues."
|
"description": "A Redis database used for managing queues."
|
||||||
},
|
},
|
||||||
|
|
@ -132,12 +118,11 @@
|
||||||
"default": 1
|
"default": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["host", "port", "password", "database"],
|
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"description": "A Redis database used for caching SQL queries. Optional."
|
"description": "A Redis database used for caching SQL queries. Optional."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["queue", "cache"],
|
"required": ["queue"],
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"description": "Redis configuration. Used for queues and caching."
|
"description": "Redis configuration. Used for queues and caching."
|
||||||
},
|
},
|
||||||
|
|
@ -165,12 +150,11 @@
|
||||||
"$ref": "#/properties/postgres/properties/password"
|
"$ref": "#/properties/postgres/properties/password"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["host", "port", "password"],
|
"required": ["password"],
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"description": "Sonic database configuration"
|
"description": "Sonic database configuration"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["enabled", "sonic"],
|
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"description": "Search and indexing configuration"
|
"description": "Search and indexing configuration"
|
||||||
},
|
},
|
||||||
|
|
@ -191,7 +175,6 @@
|
||||||
"description": "Message to show to users when registration is disabled"
|
"description": "Message to show to users when registration is disabled"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["allow", "require_approval", "message"],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"http": {
|
"http": {
|
||||||
|
|
@ -250,20 +233,12 @@
|
||||||
"description": "This value must be a file path"
|
"description": "This value must be a file path"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["key", "cert", "passphrase", "ca"],
|
"required": ["key", "cert"],
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"description": "TLS configuration. You should probably be using a reverse proxy instead of this"
|
"description": "TLS configuration. You should probably be using a reverse proxy instead of this"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["base_url"],
|
||||||
"base_url",
|
|
||||||
"bind",
|
|
||||||
"bind_port",
|
|
||||||
"banned_ips",
|
|
||||||
"banned_user_agents",
|
|
||||||
"proxy_address",
|
|
||||||
"tls"
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"frontend": {
|
"frontend": {
|
||||||
|
|
@ -275,7 +250,7 @@
|
||||||
},
|
},
|
||||||
"path": {
|
"path": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "/home/jessew/Dev/versia-server"
|
"default": "frontend"
|
||||||
},
|
},
|
||||||
"routes": {
|
"routes": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
@ -302,13 +277,6 @@
|
||||||
"default": "/oauth/reset"
|
"default": "/oauth/reset"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
|
||||||
"home",
|
|
||||||
"login",
|
|
||||||
"consent",
|
|
||||||
"register",
|
|
||||||
"password_reset"
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|
@ -317,7 +285,7 @@
|
||||||
"default": {}
|
"default": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["enabled", "path", "routes", "settings"],
|
"required": ["routes"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"email": {
|
"email": {
|
||||||
|
|
@ -351,17 +319,10 @@
|
||||||
"default": true
|
"default": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["server", "username"],
|
||||||
"server",
|
|
||||||
"port",
|
|
||||||
"username",
|
|
||||||
"password",
|
|
||||||
"tls"
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["send_emails", "smtp"],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"media": {
|
"media": {
|
||||||
|
|
@ -393,15 +354,10 @@
|
||||||
"default": false
|
"default": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
|
||||||
"convert_images",
|
|
||||||
"convert_to",
|
|
||||||
"convert_vectors"
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["backend", "uploads_path", "conversion"],
|
"required": ["conversion"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"s3": {
|
"s3": {
|
||||||
|
|
@ -425,14 +381,19 @@
|
||||||
"public_url": {
|
"public_url": {
|
||||||
"$ref": "#/properties/http/properties/base_url",
|
"$ref": "#/properties/http/properties/base_url",
|
||||||
"description": "Public URL that uploaded media will be accessible at"
|
"description": "Public URL that uploaded media will be accessible at"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"path_style": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"endpoint",
|
"endpoint",
|
||||||
"access_key",
|
"access_key",
|
||||||
"secret_access_key",
|
"secret_access_key",
|
||||||
"region",
|
|
||||||
"bucket_name",
|
|
||||||
"public_url"
|
"public_url"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
|
@ -518,18 +479,6 @@
|
||||||
"default": 20
|
"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
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
|
|
@ -570,11 +519,6 @@
|
||||||
"default": 16
|
"default": 16
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
|
||||||
"max_characters",
|
|
||||||
"allowed_url_schemes",
|
|
||||||
"max_attachments"
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"media": {
|
"media": {
|
||||||
|
|
@ -1838,11 +1782,6 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
|
||||||
"max_bytes",
|
|
||||||
"max_description_characters",
|
|
||||||
"allowed_mime_types"
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"emojis": {
|
"emojis": {
|
||||||
|
|
@ -1864,11 +1803,6 @@
|
||||||
"default": 1000
|
"default": 1000
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
|
||||||
"max_bytes",
|
|
||||||
"max_shortcode_characters",
|
|
||||||
"max_description_characters"
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"polls": {
|
"polls": {
|
||||||
|
|
@ -1895,12 +1829,6 @@
|
||||||
"default": 8640000
|
"default": 8640000
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
|
||||||
"max_options",
|
|
||||||
"max_option_characters",
|
|
||||||
"min_duration_seconds",
|
|
||||||
"max_duration_seconds"
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"emails": {
|
"emails": {
|
||||||
|
|
@ -1919,7 +1847,6 @@
|
||||||
"default": []
|
"default": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["disallow_tempmail", "disallowed_domains"],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"challenges": {
|
"challenges": {
|
||||||
|
|
@ -1940,7 +1867,7 @@
|
||||||
"description": "You can use PATH:/path/to/file to load this value from a file"
|
"description": "You can use PATH:/path/to/file to load this value from a file"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["difficulty", "expiration", "key"],
|
"required": ["key"],
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"description": "CAPTCHA challenge configuration. Challenges are disabled if not provided."
|
"description": "CAPTCHA challenge configuration. Challenges are disabled if not provided."
|
||||||
},
|
},
|
||||||
|
|
@ -1983,13 +1910,6 @@
|
||||||
"default": []
|
"default": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
|
||||||
"note_content",
|
|
||||||
"emoji_shortcode",
|
|
||||||
"username",
|
|
||||||
"displayname",
|
|
||||||
"bio"
|
|
||||||
],
|
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"description": "Block content that matches these regular expressions"
|
"description": "Block content that matches these regular expressions"
|
||||||
}
|
}
|
||||||
|
|
@ -2001,7 +1921,6 @@
|
||||||
"emojis",
|
"emojis",
|
||||||
"polls",
|
"polls",
|
||||||
"emails",
|
"emails",
|
||||||
"challenges",
|
|
||||||
"filters"
|
"filters"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
|
@ -2016,13 +1935,14 @@
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"public": {
|
"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": {
|
"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
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"subject": {
|
"subject": {
|
||||||
|
|
@ -2030,12 +1950,11 @@
|
||||||
"description": "Subject field embedded in the push notification. Example: 'mailto:contact@example.com'"
|
"description": "Subject field embedded in the push notification. Example: 'mailto:contact@example.com'"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["vapid_keys", "subject"],
|
"required": ["vapid_keys"],
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"description": "Web Push Notifications configuration. Leave out to disable."
|
"description": "Web Push Notifications configuration. Leave out to disable."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["push"],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"defaults": {
|
"defaults": {
|
||||||
|
|
@ -2062,13 +1981,6 @@
|
||||||
"description": "A style name from https://www.dicebear.com/styles"
|
"description": "A style name from https://www.dicebear.com/styles"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
|
||||||
"visibility",
|
|
||||||
"language",
|
|
||||||
"avatar",
|
|
||||||
"header",
|
|
||||||
"placeholder_style"
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"federation": {
|
"federation": {
|
||||||
|
|
@ -2155,17 +2067,6 @@
|
||||||
"default": []
|
"default": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
|
||||||
"reports",
|
|
||||||
"deletes",
|
|
||||||
"updates",
|
|
||||||
"media",
|
|
||||||
"follows",
|
|
||||||
"likes",
|
|
||||||
"reactions",
|
|
||||||
"banners",
|
|
||||||
"avatars"
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"bridge": {
|
"bridge": {
|
||||||
|
|
@ -2196,11 +2097,11 @@
|
||||||
"$ref": "#/properties/http/properties/proxy_address"
|
"$ref": "#/properties/http/properties/proxy_address"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["software", "allowed_ips", "token", "url"],
|
"required": ["software", "token", "url"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["blocked", "followers_only", "discard", "bridge"],
|
"required": ["discard"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"queues": {
|
"queues": {
|
||||||
|
|
@ -2219,10 +2120,6 @@
|
||||||
"default": 31536000
|
"default": 31536000
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
|
||||||
"remove_after_complete_seconds",
|
|
||||||
"remove_after_failure_seconds"
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"propertyNames": {
|
"propertyNames": {
|
||||||
|
|
@ -2264,7 +2161,6 @@
|
||||||
"$ref": "#/properties/http/properties/proxy_address"
|
"$ref": "#/properties/http/properties/proxy_address"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["logo", "banner"],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
|
|
@ -2489,7 +2385,7 @@
|
||||||
"description": "Longer version of the rule with additional information"
|
"description": "Longer version of the rule with additional information"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["text", "hint"],
|
"required": ["text"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"default": []
|
"default": []
|
||||||
|
|
@ -2498,28 +2394,18 @@
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"public": {
|
"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": {
|
"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
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["branding", "languages", "contact"],
|
||||||
"name",
|
|
||||||
"description",
|
|
||||||
"extended_description_path",
|
|
||||||
"tos_path",
|
|
||||||
"privacy_policy_path",
|
|
||||||
"branding",
|
|
||||||
"languages",
|
|
||||||
"contact",
|
|
||||||
"rules",
|
|
||||||
"keys"
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
|
|
@ -2798,7 +2684,6 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["anonymous", "default", "admin"],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"logging": {
|
"logging": {
|
||||||
|
|
@ -2830,7 +2715,6 @@
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["level", "log_file_path"],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -2886,15 +2770,7 @@
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": ["dsn"],
|
||||||
"dsn",
|
|
||||||
"debug",
|
|
||||||
"sample_rate",
|
|
||||||
"traces_sample_rate",
|
|
||||||
"trace_propagation_targets",
|
|
||||||
"max_breadcrumbs",
|
|
||||||
"environment"
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"log_file_path": {
|
"log_file_path": {
|
||||||
|
|
@ -2902,7 +2778,7 @@
|
||||||
"default": "logs/versia.log"
|
"default": "logs/versia.log"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["types", "log_level", "sentry", "log_file_path"],
|
"required": ["types"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"debug": {
|
"debug": {
|
||||||
|
|
@ -2913,7 +2789,6 @@
|
||||||
"default": false
|
"default": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["federation"],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
|
|
@ -2941,7 +2816,6 @@
|
||||||
"default": []
|
"default": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["enabled", "disabled"],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
|
|
@ -2949,7 +2823,7 @@
|
||||||
"additionalProperties": {}
|
"additionalProperties": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["autoload", "overrides", "config"],
|
"required": ["overrides"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -2962,7 +2836,6 @@
|
||||||
"frontend",
|
"frontend",
|
||||||
"email",
|
"email",
|
||||||
"media",
|
"media",
|
||||||
"s3",
|
|
||||||
"validation",
|
"validation",
|
||||||
"notifications",
|
"notifications",
|
||||||
"defaults",
|
"defaults",
|
||||||
|
|
@ -2971,7 +2844,6 @@
|
||||||
"instance",
|
"instance",
|
||||||
"permissions",
|
"permissions",
|
||||||
"logging",
|
"logging",
|
||||||
"debug",
|
|
||||||
"plugins"
|
"plugins"
|
||||||
],
|
],
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Multiple API routes are exposed for authentication, to be used by frontend developers.
|
Multiple API routes are exposed for authentication, to be used by frontend developers.
|
||||||
|
|
||||||
> [!INFO]
|
> [!INFO]
|
||||||
>
|
>
|
||||||
> These are different from the Client API routes, which are used by clients to interact with the Mastodon API.
|
> 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.
|
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
|
## SSO Sign In
|
||||||
|
|
||||||
```http
|
```http
|
||||||
|
|
@ -136,4 +84,4 @@ Redirects the user to the OpenID Connect provider's login page.
|
||||||
```http
|
```http
|
||||||
HTTP/2.0 302 Found
|
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
|
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.
|
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`.
|
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 type { Config } from "drizzle-kit";
|
||||||
import { config } from "~/config.ts";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drizzle can't properly resolve imports with top-level await, so uncomment
|
* Drizzle can't properly resolve imports with top-level await, so uncomment
|
||||||
|
|
@ -7,8 +7,8 @@ import { config } from "~/config.ts";
|
||||||
*/
|
*/
|
||||||
export default {
|
export default {
|
||||||
dialect: "postgresql",
|
dialect: "postgresql",
|
||||||
out: "./drizzle/migrations",
|
out: "./packages/kit/tables/migrations",
|
||||||
schema: "./drizzle/schema.ts",
|
schema: "./packages/kit/tables/schema.ts",
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
/* host: "localhost",
|
/* host: "localhost",
|
||||||
port: 40000,
|
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": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1744536153,
|
"lastModified": 1763421233,
|
||||||
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
|
"narHash": "sha256-Stk9ZYRkGrnnpyJ4eqt9eQtdFWRRIvMxpNRf4sIegnw=",
|
||||||
"owner": "nixos",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
|
"rev": "89c2b2330e733d6cdb5eae7b899326930c2c0648",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "nixos",
|
"owner": "NixOS",
|
||||||
"ref": "nixpkgs-unstable",
|
"ref": "nixos-unstable",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
description = "Versia Server";
|
description = "Versia Server";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
};
|
};
|
||||||
|
|
@ -15,7 +15,9 @@
|
||||||
}:
|
}:
|
||||||
{
|
{
|
||||||
overlays.default = final: prev: rec {
|
overlays.default = final: prev: rec {
|
||||||
versia-server = final.callPackage ./nix/package.nix {};
|
versia-server =
|
||||||
|
final.callPackage ./nix/package.nix {
|
||||||
|
};
|
||||||
versia-server-worker = final.callPackage ./nix/package-worker.nix {
|
versia-server-worker = final.callPackage ./nix/package-worker.nix {
|
||||||
inherit versia-server;
|
inherit versia-server;
|
||||||
};
|
};
|
||||||
|
|
@ -54,7 +56,6 @@
|
||||||
buildInputs = with pkgs; [
|
buildInputs = with pkgs; [
|
||||||
bun
|
bun
|
||||||
vips
|
vips
|
||||||
pnpm
|
|
||||||
nodePackages.typescript
|
nodePackages.typescript
|
||||||
nodePackages.typescript-language-server
|
nodePackages.typescript-language-server
|
||||||
nix-ld
|
nix-ld
|
||||||
|
|
|
||||||
|
|
@ -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";
|
StandardError = "journal";
|
||||||
SyslogIdentifier = "${name}";
|
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 = [
|
Environment = [
|
||||||
"CONFIG_LOCATION=${configFile}"
|
"CONFIG_LOCATION=${configFile}"
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,8 @@
|
||||||
{versia-server, ...}:
|
{versia-server, ...}:
|
||||||
versia-server.overrideAttrs (oldAttrs: {
|
versia-server.overrideAttrs (oldAttrs: {
|
||||||
pname = "${oldAttrs.pname}-worker";
|
pname = "${oldAttrs.pname}-worker";
|
||||||
buildPhase = ''
|
|
||||||
runHook preBuild
|
|
||||||
|
|
||||||
bun run build:worker
|
buildType = "worker";
|
||||||
|
|
||||||
runHook postBuild
|
|
||||||
'';
|
|
||||||
entrypointPath = "worker.js";
|
|
||||||
|
|
||||||
meta =
|
meta =
|
||||||
oldAttrs.meta
|
oldAttrs.meta
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
{
|
{
|
||||||
lib,
|
lib,
|
||||||
stdenv,
|
stdenv,
|
||||||
pnpm,
|
|
||||||
bun,
|
bun,
|
||||||
nodejs,
|
nodejs,
|
||||||
vips,
|
vips,
|
||||||
makeWrapper,
|
makeWrapper,
|
||||||
|
stdenvNoCC,
|
||||||
|
writableTmpDirAsHomeHook,
|
||||||
...
|
...
|
||||||
}: let
|
}: let
|
||||||
packageJson = builtins.fromJSON (builtins.readFile ../package.json);
|
packageJson = builtins.fromJSON (builtins.readFile ../package.json);
|
||||||
|
|
@ -16,35 +17,70 @@ in
|
||||||
|
|
||||||
src = ../.;
|
src = ../.;
|
||||||
|
|
||||||
# Fixes the build script mv usage
|
node_modules = stdenvNoCC.mkDerivation {
|
||||||
pnpmInstallFlags = ["--shamefully-hoist"];
|
pname = "${finalAttrs.pname}-node_modules";
|
||||||
|
inherit (finalAttrs) version src;
|
||||||
|
|
||||||
pnpmDeps = pnpm.fetchDeps {
|
nativeBuildInputs = [
|
||||||
inherit (finalAttrs) pname version src pnpmInstallFlags;
|
bun
|
||||||
hash = "sha256-NajZ28fHMehdx0CUz7gzRZ/egbusPT8QWv/v5hFJRa0=";
|
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 = [
|
nativeBuildInputs = [
|
||||||
pnpm
|
|
||||||
pnpm.configHook
|
|
||||||
bun
|
bun
|
||||||
nodejs
|
|
||||||
makeWrapper
|
makeWrapper
|
||||||
];
|
];
|
||||||
|
|
||||||
buildInputs = [
|
configurePhase = ''
|
||||||
vips
|
runHook preConfigure
|
||||||
];
|
|
||||||
|
cp -R ${finalAttrs.node_modules}/node_modules .
|
||||||
|
|
||||||
|
runHook postConfigure
|
||||||
|
'';
|
||||||
|
|
||||||
buildPhase = ''
|
buildPhase = ''
|
||||||
runHook preBuild
|
runHook preBuild
|
||||||
|
|
||||||
bun run build
|
bun run build ${finalAttrs.buildType}
|
||||||
|
|
||||||
runHook postBuild
|
runHook postBuild
|
||||||
'';
|
'';
|
||||||
|
|
||||||
entrypointPath = "index.js";
|
buildType = "api";
|
||||||
|
|
||||||
installPhase = let
|
installPhase = let
|
||||||
libPath = lib.makeLibraryPath [
|
libPath = lib.makeLibraryPath [
|
||||||
|
|
@ -62,7 +98,7 @@ in
|
||||||
cp -r dist $out/${finalAttrs.pname}
|
cp -r dist $out/${finalAttrs.pname}
|
||||||
|
|
||||||
makeWrapper ${lib.getExe bun} $out/bin/${finalAttrs.pname} \
|
makeWrapper ${lib.getExe bun} $out/bin/${finalAttrs.pname} \
|
||||||
--add-flags "run $out/${finalAttrs.pname}/${finalAttrs.entrypointPath}" \
|
--add-flags "run $out/${finalAttrs.pname}/${finalAttrs.buildType}.js" \
|
||||||
--set NODE_PATH $out/${finalAttrs.pname}/node_modules \
|
--set NODE_PATH $out/${finalAttrs.pname}/node_modules \
|
||||||
--set MSGPACKR_NATIVE_ACCELERATION_DISABLED true \
|
--set MSGPACKR_NATIVE_ACCELERATION_DISABLED true \
|
||||||
--prefix PATH : ${binPath} \
|
--prefix PATH : ${binPath} \
|
||||||
|
|
|
||||||
269
package.json
269
package.json
|
|
@ -2,7 +2,7 @@
|
||||||
"name": "versia-server",
|
"name": "versia-server",
|
||||||
"module": "index.ts",
|
"module": "index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.8.0",
|
"version": "0.9.0-alpha.0",
|
||||||
"description": "Powerful, configurable and modular federated server using the Versia Protocol.",
|
"description": "Powerful, configurable and modular federated server using the Versia Protocol.",
|
||||||
"homepage": "https://versia.pub",
|
"homepage": "https://versia.pub",
|
||||||
"author": {
|
"author": {
|
||||||
|
|
@ -20,9 +20,102 @@
|
||||||
"activitypub",
|
"activitypub",
|
||||||
"bun"
|
"bun"
|
||||||
],
|
],
|
||||||
"workspaces": [
|
"scripts": {
|
||||||
"packages/*"
|
"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": [
|
"maintainers": [
|
||||||
{
|
{
|
||||||
"email": "contact@cpluspatch.com",
|
"email": "contact@cpluspatch.com",
|
||||||
|
|
@ -35,110 +128,92 @@
|
||||||
"url": "git+https://github.com/versia-pub/server.git"
|
"url": "git+https://github.com/versia-pub/server.git"
|
||||||
},
|
},
|
||||||
"private": true,
|
"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": [
|
"trustedDependencies": [
|
||||||
"@biomejs/biome",
|
"@biomejs/biome",
|
||||||
"es5-ext",
|
"es5-ext",
|
||||||
"esbuild",
|
"esbuild",
|
||||||
"msgpackr-extract",
|
"msgpackr-extract",
|
||||||
|
"protobufjs",
|
||||||
"sharp"
|
"sharp"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.0.0-beta.2",
|
"@biomejs/biome": "catalog:",
|
||||||
"@types/bun": "^1.2.13",
|
"@types/bun": "catalog:",
|
||||||
"@types/html-to-text": "^9.0.4",
|
"@types/html-to-text": "catalog:",
|
||||||
"@types/markdown-it-container": "^2.0.10",
|
"@types/markdown-it-container": "catalog:",
|
||||||
"@types/mime-types": "^2.1.4",
|
"@types/mime-types": "catalog:",
|
||||||
"@types/qs": "^6.14.0",
|
"@types/qs": "catalog:",
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "catalog:",
|
||||||
"bun-bagel": "^1.2.0",
|
"bun-bagel": "catalog:",
|
||||||
"drizzle-kit": "^0.31.1",
|
"drizzle-kit": "catalog:",
|
||||||
"markdown-it-image-figures": "^2.1.1",
|
"markdown-it-image-figures": "catalog:",
|
||||||
"ts-prune": "^0.10.3",
|
"ts-prune": "catalog:",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "catalog:",
|
||||||
"vitepress": "^1.6.3",
|
"vitepress": "catalog:",
|
||||||
"vitepress-plugin-tabs": "^0.7.1",
|
"vitepress-plugin-tabs": "catalog:",
|
||||||
"vitepress-sidebar": "^1.31.1",
|
"vitepress-sidebar": "catalog:",
|
||||||
"vue": "^3.5.14",
|
"vue": "catalog:"
|
||||||
"zod-to-json-schema": "^3.24.5"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"typescript": "^5.7.2"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bull-board/api": "^6.9.6",
|
"@bull-board/api": "catalog:",
|
||||||
"@bull-board/hono": "^6.9.6",
|
"@bull-board/hono": "catalog:",
|
||||||
"@clerc/plugin-completions": "^0.44.0",
|
"@clerc/plugin-completions": "catalog:",
|
||||||
"@clerc/plugin-friendly-error": "^0.44.0",
|
"@clerc/plugin-friendly-error": "catalog:",
|
||||||
"@clerc/plugin-help": "^0.44.0",
|
"@clerc/plugin-help": "catalog:",
|
||||||
"@clerc/plugin-not-found": "^0.44.0",
|
"@clerc/plugin-not-found": "catalog:",
|
||||||
"@clerc/plugin-version": "^0.44.0",
|
"@clerc/plugin-version": "catalog:",
|
||||||
"@hackmd/markdown-it-task-lists": "^2.1.4",
|
"@hackmd/markdown-it-task-lists": "catalog:",
|
||||||
"@hono/zod-validator": "^0.5.0",
|
"@hono/standard-validator": "catalog:",
|
||||||
"@inquirer/confirm": "^5.1.10",
|
"@inquirer/confirm": "catalog:",
|
||||||
"@logtape/file": "^0.10.0",
|
"@scalar/hono-api-reference": "catalog:",
|
||||||
"@logtape/logtape": "^0.10.0",
|
"@sentry/bun": "catalog:",
|
||||||
"@scalar/hono-api-reference": "^0.8.10",
|
"@versia-server/api": "workspace:*",
|
||||||
"@sentry/bun": "^9.20.0",
|
"@versia-server/config": "workspace:*",
|
||||||
|
"@versia-server/kit": "workspace:*",
|
||||||
|
"@versia-server/logging": "workspace:*",
|
||||||
|
"@versia-server/tests": "workspace:*",
|
||||||
|
"@versia-server/worker": "workspace:*",
|
||||||
"@versia/client": "workspace:*",
|
"@versia/client": "workspace:*",
|
||||||
"@versia/kit": "workspace:*",
|
|
||||||
"@versia/sdk": "workspace:*",
|
"@versia/sdk": "workspace:*",
|
||||||
"altcha-lib": "^1.2.0",
|
"altcha-lib": "catalog:",
|
||||||
"blurhash": "^2.0.5",
|
"blurhash": "catalog:",
|
||||||
"bullmq": "^5.52.2",
|
"bullmq": "catalog:",
|
||||||
"chalk": "^5.4.1",
|
"chalk": "catalog:",
|
||||||
"clerc": "^0.44.0",
|
"clerc": "catalog:",
|
||||||
"confbox": "^0.2.2",
|
"confbox": "catalog:",
|
||||||
"drizzle-orm": "^0.43.1",
|
"drizzle-orm": "catalog:",
|
||||||
"feed": "^5.0.1",
|
"feed": "catalog:",
|
||||||
"hono": "^4.7.10",
|
"hono": "catalog:",
|
||||||
"hono-openapi": "^0.4.8",
|
"hono-openapi": "catalog:",
|
||||||
"hono-rate-limiter": "^0.4.2",
|
"hono-rate-limiter": "catalog:",
|
||||||
"html-to-text": "^9.0.5",
|
"html-to-text": "catalog:",
|
||||||
"ioredis": "^5.6.1",
|
"ioredis": "catalog:",
|
||||||
"ip-matching": "^2.1.2",
|
"ip-matching": "catalog:",
|
||||||
"iso-639-1": "^3.1.5",
|
"iso-639-1": "catalog:",
|
||||||
"jose": "^6.0.11",
|
"linkify-html": "catalog:",
|
||||||
"linkify-html": "^4.3.1",
|
"linkify-string": "catalog:",
|
||||||
"linkify-string": "^4.3.1",
|
"linkifyjs": "catalog:",
|
||||||
"linkifyjs": "^4.3.1",
|
"magic-regexp": "catalog:",
|
||||||
"magic-regexp": "^0.10.0",
|
"markdown-it": "catalog:",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it-anchor": "catalog:",
|
||||||
"markdown-it-anchor": "^9.2.0",
|
"markdown-it-container": "catalog:",
|
||||||
"markdown-it-container": "^4.0.0",
|
"markdown-it-mathjax3": "catalog:",
|
||||||
"markdown-it-mathjax3": "^4.3.2",
|
"markdown-it-toc-done-right": "catalog:",
|
||||||
"markdown-it-toc-done-right": "^4.2.0",
|
"mime-types": "catalog:",
|
||||||
"mime-types": "^3.0.1",
|
"mitata": "catalog:",
|
||||||
"mitata": "^1.0.34",
|
"ora": "catalog:",
|
||||||
"oauth4webapi": "^3.5.1",
|
"qs": "catalog:",
|
||||||
"ora": "^8.2.0",
|
"sharp": "catalog:",
|
||||||
"qs": "^6.14.0",
|
"sonic-channel": "catalog:",
|
||||||
"sharp": "^0.34.1",
|
"string-comparison": "catalog:",
|
||||||
"sonic-channel": "^1.3.1",
|
"stringify-entities": "catalog:",
|
||||||
"string-comparison": "^1.3.0",
|
"unicode-emoji-json": "catalog:",
|
||||||
"stringify-entities": "^4.0.4",
|
"uqr": "catalog:",
|
||||||
"uqr": "^0.1.2",
|
"web-push": "catalog:",
|
||||||
"web-push": "^3.6.7",
|
"xss": "catalog:",
|
||||||
"xss": "^1.0.15",
|
"youch": "catalog:",
|
||||||
"youch": "^4.1.0-beta.7",
|
"zod": "catalog:",
|
||||||
"zod": "^3.24.4",
|
"zod-openapi": "catalog:",
|
||||||
"zod-openapi": "^4.2.4",
|
"zod-validation-error": "catalog:"
|
||||||
"zod-validation-error": "^3.4.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,25 @@
|
||||||
import { resolve } from "node:path";
|
|
||||||
import { getLogger } from "@logtape/logtape";
|
|
||||||
import { Scalar } from "@scalar/hono-api-reference";
|
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 { Hono } from "hono";
|
||||||
import { serveStatic } from "hono/bun";
|
import { serveStatic } from "hono/bun";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
import { createMiddleware } from "hono/factory";
|
import { createMiddleware } from "hono/factory";
|
||||||
import { prettyJSON } from "hono/pretty-json";
|
import { prettyJSON } from "hono/pretty-json";
|
||||||
import { secureHeaders } from "hono/secure-headers";
|
import { secureHeaders } from "hono/secure-headers";
|
||||||
import { openAPISpecs } from "hono-openapi";
|
import { generateSpecs } from "hono-openapi";
|
||||||
import { Youch } from "youch";
|
import { Youch } from "youch";
|
||||||
import { applyToHono } from "@/bull-board.ts";
|
import { applyToHono } from "@/bull-board.ts";
|
||||||
import { configureLoggers } from "@/loggers";
|
import pkg from "../../package.json" with { type: "application/json" };
|
||||||
import { sentry } from "@/sentry";
|
import type { ApiRouteExports, HonoEnv } from "../../types/api.ts";
|
||||||
import { config } from "~/config.ts";
|
|
||||||
import pkg from "~/package.json" with { type: "application/json" };
|
|
||||||
import { ApiError } from "./classes/errors/api-error.ts";
|
|
||||||
import { PluginLoader } from "./classes/plugin/loader.ts";
|
|
||||||
import { agentBans } from "./middlewares/agent-bans.ts";
|
import { agentBans } from "./middlewares/agent-bans.ts";
|
||||||
import { boundaryCheck } from "./middlewares/boundary-check.ts";
|
import { boundaryCheck } from "./middlewares/boundary-check.ts";
|
||||||
import { ipBans } from "./middlewares/ip-bans.ts";
|
import { ipBans } from "./middlewares/ip-bans.ts";
|
||||||
import { logger } from "./middlewares/logger.ts";
|
import { logger } from "./middlewares/logger.ts";
|
||||||
import { rateLimit } from "./middlewares/rate-limit.ts";
|
import { rateLimit } from "./middlewares/rate-limit.ts";
|
||||||
import { routes } from "./routes.ts";
|
import { routes } from "./routes.ts";
|
||||||
import type { ApiRouteExports, HonoEnv } from "./types/api.ts";
|
|
||||||
// Extends Zod with OpenAPI schema generation
|
|
||||||
import "zod-openapi/extend";
|
|
||||||
|
|
||||||
export const appFactory = async (): Promise<Hono<HonoEnv>> => {
|
export const appFactory = async (): Promise<Hono<HonoEnv>> => {
|
||||||
await configureLoggers();
|
|
||||||
const serverLogger = getLogger("server");
|
|
||||||
|
|
||||||
const app = new Hono<HonoEnv>({
|
const app = new Hono<HonoEnv>({
|
||||||
strict: false,
|
strict: false,
|
||||||
});
|
});
|
||||||
|
|
@ -111,43 +100,23 @@ export const appFactory = async (): Promise<Hono<HonoEnv>> => {
|
||||||
route.default(app);
|
route.default(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
serverLogger.info`Loading plugins`;
|
const openApiSpecs = await generateSpecs(app, {
|
||||||
|
documentation: {
|
||||||
const time1 = performance.now();
|
info: {
|
||||||
|
title: "Versia Server API",
|
||||||
const loader = new PluginLoader();
|
version: pkg.version,
|
||||||
|
license: {
|
||||||
const plugins = await loader.loadPlugins(
|
name: "AGPL-3.0",
|
||||||
resolve("./plugins"),
|
url: "https://www.gnu.org/licenses/agpl-3.0.html",
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
|
contact: pkg.author,
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
|
app.get("/openapi.json", (context) => {
|
||||||
|
return context.json(openApiSpecs, 200);
|
||||||
|
});
|
||||||
|
|
||||||
app.get(
|
app.get(
|
||||||
"/docs",
|
"/docs",
|
||||||
|
|
@ -193,7 +162,6 @@ export const appFactory = async (): Promise<Hono<HonoEnv>> => {
|
||||||
const youch = new Youch();
|
const youch = new Youch();
|
||||||
console.error(await youch.toANSI(error));
|
console.error(await youch.toANSI(error));
|
||||||
|
|
||||||
sentry?.captureException(error);
|
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
error: "A server error occured",
|
error: "A server error occured",
|
||||||
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 { createMiddleware } from "hono/factory";
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
|
||||||
import { config } from "~/config.ts";
|
|
||||||
|
|
||||||
export const agentBans = createMiddleware(async (context, next) => {
|
export const agentBans = createMiddleware(async (context, next) => {
|
||||||
// Check for banned user agents (regex)
|
// Check for banned user agents (regex)
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { createMiddleware } from "hono/factory";
|
import { createMiddleware } from "hono/factory";
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
|
||||||
|
|
||||||
export const boundaryCheck = createMiddleware(async (context, next) => {
|
export const boundaryCheck = createMiddleware(async (context, next) => {
|
||||||
// Checks that FormData boundary is present
|
// Checks that FormData boundary is present
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import { getLogger } from "@logtape/logtape";
|
import { config } from "@versia-server/config";
|
||||||
|
import { ApiError } from "@versia-server/kit";
|
||||||
|
import { serverLogger } from "@versia-server/logging";
|
||||||
import type { SocketAddress } from "bun";
|
import type { SocketAddress } from "bun";
|
||||||
import { createMiddleware } from "hono/factory";
|
import { createMiddleware } from "hono/factory";
|
||||||
import { matches } from "ip-matching";
|
import { matches } from "ip-matching";
|
||||||
import { sentry } from "@/sentry";
|
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
|
||||||
import { config } from "~/config.ts";
|
|
||||||
|
|
||||||
export const ipBans = createMiddleware(async (context, next) => {
|
export const ipBans = createMiddleware(async (context, next) => {
|
||||||
// Check for banned IPs
|
// Check for banned IPs
|
||||||
|
|
@ -22,11 +21,8 @@ export const ipBans = createMiddleware(async (context, next) => {
|
||||||
throw new ApiError(403, "Forbidden");
|
throw new ApiError(403, "Forbidden");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const logger = getLogger("server");
|
serverLogger.error`Error while parsing banned IP "${ip}" `;
|
||||||
|
serverLogger.error`${e}`;
|
||||||
logger.error`Error while parsing banned IP "${ip}" `;
|
|
||||||
logger.error`${e}`;
|
|
||||||
sentry?.captureException(e);
|
|
||||||
|
|
||||||
return context.json(
|
return context.json(
|
||||||
{ error: `A server error occured: ${(e as Error).message}` },
|
{ error: `A server error occured: ${(e as Error).message}` },
|
||||||
|
|
@ -36,4 +32,5 @@ export const ipBans = createMiddleware(async (context, next) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
await next();
|
await next();
|
||||||
|
return;
|
||||||
});
|
});
|
||||||
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 { env } from "bun";
|
||||||
import type { MiddlewareHandler } from "hono";
|
import type { MiddlewareHandler } from "hono";
|
||||||
import { rateLimiter } from "hono-rate-limiter";
|
import { rateLimiter } from "hono-rate-limiter";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import type { ApiError } from "~/classes/errors/api-error";
|
|
||||||
import type { HonoEnv } from "~/types/api";
|
import type { HonoEnv } from "~/types/api";
|
||||||
|
|
||||||
// Not exported by hono-rate-limiter
|
// Not exported by hono-rate-limiter
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
|
import { config } from "@versia-server/config";
|
||||||
import { createMiddleware } from "hono/factory";
|
import { createMiddleware } from "hono/factory";
|
||||||
import { config } from "~/config.ts";
|
|
||||||
|
|
||||||
export const urlCheck = createMiddleware(async (context, next) => {
|
export const urlCheck = createMiddleware(async (context, next) => {
|
||||||
// Check that request URL matches base_url
|
// Check that request URL matches base_url
|
||||||
|
|
@ -15,4 +15,5 @@ export const urlCheck = createMiddleware(async (context, next) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
await next();
|
await next();
|
||||||
|
return;
|
||||||
});
|
});
|
||||||
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";
|
import { FileSystemRouter } from "bun";
|
||||||
|
|
||||||
// Returns the route filesystem path when given a URL
|
// Returns the route filesystem path when given a URL
|
||||||
export const routeMatcher = new FileSystemRouter({
|
export const routeMatcher = new FileSystemRouter({
|
||||||
style: "nextjs",
|
style: "nextjs",
|
||||||
dir: "api",
|
dir: join(import.meta.dir, "routes"),
|
||||||
fileExtensions: [".ts", ".js"],
|
fileExtensions: [".ts", ".js"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { afterAll, describe, expect, test } from "bun:test";
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
import { generateClient, getTestUsers } from "@versia-server/tests";
|
||||||
|
|
||||||
const { users, deleteUsers } = await getTestUsers(2);
|
const { users, deleteUsers } = await getTestUsers(2);
|
||||||
|
|
||||||
|
|
@ -2,11 +2,10 @@ import {
|
||||||
Relationship as RelationshipSchema,
|
Relationship as RelationshipSchema,
|
||||||
RolePermission,
|
RolePermission,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
import { Relationship } from "@versia/kit/db";
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { describeRoute } from "hono-openapi";
|
import { apiRoute, auth, withUserParam } from "@versia-server/kit/api";
|
||||||
import { resolver } from "hono-openapi/zod";
|
import { Relationship } from "@versia-server/kit/db";
|
||||||
import { apiRoute, auth, withUserParam } from "@/api";
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.post(
|
app.post(
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
import { RolePermission } from "@versia/client/schemas";
|
import { RolePermission } from "@versia/client/schemas";
|
||||||
import { describeRoute } from "hono-openapi";
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { resolver, validator } from "hono-openapi/zod";
|
import {
|
||||||
|
apiRoute,
|
||||||
|
auth,
|
||||||
|
handleZodError,
|
||||||
|
withUserParam,
|
||||||
|
} from "@versia-server/kit/api";
|
||||||
|
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
|
||||||
import { getFeed } from "@/rss";
|
import { getFeed } from "@/rss";
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.get(
|
app.get(
|
||||||
|
|
@ -34,12 +38,13 @@ export default apiRoute((app) =>
|
||||||
RolePermission.ViewNotes,
|
RolePermission.ViewNotes,
|
||||||
RolePermission.ViewAccounts,
|
RolePermission.ViewAccounts,
|
||||||
],
|
],
|
||||||
|
|
||||||
scopes: ["read:statuses"],
|
scopes: ["read:statuses"],
|
||||||
}),
|
}),
|
||||||
validator(
|
validator(
|
||||||
"query",
|
"query",
|
||||||
z.object({
|
z.object({
|
||||||
page: z.coerce.number().default(0).openapi({
|
page: z.coerce.number().default(0).meta({
|
||||||
description: "Page number to fetch. Defaults to 0.",
|
description: "Page number to fetch. Defaults to 0.",
|
||||||
example: 2,
|
example: 2,
|
||||||
}),
|
}),
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
import { RolePermission } from "@versia/client/schemas";
|
import { RolePermission } from "@versia/client/schemas";
|
||||||
import { describeRoute } from "hono-openapi";
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { resolver, validator } from "hono-openapi/zod";
|
import {
|
||||||
|
apiRoute,
|
||||||
|
auth,
|
||||||
|
handleZodError,
|
||||||
|
withUserParam,
|
||||||
|
} from "@versia-server/kit/api";
|
||||||
|
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
|
||||||
import { getFeed } from "@/rss";
|
import { getFeed } from "@/rss";
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.get(
|
app.get(
|
||||||
|
|
@ -33,12 +37,13 @@ export default apiRoute((app) =>
|
||||||
RolePermission.ViewNotes,
|
RolePermission.ViewNotes,
|
||||||
RolePermission.ViewAccounts,
|
RolePermission.ViewAccounts,
|
||||||
],
|
],
|
||||||
|
|
||||||
scopes: ["read:statuses"],
|
scopes: ["read:statuses"],
|
||||||
}),
|
}),
|
||||||
validator(
|
validator(
|
||||||
"query",
|
"query",
|
||||||
z.object({
|
z.object({
|
||||||
page: z.coerce.number().default(0).openapi({
|
page: z.coerce.number().default(0).meta({
|
||||||
description: "Page number to fetch. Defaults to 0.",
|
description: "Page number to fetch. Defaults to 0.",
|
||||||
example: 2,
|
example: 2,
|
||||||
}),
|
}),
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { afterAll, describe, expect, test } from "bun:test";
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
import { generateClient, getTestUsers } from "@versia-server/tests";
|
||||||
|
|
||||||
const { users, deleteUsers } = await getTestUsers(3);
|
const { users, deleteUsers } = await getTestUsers(3);
|
||||||
|
|
||||||
|
|
@ -3,12 +3,16 @@ import {
|
||||||
Relationship as RelationshipSchema,
|
Relationship as RelationshipSchema,
|
||||||
RolePermission,
|
RolePermission,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
import { Relationship } from "@versia/kit/db";
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { describeRoute } from "hono-openapi";
|
import {
|
||||||
import { resolver, validator } from "hono-openapi/zod";
|
apiRoute,
|
||||||
|
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 { z } from "zod";
|
||||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.post(
|
app.post(
|
||||||
|
|
@ -57,12 +61,12 @@ export default apiRoute((app) =>
|
||||||
validator(
|
validator(
|
||||||
"json",
|
"json",
|
||||||
z.object({
|
z.object({
|
||||||
reblogs: z.boolean().default(true).openapi({
|
reblogs: z.boolean().default(true).meta({
|
||||||
description:
|
description:
|
||||||
"Receive this account’s reblogs in home timeline?",
|
"Receive this account’s reblogs in home timeline?",
|
||||||
example: true,
|
example: true,
|
||||||
}),
|
}),
|
||||||
notify: z.boolean().default(false).openapi({
|
notify: z.boolean().default(false).meta({
|
||||||
description:
|
description:
|
||||||
"Receive notifications when this account posts a status?",
|
"Receive notifications when this account posts a status?",
|
||||||
example: false,
|
example: false,
|
||||||
|
|
@ -70,7 +74,7 @@ export default apiRoute((app) =>
|
||||||
languages: z
|
languages: z
|
||||||
.array(iso631)
|
.array(iso631)
|
||||||
.default([])
|
.default([])
|
||||||
.openapi({
|
.meta({
|
||||||
description:
|
description:
|
||||||
"Array of String (ISO 639-1 language two-letter code). Filter received statuses for these languages. If not provided, you will receive this account’s posts in all languages.",
|
"Array of String (ISO 639-1 language two-letter code). Filter received statuses for these languages. If not provided, you will receive this account’s posts in all languages.",
|
||||||
example: ["en", "fr"],
|
example: ["en", "fr"],
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
import { generateClient, getTestUsers } from "@versia-server/tests";
|
||||||
|
|
||||||
const { users, deleteUsers } = await getTestUsers(5);
|
const { users, deleteUsers } = await getTestUsers(5);
|
||||||
|
|
||||||
|
|
@ -2,14 +2,18 @@ import {
|
||||||
Account as AccountSchema,
|
Account as AccountSchema,
|
||||||
RolePermission,
|
RolePermission,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
import { Timeline } from "@versia/kit/db";
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { Users } from "@versia/kit/tables";
|
import {
|
||||||
|
apiRoute,
|
||||||
|
auth,
|
||||||
|
handleZodError,
|
||||||
|
withUserParam,
|
||||||
|
} from "@versia-server/kit/api";
|
||||||
|
import { Timeline } from "@versia-server/kit/db";
|
||||||
|
import { Users } from "@versia-server/kit/tables";
|
||||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
||||||
import { describeRoute } from "hono-openapi";
|
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||||
import { resolver, validator } from "hono-openapi/zod";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.get(
|
app.get(
|
||||||
|
|
@ -34,7 +38,7 @@ export default apiRoute((app) =>
|
||||||
link: z
|
link: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.openapi({
|
.meta({
|
||||||
description:
|
description:
|
||||||
"Links to the next and previous pages",
|
"Links to the next and previous pages",
|
||||||
example:
|
example:
|
||||||
|
|
@ -61,22 +65,22 @@ export default apiRoute((app) =>
|
||||||
validator(
|
validator(
|
||||||
"query",
|
"query",
|
||||||
z.object({
|
z.object({
|
||||||
max_id: AccountSchema.shape.id.optional().openapi({
|
max_id: AccountSchema.shape.id.optional().meta({
|
||||||
description:
|
description:
|
||||||
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
|
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
|
||||||
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
|
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
|
||||||
}),
|
}),
|
||||||
since_id: AccountSchema.shape.id.optional().openapi({
|
since_id: AccountSchema.shape.id.optional().meta({
|
||||||
description:
|
description:
|
||||||
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
|
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
|
||||||
example: undefined,
|
example: undefined,
|
||||||
}),
|
}),
|
||||||
min_id: AccountSchema.shape.id.optional().openapi({
|
min_id: AccountSchema.shape.id.optional().meta({
|
||||||
description:
|
description:
|
||||||
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
|
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
|
||||||
example: undefined,
|
example: undefined,
|
||||||
}),
|
}),
|
||||||
limit: z.number().int().min(1).max(40).default(20).openapi({
|
limit: z.number().int().min(1).max(40).default(20).meta({
|
||||||
description: "Maximum number of results to return.",
|
description: "Maximum number of results to return.",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
import { generateClient, getTestUsers } from "@versia-server/tests";
|
||||||
|
|
||||||
const { users, deleteUsers } = await getTestUsers(5);
|
const { users, deleteUsers } = await getTestUsers(5);
|
||||||
|
|
||||||
|
|
@ -2,14 +2,18 @@ import {
|
||||||
Account as AccountSchema,
|
Account as AccountSchema,
|
||||||
RolePermission,
|
RolePermission,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
import { Timeline } from "@versia/kit/db";
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { Users } from "@versia/kit/tables";
|
import {
|
||||||
|
apiRoute,
|
||||||
|
auth,
|
||||||
|
handleZodError,
|
||||||
|
withUserParam,
|
||||||
|
} from "@versia-server/kit/api";
|
||||||
|
import { Timeline } from "@versia-server/kit/db";
|
||||||
|
import { Users } from "@versia-server/kit/tables";
|
||||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
||||||
import { describeRoute } from "hono-openapi";
|
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||||
import { resolver, validator } from "hono-openapi/zod";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.get(
|
app.get(
|
||||||
|
|
@ -35,7 +39,7 @@ export default apiRoute((app) =>
|
||||||
link: z
|
link: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.openapi({
|
.meta({
|
||||||
description:
|
description:
|
||||||
"Links to the next and previous pages",
|
"Links to the next and previous pages",
|
||||||
example:
|
example:
|
||||||
|
|
@ -62,22 +66,22 @@ export default apiRoute((app) =>
|
||||||
validator(
|
validator(
|
||||||
"query",
|
"query",
|
||||||
z.object({
|
z.object({
|
||||||
max_id: AccountSchema.shape.id.optional().openapi({
|
max_id: AccountSchema.shape.id.optional().meta({
|
||||||
description:
|
description:
|
||||||
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
|
"All results returned will be lesser than this ID. In effect, sets an upper bound on results.",
|
||||||
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
|
example: "8d35243d-b959-43e2-8bac-1a9d4eaea2aa",
|
||||||
}),
|
}),
|
||||||
since_id: AccountSchema.shape.id.optional().openapi({
|
since_id: AccountSchema.shape.id.optional().meta({
|
||||||
description:
|
description:
|
||||||
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
|
"All results returned will be greater than this ID. In effect, sets a lower bound on results.",
|
||||||
example: undefined,
|
example: undefined,
|
||||||
}),
|
}),
|
||||||
min_id: AccountSchema.shape.id.optional().openapi({
|
min_id: AccountSchema.shape.id.optional().meta({
|
||||||
description:
|
description:
|
||||||
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
|
"Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.",
|
||||||
example: undefined,
|
example: undefined,
|
||||||
}),
|
}),
|
||||||
limit: z.number().int().min(1).max(40).default(20).openapi({
|
limit: z.number().int().min(1).max(40).default(20).meta({
|
||||||
description: "Maximum number of results to return.",
|
description: "Maximum number of results to return.",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { generateClient, getTestStatuses, getTestUsers } from "~/tests/utils";
|
import {
|
||||||
|
generateClient,
|
||||||
|
getTestStatuses,
|
||||||
|
getTestUsers,
|
||||||
|
} from "@versia-server/tests";
|
||||||
|
|
||||||
const { users, deleteUsers } = await getTestUsers(5);
|
const { users, deleteUsers } = await getTestUsers(5);
|
||||||
const timeline = (await getTestStatuses(5, users[0])).toReversed();
|
const timeline = (await getTestStatuses(5, users[0])).toReversed();
|
||||||
|
|
@ -2,10 +2,9 @@ import {
|
||||||
Account as AccountSchema,
|
Account as AccountSchema,
|
||||||
RolePermission,
|
RolePermission,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
import { describeRoute } from "hono-openapi";
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { resolver } from "hono-openapi/zod";
|
import { apiRoute, auth, withUserParam } from "@versia-server/kit/api";
|
||||||
import { apiRoute, auth, withUserParam } from "@/api";
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.get(
|
app.get(
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { afterAll, describe, expect, test } from "bun:test";
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
import { generateClient, getTestUsers } from "@versia-server/tests";
|
||||||
|
|
||||||
const { users, deleteUsers } = await getTestUsers(2);
|
const { users, deleteUsers } = await getTestUsers(2);
|
||||||
|
|
||||||
|
|
@ -2,16 +2,20 @@ import {
|
||||||
Relationship as RelationshipSchema,
|
Relationship as RelationshipSchema,
|
||||||
RolePermission,
|
RolePermission,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
import { Relationship } from "@versia/kit/db";
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { describeRoute } from "hono-openapi";
|
import {
|
||||||
import { resolver, validator } from "hono-openapi/zod";
|
apiRoute,
|
||||||
import { z } from "zod";
|
auth,
|
||||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
handleZodError,
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
withUserParam,
|
||||||
|
} from "@versia-server/kit/api";
|
||||||
|
import { Relationship } from "@versia-server/kit/db";
|
||||||
import {
|
import {
|
||||||
RelationshipJobType,
|
RelationshipJobType,
|
||||||
relationshipQueue,
|
relationshipQueue,
|
||||||
} from "~/classes/queues/relationships";
|
} from "@versia-server/kit/queues/relationships";
|
||||||
|
import { describeRoute, resolver, validator } from "hono-openapi";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.post(
|
app.post(
|
||||||
|
|
@ -51,7 +55,7 @@ export default apiRoute((app) =>
|
||||||
validator(
|
validator(
|
||||||
"json",
|
"json",
|
||||||
z.object({
|
z.object({
|
||||||
notifications: z.boolean().default(true).openapi({
|
notifications: z.boolean().default(true).meta({
|
||||||
description: "Mute notifications in addition to statuses?",
|
description: "Mute notifications in addition to statuses?",
|
||||||
}),
|
}),
|
||||||
duration: z
|
duration: z
|
||||||
|
|
@ -60,7 +64,7 @@ export default apiRoute((app) =>
|
||||||
.min(0)
|
.min(0)
|
||||||
.max(60 * 60 * 24 * 365 * 5)
|
.max(60 * 60 * 24 * 365 * 5)
|
||||||
.default(0)
|
.default(0)
|
||||||
.openapi({
|
.meta({
|
||||||
description:
|
description:
|
||||||
"How long the mute should last, in seconds. 0 means indefinite.",
|
"How long the mute should last, in seconds. 0 means indefinite.",
|
||||||
}),
|
}),
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { afterAll, describe, expect, test } from "bun:test";
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
import { generateClient, getTestUsers } from "@versia-server/tests";
|
||||||
|
|
||||||
const { users, deleteUsers } = await getTestUsers(2);
|
const { users, deleteUsers } = await getTestUsers(2);
|
||||||
|
|
||||||
|
|
@ -2,12 +2,16 @@ import {
|
||||||
Relationship as RelationshipSchema,
|
Relationship as RelationshipSchema,
|
||||||
RolePermission,
|
RolePermission,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
import { Relationship } from "@versia/kit/db";
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { describeRoute } from "hono-openapi";
|
import {
|
||||||
import { resolver, validator } from "hono-openapi/zod";
|
apiRoute,
|
||||||
|
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 { z } from "zod";
|
||||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.post(
|
app.post(
|
||||||
|
|
@ -45,7 +49,7 @@ export default apiRoute((app) =>
|
||||||
validator(
|
validator(
|
||||||
"json",
|
"json",
|
||||||
z.object({
|
z.object({
|
||||||
comment: RelationshipSchema.shape.note.optional().openapi({
|
comment: RelationshipSchema.shape.note.optional().meta({
|
||||||
description:
|
description:
|
||||||
"The comment to be set on that user. Provide an empty string or leave out this parameter to clear the currently set note.",
|
"The comment to be set on that user. Provide an empty string or leave out this parameter to clear the currently set note.",
|
||||||
}),
|
}),
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { afterAll, describe, expect, test } from "bun:test";
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
import { generateClient, getTestUsers } from "@versia-server/tests";
|
||||||
|
|
||||||
const { users, deleteUsers } = await getTestUsers(2);
|
const { users, deleteUsers } = await getTestUsers(2);
|
||||||
|
|
||||||
|
|
@ -2,10 +2,9 @@ import {
|
||||||
Relationship as RelationshipSchema,
|
Relationship as RelationshipSchema,
|
||||||
RolePermission,
|
RolePermission,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
import { Relationship } from "@versia/kit/db";
|
import { apiRoute, auth, withUserParam } from "@versia-server/kit/api";
|
||||||
import { describeRoute } from "hono-openapi";
|
import { Relationship } from "@versia-server/kit/db";
|
||||||
import { resolver } from "hono-openapi/zod";
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
import { apiRoute, auth, withUserParam } from "@/api";
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.post(
|
app.post(
|
||||||
|
|
@ -2,11 +2,10 @@ import {
|
||||||
Account as AccountSchema,
|
Account as AccountSchema,
|
||||||
RolePermission,
|
RolePermission,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
import { describeRoute } from "hono-openapi";
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { resolver } from "hono-openapi/zod";
|
import { apiRoute, auth, withUserParam } from "@versia-server/kit/api";
|
||||||
import { apiRoute, auth, withUserParam } from "@/api";
|
import { User } from "@versia-server/kit/db";
|
||||||
import { User } from "~/classes/database/user";
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.post(
|
app.post(
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
import { generateClient, getTestUsers } from "@versia-server/tests";
|
||||||
|
|
||||||
const { users, deleteUsers } = await getTestUsers(2);
|
const { users, deleteUsers } = await getTestUsers(2);
|
||||||
|
|
||||||
|
|
@ -2,11 +2,10 @@ import {
|
||||||
Relationship as RelationshipSchema,
|
Relationship as RelationshipSchema,
|
||||||
RolePermission,
|
RolePermission,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
import { Relationship } from "@versia/kit/db";
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { describeRoute } from "hono-openapi";
|
import { apiRoute, auth, withUserParam } from "@versia-server/kit/api";
|
||||||
import { resolver } from "hono-openapi/zod";
|
import { Relationship } from "@versia-server/kit/db";
|
||||||
import { apiRoute, auth, withUserParam } from "@/api";
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.post(
|
app.post(
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { RolePermission } from "@versia/client/schemas";
|
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 { randomUUIDv7 } from "bun";
|
||||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
|
||||||
|
|
||||||
const { users, deleteUsers } = await getTestUsers(2);
|
const { users, deleteUsers } = await getTestUsers(2);
|
||||||
let role: Role;
|
let role: Role;
|
||||||
|
|
@ -3,12 +3,16 @@ import {
|
||||||
RolePermission,
|
RolePermission,
|
||||||
Role as RoleSchema,
|
Role as RoleSchema,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
import { Role } from "@versia/kit/db";
|
import { ApiError } from "@versia-server/kit";
|
||||||
import { describeRoute } from "hono-openapi";
|
import {
|
||||||
import { validator } from "hono-openapi/zod";
|
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 { z } from "zod";
|
||||||
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
|
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
|
||||||
|
|
||||||
export default apiRoute((app) => {
|
export default apiRoute((app) => {
|
||||||
app.post(
|
app.post(
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { RolePermission } from "@versia/client/schemas";
|
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 { randomUUIDv7 } from "bun";
|
||||||
import { generateClient, getTestUsers } from "~/tests/utils";
|
|
||||||
|
|
||||||
const { users, deleteUsers } = await getTestUsers(2);
|
const { users, deleteUsers } = await getTestUsers(2);
|
||||||
let role: Role;
|
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