Compare commits

..

No commits in common. "main" and "v0.7.0-rc.0" have entirely different histories.

743 changed files with 36248 additions and 87132 deletions

View file

@ -1,18 +1,16 @@
version = 1
test_patterns = ["**/*.test.ts"]
[[analyzers]]
name = "shell"
[[analyzers]]
name = "javascript"
[analyzers.meta]
environment = ["nodejs"]
[analyzers.meta]
environment = ["nodejs"]
[[analyzers]]
name = "docker"
[analyzers.meta]
dockerfile_paths = ["Dockerfile"]
[analyzers.meta]
dockerfile_paths = ["Dockerfile"]

9
.devcontainer/Dockerfile Normal file
View file

@ -0,0 +1,9 @@
# Bun doesn't run well on Musl but this seems to work
FROM oven/bun:1.1.34-alpine as base
# Switch to Bash by editing /etc/passwd
RUN apk add --no-cache libstdc++ git bash curl openssh cloc && \
sed -i -e 's|/bin/ash|/bin/bash|g' /etc/passwd
# Extract Node from its docker image (node:22-alpine)
COPY --from=node:22-alpine /usr/local/bin/node /usr/local/bin/node

View file

@ -0,0 +1,34 @@
{
"name": "versia Dev Container",
"dockerFile": "Dockerfile",
"runArgs": [
"-v",
"${localWorkspaceFolder}/config:/workspace/config",
"-v",
"${localWorkspaceFolder}/logs:/workspace/logs",
"-v",
"${localWorkspaceFolder}/uploads:/workspace/uploads",
"--network=host"
],
"mounts": [
"source=node_modules,target=/workspace/node_modules,type=bind,consistency=cached",
"type=bind,source=/home/${localEnv:USER}/.ssh,target=/root/.ssh,readonly"
],
"customizations": {
"vscode": {
"settings": {
"terminal.integrated.shell.linux": "/bin/bash"
},
"extensions": [
"biomejs.biome",
"ms-vscode-remote.remote-containers",
"oven.bun-vscode",
"vivaxy.vscode-conventional-commits",
"EditorConfig.EditorConfig",
"tamasfe.even-better-toml",
"YoavBls.pretty-ts-errors",
"eamodio.gitlens"
]
}
}
}

View file

@ -1,9 +0,0 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
insert_final_newline = true
tab_width = 4
trim_trailing_whitespace = true

View file

@ -1,171 +1,100 @@
# You can change the URL to the commit/tag you are using
#:schema https://raw.githubusercontent.com/versia-pub/server/main/config/config.schema.json
# All values marked as "sensitive" can be set to "PATH:/path/to/file" to read the value from a file (e.g. a secret manager)
[postgres]
# PostgreSQL database configuration
[database]
host = "localhost"
port = 5432
username = "versia"
# Sensitive value
password = "versia"
database = "versia"
# Additional read-only replicas
# [[postgres.replicas]]
# host = "other-host"
# port = 5432
# username = "versia"
# password = "mycoolpassword2"
# database = "replica1"
[redis.queue]
# A Redis database used for managing queues.
# Required for federation
host = "localhost"
port = 6379
# Sensitive value
# password = "test"
password = ""
database = 0
# A Redis database used for caching SQL queries.
# Optional, can be the same as the queue instance
# [redis.cache]
# host = "localhost"
# port = 6380
# database = 1
# password = ""
# Search and indexing configuration
[search]
# Enable indexing and searching?
enabled = false
# Optional if search is disabled
[search.sonic]
[redis.cache]
host = "localhost"
port = 6379
password = ""
database = 1
enabled = false
[sonic]
host = "localhost"
port = 40007
# Sensitive value
password = ""
enabled = false
[registration]
# Can users sign up freely?
allow = true
# NOT IMPLEMENTED
require_approval = false
# Message to show to users when registration is disabled
# message = "ran out of spoons to moderate registrations, sorry"
[signups]
# Whether to enable registrations or not
registration = true
rules = [
"Do not harass others",
"Be nice to people",
"Don't spam",
"Don't post illegal content",
]
[http]
# URL that the instance will be accessible at
base_url = "http://0.0.0.0:8080"
# Address to bind to (0.0.0.0 is suggested for proxies)
bind = "0.0.0.0"
bind_port = 8080
# Bans IPv4 or IPv6 IPs (wildcards, networks and ranges are supported)
banned_ips = []
# Banned user agents, regex format
banned_user_agents = [
# "curl\/7.68.0",
# "wget\/1.20.3",
]
# URL to an eventual HTTP proxy
# Will be used for all outgoing requests
# proxy_address = "http://localhost:8118"
# TLS configuration. You should probably be using a reverse proxy instead of this
# [http.tls]
# key = "/path/to/key.pem"
# cert = "/path/to/cert.pem"
# Sensitive value
# passphrase = "awawa"
# ca = "/path/to/ca.pem"
[frontend]
# Enable custom frontends (warning: not enabling this will make Versia Server only accessible via the Mastodon API)
# Frontends also control the OpenID flow, so if you disable this, you will need to use the Mastodon frontend
enabled = true
# Path that frontend files are served from
# Edit this property to serve custom frontends
# If this is not set, Versia Server will also check
# the VERSIA_FRONTEND_PATH environment variable
# path = ""
[frontend.routes]
# Special routes for your frontend, below are the defaults for Versia-FE
# Can be set to a route already used by Versia Server, as long as it is on a different HTTP method
# e.g. /oauth/authorize is a POST-only route, so you can serve a GET route at /oauth/authorize
# home = "/"
# login = "/oauth/authorize"
# consent = "/oauth/consent"
# register = "/register"
# password_reset = "/oauth/reset"
[frontend.settings]
# Arbitrary key/value pairs to be passed to the frontend
# This can be used to set up custom themes, etc on supported frontends.
# theme = "dark"
# NOT IMPLEMENTED
[email]
# Enable email sending
send_emails = false
# If send_emails is true, the following settings are required
# [email.smtp]
[smtp]
# SMTP server to use for sending emails
# server = "smtp.example.com"
# port = 465
# username = "test@example.com"
# Sensitive value
# password = "password123"
# tls = true
server = "smtp.example.com"
port = 465
username = "test@example.com"
password = "password123"
tls = true
[media]
# Can be "s3" or "local", where "local" uploads the file to the local filesystem
# Changing this value will not retroactively apply to existing data
# Don't forget to fill in the s3 config :3
# If you need to change this value after setting up your instance, you must move all the files
# from one backend to the other manually
backend = "local"
# Whether to check the hash of media when uploading to avoid duplication
deduplicate_media = true
# If media backend is "local", this is the folder where the files will be stored
# Can be any path
uploads_path = "uploads"
local_uploads_folder = "uploads"
[media.conversion]
# Whether to automatically convert images to another format on upload
convert_images = false
# Can be: "image/jxl", "image/webp", "image/avif", "image/png", "image/jpeg", "image/heif", "image/gif"
# Can be: "jxl", "webp", "avif", "png", "jpg", "heif"
# JXL support will likely not work
convert_to = "image/webp"
# Also convert SVG images?
convert_vectors = false
convert_to = "webp"
# [s3]
# Can be left commented if you don't use the S3 media backend
# endpoint = "https://s3.example.com"
# Sensitive value
# access_key = "XXXXX"
# Sensitive value
# secret_access_key = "XXX"
# region = "us-east-1"
# Can be left blank if you don't use the S3 media backend
# endpoint = "https://s3-us-west-2.amazonaws.com"
# access_key = ""
# secret_access_key = ""
# region = "us-west-2"
# bucket_name = "versia"
# public_url = "https://cdn.example.com"
[validation]
# Checks user data
# Does not retroactively apply to previously entered data
[validation.accounts]
max_displayname_characters = 50
max_username_characters = 30
max_bio_characters = 5000
max_avatar_bytes = 5_000_000
max_header_bytes = 5_000_000
# Regex is allowed here
disallowed_usernames = [
"well-known",
# Self explanatory
max_displayname_size = 50
max_bio_size = 160
max_note_size = 5000
max_avatar_size = 5_000_000
max_header_size = 5_000_000
max_media_size = 40_000_000
max_media_attachments = 10
max_media_description_size = 1000
max_poll_options = 20
max_poll_option_size = 500
min_poll_duration = 60
max_poll_duration = 1893456000
max_username_size = 30
# An array of strings, defaults are from Akkoma
username_blacklist = [
".well-known",
"~",
"about",
"activities",
"api",
@ -191,14 +120,12 @@ disallowed_usernames = [
"search",
"mfa",
]
max_field_count = 10
max_field_name_characters = 1000
max_field_value_characters = 1000
max_pinned_notes = 20
[validation.notes]
max_characters = 5000
allowed_url_schemes = [
# Whether to blacklist known temporary email providers
blacklist_tempmail = false
# Additional email providers to blacklist
email_blacklist = []
# Valid URL schemes, otherwise the URL is parsed as text
url_scheme_whitelist = [
"http",
"https",
"ftp",
@ -216,122 +143,62 @@ allowed_url_schemes = [
"mumble",
"ssb",
"gemini",
] # NOT IMPLEMENTED
enforce_mime_types = false
allowed_mime_types = [
"image/jpeg",
"image/png",
"image/gif",
"image/heic",
"image/heif",
"image/webp",
"image/avif",
"video/webm",
"video/mp4",
"video/quicktime",
"video/ogg",
"audio/wave",
"audio/wav",
"audio/x-wav",
"audio/x-pn-wave",
"audio/vnd.wave",
"audio/ogg",
"audio/vorbis",
"audio/mpeg",
"audio/mp3",
"audio/webm",
"audio/flac",
"audio/aac",
"audio/m4a",
"audio/x-m4a",
"audio/mp4",
"audio/3gpp",
"video/x-ms-asf",
]
max_attachments = 16
[validation.media]
max_bytes = 40_000_000
max_description_characters = 1000
# An empty array allows all MIME types
allowed_mime_types = []
[validation.emojis]
max_bytes = 1_000_000
max_shortcode_characters = 100
max_description_characters = 1000
[validation.polls]
max_options = 20
max_option_characters = 500
min_duration_seconds = 60
# 100 days
max_duration_seconds = 8_640_000
[validation.emails]
# Blocks over 10,000 common tempmail domains
disallow_tempmail = false
# Regex is allowed here
disallowed_domains = []
[validation.challenges]
# "Challenges" (aka captchas) are a way to verify that a user is human
# Versia Server's challenges use no external services, and are proof-of-work based
# Versia Server's challenges use no external services, and are Proof of Work based
# This means that they do not require any user interaction, instead
# they require the user's computer to do a small amount of work
# The difficulty of the challenge, higher is will take more time to solve
enabled = true
# The difficulty of the challenge, higher is harder
difficulty = 50000
# Challenge expiration time in seconds
expiration = 300 # 5 minutes
# Leave this empty to generate a new key
# Sensitive value
key = "YBpAV0KZOeM/MZ4kOb2E9moH9gCUr00Co9V7ncGRJ3wbd/a9tLDKKFdI0BtOcnlpfx0ZBh0+w3WSvsl0TsesTg=="
# Block content that matches these regular expressions
[validation.filters]
note_content = [
# "(https?://)?(www\\.)?youtube\\.com/watch\\?v=[a-zA-Z0-9_-]+",
# "(https?://)?(www\\.)?youtu\\.be/[a-zA-Z0-9_-]+",
]
emoji_shortcode = []
username = []
displayname = []
bio = []
[notifications]
# Web Push Notifications configuration.
# Leave out to disable.
[notifications.push]
# Subject field embedded in the push notification
# subject = "mailto:joe@example.com"
#
[notifications.push.vapid_keys]
# VAPID keys for push notifications
# Run Versia Server with those values missing to generate new keys
# Sensitive value
public = "BBanhyj2_xWwbTsWld3T49VcAoKZHrVJTzF1f6Av2JwQY_wUi3CF9vZ0WeEcACRj6EEqQ7N35CkUh5epF7n4P_s"
# Sensitive value
private = "Eujaz7NsF0rKZOVrAFL7mMpFdl96f591ERsRn81unq0"
[defaults]
# Default visibility for new notes
# Can be public, unlisted, private or direct
# Private only sends to followers, unlisted doesn't show up in timelines
visibility = "public"
# Default language for new notes (ISO code)
# Default language for new notes
language = "en"
# Default avatar, must be a valid URL or left out for a placeholder avatar
# Default avatar, must be a valid URL or ""
# avatar = ""
# Default header, must be a valid URL or left out for none
# Default header, must be a valid URL or ""
# header = ""
# A style name from https://www.dicebear.com/styles
placeholder_style = "thumbs"
[queues]
# Controls the delivery queue (for outbound federation)
[queues.delivery]
# Time in seconds to remove completed jobs
remove_after_complete_seconds = 31536000
# Time in seconds to remove failed jobs
remove_after_failure_seconds = 31536000
# Controls the inbox processing queue (for inbound federation)
[queues.inbox]
# Time in seconds to remove completed jobs
remove_after_complete_seconds = 31536000
# Time in seconds to remove failed jobs
remove_after_failure_seconds = 31536000
# Controls the fetch queue (for remote data refreshes)
[queues.fetch]
# Time in seconds to remove completed jobs
remove_after_complete_seconds = 31536000
# Time in seconds to remove failed jobs
remove_after_failure_seconds = 31536000
# Controls the push queue (for push notification delivery)
[queues.push]
# Time in seconds to remove completed jobs
remove_after_complete_seconds = 31536000
# Time in seconds to remove failed jobs
remove_after_failure_seconds = 31536000
# Controls the media queue (for media processing)
[queues.media]
# Time in seconds to remove completed jobs
remove_after_complete_seconds = 31536000
# Time in seconds to remove failed jobs
remove_after_failure_seconds = 31536000
[federation]
# This is a list of domain names, such as "mastodon.social" or "pleroma.site"
@ -356,119 +223,57 @@ reactions = []
banners = []
avatars = []
# For bridge software, such as versia-pub/activitypub
# Bridges must be hosted separately from the main Versia Server process
# [federation.bridge]
# Only versia-ap exists for now
# software = "versia-ap"
# If this is empty, any bridge with the correct token
# will be able to send data to your instance
# v4, v6, ranges and wildcards are supported
# allowed_ips = ["192.168.1.0/24"]
# Token for the bridge software
# Bridge must have the same token!
# Sensitive value
# token = "mycooltoken"
# url = "https://ap.versia.social"
[instance]
name = "Versia"
description = "A Versia Server instance"
description = "A test instance of Versia Server"
# URL to your instance logo (jpg files should be renamed to jpeg)
# logo = ""
# URL to your instance banner (jpg files should be renamed to jpeg)
# banner = ""
# Paths to instance long description, terms of service, and privacy policy
# These will be parsed as Markdown
#
# extended_description_path = "config/extended_description.md"
# tos_path = "config/tos.md"
# privacy_policy_path = "config/privacy_policy.md"
# Primary instance languages. ISO 639-1 codes.
languages = ["en"]
[filters]
# Regex filters for federated and local data
# Drops data matching the filters
# Does not apply retroactively to existing data
[instance.contact]
email = "staff@yourinstance.com"
[instance.branding]
# logo = "https://cdn.example.com/logo.png"
# banner = "https://cdn.example.com/banner.png"
# Used for federation. If left empty or missing, the server will generate one for you.
[instance.keys]
# Sensitive value
public = "MCowBQYDK2VwAyEASN0V5OWRbhRCnuhxfRLqpUOfszHozvrLLVhlIYLNTZM="
# Sensitive value
private = "MC4CAQAwBQYDK2VwBCIEIKaxDGMaW71OcCGMY+GKTZPtLPNlTvMFe3G5qXVHPhQM"
[[instance.rules]]
# Short description of the rule
text = "No hate speech"
# Longer version of the rule with additional information
hint = "Hate speech includes slurs, threats, and harassment."
[[instance.rules]]
text = "No spam"
# [[instance.rules]]
# ...etc
[permissions]
# Control default permissions for users
# Note that an anonymous user having a permission will not allow them
# to do things that require authentication (e.g. 'owner:notes' -> posting a note will need
# auth, but viewing a note will not)
# See https://server.versia.pub/api/roles#list-of-permissions for a list of all permissions
# Defaults to being able to login and manage their own content
# anonymous = []
# Defaults to identical to anonymous
# default = []
# Defaults to being able to manage all instance data, content, and users
# admin = []
# Note contents
note_content = [
# "(https?://)?(www\\.)?youtube\\.com/watch\\?v=[a-zA-Z0-9_-]+",
# "(https?://)?(www\\.)?youtu\\.be/[a-zA-Z0-9_-]+",
]
emoji = []
# These will drop users matching the filters
username = []
displayname = []
bio = []
[logging]
# Log all requests (warning: this is a lot of data)
log_requests = true
# Log request and their contents (warning: this is a lot of data)
log_requests_verbose = false
# For GDPR compliance, you can disable logging of IPs
log_ip = false
# Available levels: trace, debug, info, warning, error, fatal
log_level = "info" # For console output
# Log all filtered objects
log_filters = true
# [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
# [logging.sentry]
# dsn = "https://example.com"
# debug = false
# sample_rate = 1.0
# traces_sample_rate = 1.0
# Can also be regex
# trace_propagation_targets = []
# max_breadcrumbs = 100
# environment = "production"
# log_level = "info"
[ratelimits]
# These settings apply to every route at once
# Amount to multiply every route's duration by
duration_coeff = 1.0
# Amount to multiply every route's max requests per [duration] by
max_coeff = 1.0
[authentication]
# Run Versia Server with this value missing to generate a new key
key = "ZWcwanRaQAqY3ChUro/Jey9XGQjzsxEed5iqTp4yFr8W6vEnXdz91F/Pu/uf7HBMbNeIK7V6aHsM0lq9onrO8Q=="
[ratelimits.custom]
# Add in any API route in this style here
# Applies before the global ratelimit changes
# "/api/v1/accounts/:id/block" = { duration = 30, max = 60 }
# "/api/v1/timelines/public" = { duration = 60, max = 200 }
# The provider MUST support OpenID Connect with .well-known discovery
# Most notably, GitHub does not support this
# Redirect URLs in your OpenID provider can be set to this:
# <base_url>/oauth/sso/<provider_id>/callback*
# The asterisk is important, as it allows for any query parameters to be passed
# Authentik for example uses regex so it can be set to (regex):
# <base_url>/oauth/sso/<provider_id>/callback.*
# [[authentication.openid_providers]]
# name = "CPlusPatch ID"
# id = "cpluspatch-id"
# This MUST match the provider's issuer URI, including the trailing slash (or lack thereof)
# url = "https://id.cpluspatch.com/application/o/versia-testing/"
# client_id = "XXXX"
# Sensitive value
# client_secret = "XXXXX"
# icon = "https://cpluspatch.com/images/icons/logo.svg"
[plugins]
[plugins.config."@versia/openid".keys]
private = "MC4CAQAwBQYDK2VwBCIEID+H5n9PY3zVKZQcq4jrnE1IiRd2EWWr8ApuHUXmuOzl"
public = "MCowBQYDK2VwAyEAzenliNkgpXYsh3gXTnAoUWzlCPjIOppmAVx2DBlLsC8="

View file

@ -1,22 +0,0 @@
We use full TypeScript and ESM with Bun for our codebase. Please include relevant and detailed JSDoc comments for all functions and classes. Use explicit type annotations for all variables and function return values, such as:
```typescript
/**
* Adds two numbers together.
*
* @param {number} a
* @param {number} b
* @returns {number}
*/
const add = (a: number, b: number): number => a + b;
```
We always write TypeScript with double quotes and four spaces for indentation, so when your responses include TypeScript code, please follow those conventions.
Our codebase uses Drizzle as an ORM, which is exposed in the `@versia-server/kit/db` and `@versia-server/kit/tables` packages. This project uses a monorepo structure with Bun as the package manager.
The app has two modes: worker and API. The worker mode is used for background tasks, while the API mode serves HTTP requests. The entry point for the worker is `worker.ts`, and for the API, it is `api.ts`.
Run the typechecker with `bun run typecheck` to ensure that all TypeScript code is type-checked correctly. Run tests with `bun test` to ensure that all tests pass. Run the linter and formatter with `bun lint` to ensure that the code adheres to our style guidelines, and `bun lint --write` to automatically fix minor/formatting issues.
Cover all new functionality with tests, and ensure that all tests pass before submitting your code.

View file

@ -1,27 +1,31 @@
name: Check Types
name: Check Types
on:
workflow_call:
on:
push:
branches: ["*"]
pull_request:
# The branches below must be a subset of the branches above
branches: ["main"]
jobs:
jobs:
tests:
runs-on: ubuntu-latest
permissions:
contents: read
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install NPM packages
run: |
bun install
- name: Install NPM packages
run: |
bun install
- name: Run typechecks
run: |
bun run typecheck
- name: Run typechecks
run: |
bun run check

View file

@ -1,27 +0,0 @@
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

74
.github/workflows/docker-publish.yml vendored Normal file
View file

@ -0,0 +1,74 @@
name: Docker Build
on:
push:
branches: [ "main" ]
# Publish semver tags as releases.
tags: [ 'v*.*.*' ]
pull_request:
branches: [ "main" ]
env:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
# This is used to complete the identity challenge
# with sigstore/fulcio when running outside of PRs.
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: all
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 # v3.0.0
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@v3 # v3.0.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5 # v5.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Get the commit hash
run: echo "GIT_COMMIT=$(git rev-parse --short ${{ github.sha }})" >> $GITHUB_ENV
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v5 # v5.0.0
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_COMMIT=${{ env.GIT_COMMIT }}
provenance: mode=max
sbom: true
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max

View file

@ -1,98 +0,0 @@
name: Build Docker Images
on:
push:
branches: ["*"]
# Publish semver tags as releases.
tags: ["v*.*.*"]
pull_request:
branches: ["main"]
jobs:
lint:
uses: ./.github/workflows/lint.yml
check:
uses: ./.github/workflows/check.yml
tests:
uses: ./.github/workflows/tests.yml
detect-circular:
uses: ./.github/workflows/circular-imports.yml
build:
if: ${{ success() }}
needs: [lint, check, tests, detect-circular]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
# This is used to complete the identity challenge
# with sigstore/fulcio when running outside of PRs.
id-token: write
strategy:
matrix:
include:
- container: worker
image_name: ${{ github.repository_owner }}/worker
dockerfile: Worker.Dockerfile
- container: server
image_name: ${{ github.repository_owner }}/server
dockerfile: Dockerfile
env:
REGISTRY: ghcr.io
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: all
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ matrix.image_name }}
tags: |
type=schedule
type=ref,event=branch
type=ref,event=tag
type=ref,event=pr
type=sha
- name: Get the commit hash
run: echo "GIT_COMMIT=$(git rev-parse --short ${{ github.sha }})" >> $GITHUB_ENV
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
GIT_COMMIT=${{ env.GIT_COMMIT }}
file: ${{ matrix.dockerfile }}
provenance: mode=max
sbom: true
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max

View file

@ -1,56 +0,0 @@
name: Deploy Docs to GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: oven-sh/setup-bun@v2
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Install dependencies
run: bun install
- name: Build with VitePress
run: bun run --filter="@versia-server/api" docs:build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: packages/api/docs/.vitepress/dist
# Deployment job
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
needs: build
runs-on: ubuntu-latest
name: Deploy
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View file

@ -1,27 +1,31 @@
name: Lint & Format
on:
workflow_call:
push:
branches: ["*"]
pull_request:
# The branches below must be a subset of the branches above
branches: ["main"]
jobs:
tests:
runs-on: ubuntu-latest
permissions:
contents: read
tests:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install NPM packages
run: |
bun install
- name: Install NPM packages
run: |
bun install
- name: Run linting
run: |
bunx @biomejs/biome ci .
- name: Run linting
run: |
bunx @biomejs/biome ci .

View file

@ -2,7 +2,7 @@ name: Mirror to Codeberg
on: [push]
jobs:
mirror:
name: Mirror
uses: versia-pub/.github/.github/workflows/mirror.yml@main
secrets: inherit
mirror:
name: Mirror
uses: versia-pub/.github/.github/workflows/mirror.yml@main
secrets: inherit

View file

@ -1,25 +1,25 @@
name: Nix Build
name: Nix Build
on:
pull_request:
push:
branches: ["*"]
workflow_dispatch:
pull_request:
push:
branches: ["*"]
workflow_dispatch:
jobs:
check:
runs-on: ubuntu-latest
permissions:
id-token: "write"
contents: "read"
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main
with:
extra-conf: accept-flake-config = true
- uses: DeterminateSystems/magic-nix-cache-action@main
- uses: DeterminateSystems/flake-checker-action@main
- name: Build default package
run: nix build .
- name: Check flakes
run: nix flake check --allow-import-from-derivation
check:
runs-on: ubuntu-latest
permissions:
id-token: "write"
contents: "read"
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main
with:
extra-conf: accept-flake-config = true
- uses: DeterminateSystems/magic-nix-cache-action@main
- uses: DeterminateSystems/flake-checker-action@main
- name: Build default package
run: nix build .
- name: Check flakes
run: nix flake check --allow-import-from-derivation

View file

@ -1,48 +0,0 @@
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

50
.github/workflows/staging.yml vendored Normal file
View file

@ -0,0 +1,50 @@
name: Staging build bundle
on:
push:
branches: ["staging"]
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: Build dist
run: |
bun run build
- name: Bundle
run: |
mkdir bundle
cp -r dist bundle/
cp -r config bundle/
cp -r docs bundle/
cp -r CODE_OF_CONDUCT.md bundle/
cp -r CONTRIBUTING.md bundle/
cp -r README.md bundle/
cp -r flake.nix bundle/
cp -r shell.nix bundle/
cp -r flake.lock bundle/
cp -r LICENSE bundle/
cp -r SECURITY.md bundle/
tar cfJ archive.tar.xz bundle/
- name: Upload
uses: actions/upload-artifact@v4
with:
name: staging-dist
path: archive.tar.xz

View file

@ -1,36 +0,0 @@
name: Test Publish
on:
push:
permissions:
contents: read
# For provenance generation
id-token: write
jobs:
# Build job
build:
runs-on: ubuntu-latest
environment: NPM Deploy
strategy:
matrix:
package: ["sdk", "client"]
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/${{ matrix.package }}
env:
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
run: bun publish --dry-run --access public
- name: Publish to JSR
working-directory: packages/${{ matrix.package }}
run: bunx jsr publish --allow-slow-types --allow-dirty --dry-run

View file

@ -1,53 +1,51 @@
name: Tests
on:
workflow_call:
push:
branches: ["*"]
pull_request:
# The branches below must be a subset of the branches above
branches: ["main"]
jobs:
tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17-alpine
ports:
- 5432:5432
env:
POSTGRES_DB: versia
POSTGRES_USER: versia
POSTGRES_PASSWORD: versia
volumes:
- versia-data:/var/lib/postgresql/data
options: --health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:latest
ports:
- 6379:6379
options: --health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
tests:
runs-on: ubuntu-latest
services:
postgres:
image: ghcr.io/versia-pub/postgres:main
ports:
- 5432:5432
env:
POSTGRES_DB: versia
POSTGRES_USER: versia
POSTGRES_PASSWORD: versia
volumes:
- versia-data:/var/lib/postgresql/data
options: --health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
permissions:
contents: read
security-events: write
actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install NPM packages
run: |
bun install
- name: Install NPM packages
run: |
bun install
- name: Move workflow config to config folder
run: |
mv .github/config.workflow.toml config/config.toml
- name: Move workflow config to config folder
run: |
mv .github/config.workflow.toml config/config.toml
- name: Run tests
run: |
bun run test
- name: Run tests
run: |
bun run test

5
.gitignore vendored
View file

@ -183,8 +183,3 @@ config/extended_description_test.md
oclif.manifest.json
.direnv/
tsconfig.tsbuildinfo
# Vitepress Docs
*/.vitepress/dist
*/.vitepress/cache

View file

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

48
.vscode/launch.json vendored
View file

@ -1,48 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "bun",
"internalConsoleOptions": "neverOpen",
"request": "launch",
"name": "Debug File",
"program": "${file}",
"cwd": "${workspaceFolder}",
"stopOnEntry": false,
"watchMode": false
},
{
"type": "bun",
"internalConsoleOptions": "neverOpen",
"request": "launch",
"name": "Run File",
"program": "${file}",
"cwd": "${workspaceFolder}",
"watchMode": false
},
{
"type": "bun",
"internalConsoleOptions": "neverOpen",
"request": "attach",
"name": "Attach Bun",
"url": "ws://localhost:6499/",
"stopOnEntry": false
},
{
"type": "bun",
"internalConsoleOptions": "neverOpen",
"request": "launch",
"name": "Run index.ts",
"program": "${workspaceFolder}/index.ts",
"cwd": "${workspaceFolder}",
"watchMode": true
},
{
"type": "bun",
"internalConsoleOptions": "neverOpen",
"request": "launch",
"name": "Run tests",
"program": "test"
}
]
}

View file

@ -6,10 +6,7 @@
"cli",
"federation",
"config",
"worker",
"media",
"packages/client",
"packages/sdk"
"plugin"
],
"languageToolLinter.languageTool.ignoredWordsInWorkspace": ["versia"]
}

View file

@ -1,93 +1,4 @@
# `0.9.0` (upcoming)
## Features
### API
- [x] 🥺 Emoji Reactions are now available! You can react to any note with custom emojis.
- [x] 🔎 Added support for [batch account data API](https://docs.joinmastodon.org/methods/accounts/#index).
### Backend
- [x] 🚀 Upgraded Bun to `1.3.2`
# `0.8.0` • Federation 2: Electric Boogaloo
## Backwards Compatibility
Versia Server `0.8.0` is **not** backwards-compatible with `0.7.0`. This release includes some breaking changes to the database schema and configuration file.
Please see [Database Changes](#database-changes) and [New Configuration](#new-configuration) for more information.
## Features
### Federation
- [x] 🦄 Updated to [`Versia 0.5`](https://versia.pub/changelog).
- [x] 📦 Added support for new Versia features:
- [x] [**Instance Messaging Extension**](https://versia.pub/extensions/instance-messaging)
- [x] [**Shared Inboxes**](https://versia.pub/federation#inboxes)
- [x] 🔗 Changed entity URIs to be more readable (`example.org/objects/:id` → `example.org/{notes,likes,...}/:id`)
### API
- [x] 📲 Added [Push Notifications](https://docs.joinmastodon.org/methods/push) support.
- [x] 📖 Overhauled OpenAPI schemas to match [Mastodon API docs](https://docs.joinmastodon.org)
- [x] 👷 Improved [**Roles API**](https://server.versia.pub/api/roles) to allow for full role control (create, update, delete, assign).
- [x] ✏️ `<div>` and `<span>` tags are now allowed in Markdown.
- [x] 🔥 Removed nonstandard `/api/v1/accounts/id` endpoint (the same functionality was already possible with other endpoints).
- [x] ✨️ Implemented rate limiting support for API endpoints.
- [x] 🔒 Implemented `is_indexable` and `is_hiding_collections` fields to the [**Accounts API**](https://docs.joinmastodon.org/methods/accounts/#update_credentials).
- [x] ✨️ Muting other users now lets you specify a duration, after which the mute will be automatically removed.
- [x] 📰 All accounts now have an RSS/Atom feed attached to them.
### CLI
- [x] ⌨️ New commands!
- [x] ✨️ `cli user token` to generate API tokens.
- [x] 👷 Error messages are now prettier!
### Frontend
The way frontend is built and served has been changed. In the past, it was required to have a second process serving a frontend, which `versia-server` would proxy requests to. This is no longer the case.
Versia Server now serves static files directly from a configurable path, and `versia-fe` has been updated to support this.
### Backend
- [x] 🚀 Upgraded Bun to `1.2.13`
- [x] 🔥 Removed dependency on the `pg_uuidv7` extension. Versia Server can now be used with "vanilla" PostgreSQL.
- [x] 🖼️ Simplified media pipeline: this will improve S3 performance
- [x] 📈 It is now possible to disable media proxying for your CDN (offloading considerable bandwidth to your more optimized CDN).
- [x] 👷 Outbound federation, inbox processing, data fetching and media processing are now handled by a queue system.
- [x] 🌐 An administration panel is available at `/admin/queues` to monitor and manage queues.
- [x] 🔥 Removed support for **from-source** installations, as Versia Server is designed around containerization and maintaining support was a large burden.
- [x] ❄️ A [**Nix**](https://nixos.org/) package is now available for this project, packaged as a [Flake](https://wiki.nixos.org/wiki/Flakes). A **NixOS** module is also provided.
## New Configuration
Configuration parsing and validation has been overhauled. Unfortunately, this means that since a bunch of options have been renamed, you'll need to redownload [the default configuration file](config/config.example.toml) and reapply your changes.
## Database Changes
Various media-related attributes have been merged into a single `Medias` table. This will require a migration in order to preserve the old data.
Since very few instances are running `0.7.0`, we have decided to "rawdog it" instead of making a proper migration script (as that would take a ton of time that we don't have).
In the case that you've been running secret instances in the shadows, let us know and we'll help you out.
## Bug Fixes
- 🐛 All URIs in custom Markdown text are now correctly proxied.
- 🐛 Fixed several issues with the [ActivityPub Federation Bridge](https://github.com/versia-pub/activitypub) preventing it from operating properly.
- 🐛 Fixed incorrect content-type on some media when using S3.
- 🐛 All media content-type is now correctly fetched, instead of guessed from the file extension as before.
- 🐛 Fixed OpenAPI schema generation and `/docs` endpoint.
- 🐛 Logs folder is now automatically created if it doesn't exist.
- 🐛 Media hosted on the configured S3 bucket and on the local filesystem is no longer unnecessarily proxied.
- 🐛 Likes and Shares now federate properly.
# `0.7.0` • The Auth and APIs Update
# `0.7.0` (unreleased)
> [!WARNING]
> This release marks the rename of the project from `Lysand` to `Versia`.
@ -98,87 +9,89 @@ Versia Server `0.7.0` is backwards compatible with `0.6.0`. However, some new fe
## Features
- Upgraded Bun to `1.1.34`. This brings performance upgrades and better stability.
- Added support for the [ActivityPub Federation Bridge](https://github.com/versia-pub/activitypub).
- Added support for the [Sonic](https://github.com/valeriansaliou/sonic) search indexer.
- Note deletions are now federated.
- Note edits are now federated.
- Added support for [Sentry](https://sentry.io).
- Added option for more federation debug logging.
- Added [**Roles API**](https://server.versia.pub/api/roles).
- Added [**Permissions API**](https://server.versia.pub/api/roles) and enabled it for every route.
- Added [**TOS and Privacy Policy**](https://server.versia.pub/api/mastodon) endpoints.
- Added [**Challenge API**](https://server.versia.pub/api/challenges). (basically CAPTCHAS). This can be enabled/disabled by administrators. No `versia-fe` support yet.
- Added ability to refetch user data from remote instances.
- Added ability to change the `username` of a user. ([Mastodon API extension](https://server.versia.pub/api/mastodon#api-v1-accounts-update-credentials)).
- Added an endpoint to get a user by its username.
- Add OpenID Connect registration support. Admins can now disable username/password registration entirely and still allow users to sign up via OpenID Connect.
- Add option to never convert vector images to a raster format.
- Refactor logging system to be more robust and easier to use. Log files are now automatically rotated.
- Add support for HTTP proxies.
- Add support for serving Versia over a Tor hidden service.
- Add global server error handler, to properly return 500 error messages to clients.
- Sign all federation HTTP requests.
- Add JSON schema for configuration file.
- Rewrite federation stack
- Updated federation to Versia 0.4
- Implement OAuth2 token revocation
- Add new **Plugin API**
- Upgraded Bun to `1.1.34`. This brings performance upgrades and better stability.
- Added support for the [ActivityPub Federation Bridge](https://github.com/versia-pub/activitypub).
- Added support for the [Sonic](https://github.com/valeriansaliou/sonic) search indexer.
- Note deletions are now federated.
- Note edits are now federated.
- Added support for [Sentry](https://sentry.io).
- Added option for more federation debug logging.
- Added [**Roles API**](docs/api/roles.md).
- Added [**Permissions API**](docs/api/roles.md) and enabled it for every route.
- Added [**TOS and Privacy Policy**](docs/api/mastodon.md) endpoints.
- Added [**Challenge API**](docs/api/challenges.md). (basically CAPTCHAS). This can be enabled/disabled by administrators. No `versia-fe` support yet.
- Added ability to refetch user data from remote instances.
- Added ability to change the `username` of a user. ([Mastodon API extension](docs/api/mastodon.md)).
- Added an endpoint to get a user by its username.
- Add OpenID Connect registration support. Admins can now disable username/password registration entirely and still allow users to sign up via OpenID Connect.
- Add option to never convert vector images to a raster format.
- Refactor logging system to be more robust and easier to use. Log files are now automatically rotated.
- Add support for HTTP proxies.
- Add support for serving Versia over a Tor hidden service.
- Add global server error handler, to properly return 500 error messages to clients.
- Sign all federation HTTP requests.
- Add JSON schema for configuration file.
- Rewrite federation stack
- Updated federation to Versia 0.4
- Implement OAuth2 token revocation
- Add new **Plugin API**
## Plugin System
A new plugin system for extending Versia Server has been added in this release!
> [!NOTE]
>
>
> This is an internal feature and is not documented. Support for third-party plugins will be given on a "if we have time" basis, until the API is fully stabilized and documented
Plugins using this framework support:
- [x] Plugin hotswapping and hotreloading
- [x] Manifest files (JSON, JSON5, JSONC supported) with metadata (JSON schema provided)
- [x] Installation by dropping a folder into the plugins/ directory
- [x] Support for plugins having their own NPM dependencies
- [x] Support for storing plugins' configuration in the main config.toml (single source of truth)
- [x] Schema-based strict config validation (plugins can specify their own schemas)
- [x] Full type-safety
- [x] Custom hooks
- [x] FFI compatibility (with `bun:ffi` or Node's FFI)
- [x] Custom API route registration or overriding or middlewaring
- [x] Automatic OpenAPI schema generation for all installed plugins
- [x] End-to-end and unit testing supported
- [x] Automatic user input validation for API routes with schemas (specify a schema for the route and the server will take care of validating everything)
- [x] Access to internal database abstractions
- [x] Support for sending raw SQL to database (type-safe!)
- [x] Plugin autoload on startup with override controls (enable/disable)
- [x] Plugin hotswapping and hotreloading
- [x] Manifest files (JSON, JSON5, JSONC supported) with metadata (JSON schema provided)
- [x] Installation by dropping a folder into the plugins/ directory
- [x] Support for plugins having their own NPM dependencies
- [x] Support for storing plugins' configuration in the main config.toml (single source of truth)
- [x] Schema-based strict config validation (plugins can specify their own schemas)
- [x] Full type-safety
- [x] Custom hooks
- [x] FFI compatibility (with `bun:ffi` or Node's FFI)
- [x] Custom API route registration or overriding or middlewaring
- [x] Automatic OpenAPI schema generation for all installed plugins
- [x] End-to-end and unit testing supported
- [x] Automatic user input validation for API routes with schemas (specify a schema for the route and the server will take care of validating everything)
- [x] Access to internal database abstractions
- [x] Support for sending raw SQL to database (type-safe!)
- [x] Plugin autoload on startup with override controls (enable/disable)
The new `@versia/kit` NPM/JSR package is available for plugin developers.
As a demonstration of the power of this system and an effort to modularize the codebase further, OpenID functionality has been moved to a plugin. This plugin is required for login.
## Bug Fixes
- Fix favouriting/unfavouriting sometimes returning negative counts.
- Non-images will now properly be uploaded to object storage.
- Make account searches case-insensitive
- Fix image decoding error when passing media through proxy.
- OpenID Connect now correctly remembers and passes `state` parameter.
- OpenID Connect will not reject some correct but weird redirect URIs.
- Markdown posts will not have invisible anchor tags anymore (this messed up accessibility).
- Reverse proxies incorrectly reporting an HTTPS request as HTTP will now be handled correctly during OpenID Connect flows.
- API Relationships will now correctly return `requested_by`.
- Make process wait for Ctrl+C to exit on error, instead of exiting immediately. This fixes some issues with Docker restarting endlessly.
- Animated media will now stay animated when uploaded.
- Some instance metadata will no longer be missing from `/api/v2/instabnce` endpoint. In fact, it will now be more complete than Mastodon's implementation.
- The Origin HTTP header will no longer be used to determine the origin of a request. This was a security issue.
- New notes will no longer incorrectly be federated to _all_ remote users at once.
- Fix [Elk Client](https://elk.zone/) not being able to log in.
- Fix favouriting/unfavouriting sometimes returning negative counts.
- Non-images will now properly be uploaded to object storage.
- Make account searches case-insensitive
- Fix image decoding error when passing media through proxy.
- OpenID Connect now correctly remembers and passes `state` parameter.
- OpenID Connect will not reject some correct but weird redirect URIs.
- Markdown posts will not have invisible anchor tags anymore (this messed up accessibility).
- Reverse proxies incorrectly reporting an HTTPS request as HTTP will now be handled correctly during OpenID Connect flows.
- API Relationships will now correctly return `requested_by`.
- Make process wait for Ctrl+C to exit on error, instead of exiting immediately. This fixes some issues with Docker restarting endlessly.
- Animated media will now stay animated when uploaded.
- Some instance metadata will no longer be missing from `/api/v2/instabnce` endpoint. In fact, it will now be more complete than Mastodon's implementation.
- The Origin HTTP header will no longer be used to determine the origin of a request. This was a security issue.
- New notes will no longer incorrectly be federated to *all* remote users at once.
- Fix [Elk Client](https://elk.zone/) not being able to log in.
## Removals
- Remove old logging system, to be replaced by a new one.
- Removed Meilisearch support, in favor of Sonic. Follow instructions in the [installation guide](https://server.versia.pub/setup/installation) to set up Sonic.
- Removed explicit Glitch-FE support. Glitch-FE will still work, but must be hosted separately like any other frontend.
- Remove old logging system, to be replaced by a new one.
- Removed Meilisearch support, in favor of Sonic. Follow instructions in the [installation guide](docs/installation.md) to set up Sonic.
- Removed explicit Glitch-FE support. Glitch-FE will still work, but must be hosted separately like any other frontend.
## Miscellaneous
- Remove Node.js from Docker build.
- Update all dependencies.
- Remove Node.js from Docker build.
- Update all dependencies.

View file

@ -36,9 +36,9 @@ git clone https://github.com/versia-pub/server.git
bun install
```
1. Set up a PostgreSQL database (you need a special extension, please look at [the database documentation](https://server.versia.pub/setup/database))
4. Set up a PostgreSQL database (you need a special extension, please look at [the database documentation](docs/database.md))
2. Copy the `config/config.example.toml` file to `config/config.toml` and edit it to set up the database connection and other settings.
5. Copy the `config/config.example.toml` file to `config/config.toml` and edit it to set up the database connection and other settings.
## HTTPS development
@ -112,7 +112,7 @@ TypeScript errors should be ignored with `// @ts-expect-error` comments, as well
To scan for all TypeScript errors, run:
```sh
bun typecheck
bun check
```
### Commit messages
@ -153,4 +153,4 @@ If you find a bug, please open an issue on GitHub. Please make sure to include t
# License
Versia Server is licensed under the [AGPLv3 or later](https://www.gnu.org/licenses/agpl-3.0.en.html) license. By contributing to Versia, you agree to license your contributions under the same license.
Versia Server is licensed under the [AGPLv3 or later](https://www.gnu.org/licenses/agpl-3.0.en.html) license. By contributing to Versia, you agree to license your contributions under the same license.

View file

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

View file

@ -1,30 +1,8 @@
<div align="center">
<a href="https://versia.pub">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://cdn.versia.pub/branding/logo-dark.svg">
<source media="(prefers-color-scheme: light)" srcset="https://cdn.versia.pub/branding/logo-light.svg">
<img src="https://cdn.versia.pub/branding/logo-dark.svg" alt="Versia Logo" height="110" />
</picture>
</a>
</div>
<p align="center">
<a href="https://versia.pub"><img src="https://cdn.versia.pub/branding/logo-dark.svg" alt="Versia Logo" height="110"></a>
</p>
<h2 align="center">
<strong><code>Versia Server</code></strong>
</h2>
<div align="center">
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/typescript/typescript-original.svg" height="42" width="52" alt="TypeScript logo">
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/postgresql/postgresql-original.svg" height="42" width="52" alt="PostgreSQL logo">
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/docker/docker-original.svg" height="42" width="52" alt="Docker logo">
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/bun/bun-original.svg" height="42" width="52" alt="Bun logo">
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/vscode/vscode-original.svg" height="42" width="52" alt="VSCode logo">
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/sentry/sentry-original.svg" height="42" width="52" alt="Sentry logo">
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/linux/linux-original.svg" height="42" width="52" alt="Linux logo">
</div>
<br/>
![Postgres](https://img.shields.io/badge/postgres-%23316192.svg?style=for-the-badge&logo=postgresql&logoColor=white) ![Bun](https://img.shields.io/badge/Bun-%23000000.svg?style=for-the-badge&logo=bun&logoColor=white) ![VS Code Insiders](https://img.shields.io/badge/VS%20Code%20Insiders-35b393.svg?style=for-the-badge&logo=visual-studio-code&logoColor=white) ![TypeScript](https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white) ![Linux](https://img.shields.io/badge/Linux-FCC624?style=for-the-badge&logo=linux&logoColor=black) ![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa?style=for-the-badge)](code_of_conduct.md)
## What is this?
@ -82,7 +60,7 @@ You can visit [social.lysand.org](https://social.lysand.org) to see a live insta
## How do I run it?
Please see the [installation guide](https://server.versia.pub/setup/installation) for more information on how to install Versia.
Please see the [installation guide](docs/installation.md) for more information on how to install Versia.
## Contributing
@ -92,10 +70,8 @@ Contributions are welcome! Please see the [CONTRIBUTING.md](CONTRIBUTING.md) fil
The following extensions are currently supported or being worked on:
- `pub.versia:custom_emojis`: Custom emojis
- `pub.versia:instance_messaging`: Instance Messaging
- `pub.versia:likes`: Likes
- `pub.versia:polls`: Polls
- `pub.versia:share`: Share
- `pub.versia:reactions`: Reactions
## API
@ -212,7 +188,7 @@ Working endpoints are:
- [x] `/oauth/authorize`
- [x] `/oauth/token`
- [x] `/oauth/revoke`
- Admin API
- Admin API
### Main work to do for API
@ -232,26 +208,8 @@ Working endpoints are:
## Versia Server API
For Versia Server's own custom API, please see the [API documentation](https://server.versia.pub/api/emojis).
For Versia Server's own custom API, please see the [API documentation](docs/api/index.md).
## License
This project is licensed under the [AGPL-3.0-or-later](LICENSE).
All Versia assets (icon, logo, banners, etc) are licensed under [CC-BY-NC-SA-4.0](https://creativecommons.org/licenses/by-nc-sa/4.0)
## Thanks!
Thanks to [**Fastly**](https://fastly.com) for providing us with support and resources to build Versia!
<br />
<p align="center">
<a href="https://fastly.com">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/fastly-red.svg">
<source media="(prefers-color-scheme: light)" srcset="assets/fastly-red.svg">
<img src="assets/fastly-red.svg" alt="Fastly Logo" height="110" />
</picture>
</a>
</p>

View file

@ -1,51 +0,0 @@
# Node is required for building the project
FROM imbios/bun-node:latest-23-alpine AS base
# Install dependencies into temp directory
# This will cache them and speed up future builds
FROM base AS install
RUN mkdir -p /temp
COPY . /temp
WORKDIR /temp
RUN bun install --production
FROM base AS build
# Copy the project
RUN mkdir -p /temp
COPY . /temp
# Copy dependencies
COPY --from=install /temp/node_modules /temp/node_modules
# Build the project
WORKDIR /temp
RUN bun run build worker
WORKDIR /temp/dist
# Copy production dependencies and source code into final image
FROM oven/bun:1.3.2-alpine
# Install libstdc++ for Bun and create app directory
RUN mkdir -p /app
COPY --from=build /temp/dist /app/dist
COPY entrypoint.sh /app
LABEL org.opencontainers.image.authors="Gaspard Wierzbinski (https://cpluspatch.com)"
LABEL org.opencontainers.image.source="https://github.com/versia-pub/server"
LABEL org.opencontainers.image.vendor="Versia Pub"
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
LABEL org.opencontainers.image.title="Versia Server Worker"
LABEL org.opencontainers.image.description="Versia Server Worker Docker image"
# Set current Git commit hash as an environment variable
ARG GIT_COMMIT
ENV GIT_COMMIT=$GIT_COMMIT
# CD to app
WORKDIR /app
ENV NODE_ENV=production
ENTRYPOINT [ "/bin/sh", "/app/entrypoint.sh" ]
# Run migrations and start the server
CMD [ "bun", "run", "worker.js" ]

19
api.ts
View file

@ -1,19 +0,0 @@
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());

View file

@ -0,0 +1,228 @@
import { afterAll, describe, expect, test } from "bun:test";
import { randomString } from "@/math";
import { Application } from "@versia/kit/db";
import { config } from "~/packages/config-manager";
import { fakeRequest, getTestUsers } from "~/tests/utils";
import { meta } from "./index.ts";
const { users, deleteUsers, passwords } = await getTestUsers(1);
// Create application
const application = await Application.insert({
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(meta.route, () => {
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();
});
});
});

236
api/api/auth/login/index.ts Normal file
View file

@ -0,0 +1,236 @@
import { apiRoute, applyConfig } from "@/api";
import type { Context } from "@hono/hono";
import { setCookie } from "@hono/hono/cookie";
import { createRoute } from "@hono/zod-openapi";
import { Application, User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { eq, or } from "drizzle-orm";
import { SignJWT } from "jose";
import { z } from "zod";
import { config } from "~/packages/config-manager";
export const meta = applyConfig({
ratelimits: {
max: 4,
duration: 60,
},
route: "/api/auth/login",
auth: {
required: false,
},
});
export const schemas = {
form: z.object({
identifier: z
.string()
.email()
.toLowerCase()
.or(z.string().toLowerCase()),
password: z.string().min(2).max(100),
}),
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),
}),
};
const route = createRoute({
method: "post",
path: "/api/auth/login",
summary: "Login",
description: "Login to the application",
request: {
body: {
content: {
"multipart/form-data": {
schema: schemas.form,
},
},
},
query: schemas.query,
},
responses: {
302: {
description: "Redirect to OAuth authorize, or error",
headers: {
"Set-Cookie": {
description: "JWT cookie",
required: false,
},
},
},
},
});
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.openapi(route, 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 Bun.password.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: new URL(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) {
return context.json({ error: "Invalid application" }, 400);
}
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: "/",
maxAge: 60 * 60,
});
return context.redirect(
`${config.frontend.routes.consent}?${searchParams.toString()}`,
);
}),
);

View file

@ -0,0 +1,83 @@
import { apiRoute, applyConfig } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { db } from "@versia/kit/db";
import { Applications, Tokens } from "@versia/kit/tables";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { config } from "~/packages/config-manager";
export const meta = applyConfig({
ratelimits: {
max: 4,
duration: 60,
},
route: "/api/auth/redirect",
auth: {
required: false,
},
});
export const schemas = {
query: z.object({
redirect_uri: z.string().url(),
client_id: z.string(),
code: z.string(),
}),
};
const route = createRoute({
method: "get",
path: "/api/auth/redirect",
summary: "OAuth Code flow",
description:
"Redirects to the application, or back to login if the code is invalid",
responses: {
302: {
description:
"Redirects to the application, or back to login if the code is invalid",
},
},
request: {
query: schemas.query,
},
});
/**
* OAuth Code flow
*/
export default apiRoute((app) =>
app.openapi(route, 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()}`,
);
}),
);

View file

@ -0,0 +1,123 @@
import { afterAll, describe, expect, test } from "bun:test";
import { randomString } from "@/math";
import { Application } from "@versia/kit/db";
import { config } from "~/packages/config-manager";
import { fakeRequest, getTestUsers } from "~/tests/utils";
import { meta } from "./index.ts";
const { users, deleteUsers, passwords } = await getTestUsers(1);
const token = randomString(32, "hex");
const newPassword = randomString(16, "hex");
// Create application
const application = await Application.insert({
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(meta.route, () => {
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=[^;]+;/);
});
});

View file

@ -0,0 +1,98 @@
import { apiRoute, applyConfig } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { eq } from "drizzle-orm";
import type { Context } from "hono";
import { z } from "zod";
import { config } from "~/packages/config-manager";
export const meta = applyConfig({
ratelimits: {
max: 4,
duration: 60,
},
route: "/api/auth/reset",
auth: {
required: false,
},
});
export const schemas = {
form: z.object({
token: z.string().min(1),
password: z.string().min(3).max(100),
}),
};
const route = createRoute({
method: "post",
path: "/api/auth/reset",
summary: "Reset password",
description: "Reset password",
responses: {
302: {
description: "Redirect to the password reset page with a message",
},
},
request: {
body: {
content: {
"application/x-www-form-urlencoded": {
schema: schemas.form,
},
"multipart/form-data": {
schema: schemas.form,
},
},
},
},
});
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.openapi(route, 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 Bun.password.hash(password),
passwordResetToken: null,
});
return context.redirect(
`${config.frontend.routes.password_reset}?success=true`,
);
}),
);

View file

@ -0,0 +1,68 @@
import { afterAll, describe, expect, test } from "bun:test";
import type { Relationship as ApiRelationship } from "@versia/client/types";
import { fakeRequest, getTestUsers } from "~/tests/utils";
import { meta } from "./block.ts";
const { users, tokens, deleteUsers } = await getTestUsers(2);
afterAll(async () => {
await deleteUsers();
});
// /api/v1/accounts/:id/block
describe(meta.route, () => {
test("should return 401 if not authenticated", async () => {
const response = await fakeRequest(
meta.route.replace(":id", users[1].id),
{
method: "POST",
},
);
expect(response.status).toBe(401);
});
test("should return 404 if user not found", async () => {
const response = await fakeRequest(
meta.route.replace(":id", "00000000-0000-0000-0000-000000000000"),
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response.status).toBe(404);
});
test("should block user", async () => {
const response = await fakeRequest(
meta.route.replace(":id", users[1].id),
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response.status).toBe(200);
const relationship = (await response.json()) as ApiRelationship;
expect(relationship.blocking).toBe(true);
});
test("should return 200 if user already blocked", async () => {
const response = await fakeRequest(
meta.route.replace(":id", users[1].id),
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response.status).toBe(200);
const relationship = (await response.json()) as ApiRelationship;
expect(relationship.blocking).toBe(true);
});
});

View file

@ -0,0 +1,97 @@
import { apiRoute, applyConfig, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { Relationship, User } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables";
import { z } from "zod";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
ratelimits: {
max: 30,
duration: 60,
},
route: "/api/v1/accounts/:id/block",
auth: {
required: true,
oauthPermissions: ["write:blocks"],
},
permissions: {
required: [
RolePermissions.ManageOwnBlocks,
RolePermissions.ViewAccounts,
],
},
});
export const schemas = {
param: z.object({
id: z.string().uuid(),
}),
};
const route = createRoute({
method: "post",
path: "/api/v1/accounts/{id}/block",
summary: "Block user",
description: "Block a user",
middleware: [auth(meta.auth, meta.permissions)],
responses: {
200: {
description: "Updated relationship",
content: {
"application/json": {
schema: Relationship.schema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
404: {
description: "User not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
request: {
params: schemas.param,
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { id } = context.req.valid("param");
const { user } = context.get("auth");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
const otherUser = await User.fromId(id);
if (!otherUser) {
return context.json({ error: "User not found" }, 404);
}
const foundRelationship = await Relationship.fromOwnerAndSubject(
user,
otherUser,
);
if (!foundRelationship.data.blocking) {
await foundRelationship.update({
blocking: true,
});
}
return context.json(foundRelationship.toApi(), 200);
}),
);

View file

@ -0,0 +1,78 @@
import { afterAll, describe, expect, test } from "bun:test";
import type { Relationship as ApiRelationship } from "@versia/client/types";
import { fakeRequest, getTestUsers } from "~/tests/utils";
import { meta } from "./follow.ts";
const { users, tokens, deleteUsers } = await getTestUsers(2);
afterAll(async () => {
await deleteUsers();
});
// /api/v1/accounts/:id/follow
describe(meta.route, () => {
test("should return 401 if not authenticated", async () => {
const response = await fakeRequest(
meta.route.replace(":id", users[1].id),
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
},
);
expect(response.status).toBe(401);
});
test("should return 404 if user not found", async () => {
const response = await fakeRequest(
meta.route.replace(":id", "00000000-0000-0000-0000-000000000000"),
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
},
);
expect(response.status).toBe(404);
});
test("should follow user", async () => {
const response = await fakeRequest(
meta.route.replace(":id", users[1].id),
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
},
);
expect(response.status).toBe(200);
const relationship = (await response.json()) as ApiRelationship;
expect(relationship.following).toBe(true);
});
test("should return 200 if user already followed", async () => {
const response = await fakeRequest(
meta.route.replace(":id", users[1].id),
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
},
);
expect(response.status).toBe(200);
const relationship = (await response.json()) as ApiRelationship;
expect(relationship.following).toBe(true);
});
});

View file

@ -0,0 +1,118 @@
import { apiRoute, applyConfig, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { Relationship, User } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables";
import ISO6391 from "iso-639-1";
import { z } from "zod";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
ratelimits: {
max: 30,
duration: 60,
},
route: "/api/v1/accounts/:id/follow",
auth: {
required: true,
oauthPermissions: ["write:follows"],
},
permissions: {
required: [
RolePermissions.ManageOwnFollows,
RolePermissions.ViewAccounts,
],
},
});
export const schemas = {
param: z.object({
id: z.string().uuid(),
}),
json: z
.object({
reblogs: z.coerce.boolean().optional(),
notify: z.coerce.boolean().optional(),
languages: z
.array(z.enum(ISO6391.getAllCodes() as [string, ...string[]]))
.optional(),
})
.optional()
.default({ reblogs: true, notify: false, languages: [] }),
};
const route = createRoute({
method: "post",
path: "/api/v1/accounts/{id}/follow",
summary: "Follow user",
description: "Follow a user",
middleware: [auth(meta.auth, meta.permissions)],
responses: {
200: {
description: "Updated relationship",
content: {
"application/json": {
schema: Relationship.schema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
404: {
description: "User not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
request: {
params: schemas.param,
body: {
content: {
"application/json": {
schema: schemas.json,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { id } = context.req.valid("param");
const { user } = context.get("auth");
const { reblogs, notify, languages } = context.req.valid("json");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
const otherUser = await User.fromId(id);
if (!otherUser) {
return context.json({ error: "User not found" }, 404);
}
let relationship = await Relationship.fromOwnerAndSubject(
user,
otherUser,
);
if (!relationship.data.following) {
relationship = await user.followRequest(otherUser, {
reblogs,
notify,
languages,
});
}
return context.json(relationship.toApi(), 200);
}),
);

View file

@ -0,0 +1,80 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { Account as ApiAccount } from "@versia/client/types";
import { fakeRequest, getTestUsers } from "~/tests/utils";
import { meta } from "./followers.ts";
const { users, tokens, deleteUsers } = await getTestUsers(5);
afterAll(async () => {
await deleteUsers();
});
beforeAll(async () => {
// Follow user
const response = await fakeRequest(
`/api/v1/accounts/${users[1].id}/follow`,
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
},
);
expect(response.status).toBe(200);
});
// /api/v1/accounts/:id/followers
describe(meta.route, () => {
test("should return 200 with followers", async () => {
const response = await fakeRequest(
meta.route.replace(":id", users[1].id),
{
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response.status).toBe(200);
const data = (await response.json()) as ApiAccount[];
expect(data).toBeInstanceOf(Array);
expect(data.length).toBe(1);
expect(data[0].id).toBe(users[0].id);
});
test("should return no followers after unfollowing", async () => {
// Unfollow user
const response = await fakeRequest(
`/api/v1/accounts/${users[1].id}/unfollow`,
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response.status).toBe(200);
const response2 = await fakeRequest(
meta.route.replace(":id", users[1].id),
{
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response2.status).toBe(200);
const data = (await response2.json()) as ApiAccount[];
expect(data).toBeInstanceOf(Array);
expect(data.length).toBe(0);
});
});

View file

@ -0,0 +1,107 @@
import { apiRoute, applyConfig, auth, idValidator } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { Timeline, User } from "@versia/kit/db";
import { RolePermissions, Users } from "@versia/kit/tables";
import { and, gt, gte, lt, sql } from "drizzle-orm";
import { z } from "zod";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
ratelimits: {
max: 60,
duration: 60,
},
route: "/api/v1/accounts/:id/followers",
auth: {
required: false,
oauthPermissions: ["read:accounts"],
},
permissions: {
required: [
RolePermissions.ViewAccountFollows,
RolePermissions.ViewAccounts,
],
},
});
export const schemas = {
query: z.object({
max_id: z.string().regex(idValidator).optional(),
since_id: z.string().regex(idValidator).optional(),
min_id: z.string().regex(idValidator).optional(),
limit: z.coerce.number().int().min(1).max(40).optional().default(20),
}),
param: z.object({
id: z.string().uuid(),
}),
};
const route = createRoute({
method: "get",
path: "/api/v1/accounts/{id}/followers",
summary: "Get account followers",
description:
"Gets an paginated list of accounts that follow the specified account",
middleware: [auth(meta.auth, meta.permissions)],
request: {
params: schemas.param,
query: schemas.query,
},
responses: {
200: {
description: "A list of accounts that follow the specified account",
content: {
"application/json": {
schema: z.array(User.schema),
},
},
headers: {
Link: {
description: "Links to the next and previous pages",
},
},
},
404: {
description: "The specified account was not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { id } = context.req.valid("param");
const { max_id, since_id, min_id, limit } = context.req.valid("query");
const otherUser = await User.fromId(id);
// TODO: Add follower/following privacy settings
if (!otherUser) {
return context.json({ error: "User not found" }, 404);
}
const { objects, link } = await Timeline.getUserTimeline(
and(
max_id ? lt(Users.id, max_id) : undefined,
since_id ? gte(Users.id, since_id) : undefined,
min_id ? gt(Users.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${otherUser.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."following" = true)`,
),
limit,
context.req.url,
);
return context.json(
await Promise.all(objects.map((object) => object.toApi())),
200,
{
Link: link,
},
);
}),
);

View file

@ -0,0 +1,80 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { Account as ApiAccount } from "@versia/client/types";
import { fakeRequest, getTestUsers } from "~/tests/utils";
import { meta } from "./following.ts";
const { users, tokens, deleteUsers } = await getTestUsers(5);
afterAll(async () => {
await deleteUsers();
});
beforeAll(async () => {
// Follow user
const response = await fakeRequest(
`/api/v1/accounts/${users[1].id}/follow`,
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
},
);
expect(response.status).toBe(200);
});
// /api/v1/accounts/:id/following
describe(meta.route, () => {
test("should return 200 with following", async () => {
const response = await fakeRequest(
meta.route.replace(":id", users[0].id),
{
headers: {
Authorization: `Bearer ${tokens[1].data.accessToken}`,
},
},
);
expect(response.status).toBe(200);
const data = (await response.json()) as ApiAccount[];
expect(data).toBeInstanceOf(Array);
expect(data.length).toBe(1);
expect(data[0].id).toBe(users[1].id);
});
test("should return no following after unfollowing", async () => {
// Unfollow user
const response = await fakeRequest(
`/api/v1/accounts/${users[1].id}/unfollow`,
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response.status).toBe(200);
const response2 = await fakeRequest(
meta.route.replace(":id", users[0].id),
{
headers: {
Authorization: `Bearer ${tokens[1].data.accessToken}`,
},
},
);
expect(response2.status).toBe(200);
const data = (await response2.json()) as ApiAccount[];
expect(data).toBeInstanceOf(Array);
expect(data.length).toBe(0);
});
});

View file

@ -0,0 +1,108 @@
import { apiRoute, applyConfig, auth, idValidator } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { Timeline, User } from "@versia/kit/db";
import { RolePermissions, Users } from "@versia/kit/tables";
import { and, gt, gte, lt, sql } from "drizzle-orm";
import { z } from "zod";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
ratelimits: {
max: 60,
duration: 60,
},
route: "/api/v1/accounts/:id/following",
auth: {
required: false,
oauthPermissions: ["read:accounts"],
},
permissions: {
required: [
RolePermissions.ViewAccountFollows,
RolePermissions.ViewAccounts,
],
},
});
export const schemas = {
query: z.object({
max_id: z.string().regex(idValidator).optional(),
since_id: z.string().regex(idValidator).optional(),
min_id: z.string().regex(idValidator).optional(),
limit: z.coerce.number().int().min(1).max(40).optional().default(20),
}),
param: z.object({
id: z.string().uuid(),
}),
};
const route = createRoute({
method: "get",
path: "/api/v1/accounts/{id}/following",
summary: "Get account following",
description:
"Gets an paginated list of accounts that the specified account follows",
middleware: [auth(meta.auth, meta.permissions)],
request: {
params: schemas.param,
query: schemas.query,
},
responses: {
200: {
description:
"A list of accounts that the specified account follows",
content: {
"application/json": {
schema: z.array(User.schema),
},
},
headers: {
Link: {
description: "Link to the next page of results",
},
},
},
404: {
description: "User not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { id } = context.req.valid("param");
const { max_id, since_id, min_id } = context.req.valid("query");
const otherUser = await User.fromId(id);
if (!otherUser) {
return context.json({ error: "User not found" }, 404);
}
// TODO: Add follower/following privacy settings
const { objects, link } = await Timeline.getUserTimeline(
and(
max_id ? lt(Users.id, max_id) : undefined,
since_id ? gte(Users.id, since_id) : undefined,
min_id ? gt(Users.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${otherUser.id} AND "Relationships"."following" = true)`,
),
context.req.valid("query").limit,
context.req.url,
);
return context.json(
await Promise.all(objects.map((object) => object.toApi())),
200,
{
Link: link,
},
);
}),
);

View file

@ -1,42 +1,45 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import {
generateClient,
getTestStatuses,
getTestUsers,
} from "@versia-server/tests";
import type { Account as ApiAccount } from "@versia/client/types";
import { fakeRequest, getTestStatuses, getTestUsers } from "~/tests/utils";
import { meta } from "./index.ts";
const { users, deleteUsers } = await getTestUsers(5);
const timeline = (await getTestStatuses(5, users[0])).toReversed();
const { users, tokens, deleteUsers } = await getTestUsers(5);
const timeline = (await getTestStatuses(40, users[0])).toReversed();
afterAll(async () => {
await deleteUsers();
});
beforeAll(async () => {
await using client = await generateClient(users[0]);
for (const status of timeline) {
await client.favouriteStatus(status.id);
await fakeRequest(`/api/v1/statuses/${status.id}/favourite`, {
method: "POST",
headers: {
Authorization: `Bearer ${tokens[1].data.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
}
});
// /api/v1/accounts/:id
describe("/api/v1/accounts/:id", () => {
describe(meta.route, () => {
test("should return 404 if ID is invalid", async () => {
await using client = await generateClient(users[0]);
const { ok, raw } = await client.getAccount("invalid-id");
expect(ok).toBe(false);
expect(raw.status).toBe(422);
const response = await fakeRequest(
meta.route.replace(":id", "invalid"),
);
expect(response.status).toBe(422);
});
test("should return user", async () => {
await using client = await generateClient(users[0]);
const response = await fakeRequest(
meta.route.replace(":id", users[0].id),
);
const { ok, data } = await client.getAccount(users[0].id);
expect(response.status).toBe(200);
expect(ok).toBe(true);
const data = (await response.json()) as ApiAccount;
expect(data).toMatchObject({
id: users[0].id,
username: users[0].data.username,
@ -47,7 +50,7 @@ describe("/api/v1/accounts/:id", () => {
created_at: new Date(users[0].data.createdAt).toISOString(),
followers_count: 0,
following_count: 0,
statuses_count: 5,
statuses_count: 40,
note: users[0].data.note,
acct: users[0].data.username,
uri: expect.any(String),
@ -69,8 +72,9 @@ describe("/api/v1/accounts/:id", () => {
priority: 0,
description: "Default role for all users",
visible: false,
icon: null,
}),
]),
});
} satisfies ApiAccount);
});
});

View file

@ -0,0 +1,71 @@
import { apiRoute, applyConfig, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { User } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables";
import { z } from "zod";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
ratelimits: {
max: 30,
duration: 60,
},
route: "/api/v1/accounts/:id",
auth: {
required: false,
oauthPermissions: [],
},
permissions: {
required: [RolePermissions.ViewAccounts],
},
});
export const schemas = {
param: z.object({
id: z.string().uuid(),
}),
};
const route = createRoute({
method: "get",
path: "/api/v1/accounts/{id}",
summary: "Get account data",
description: "Gets the specified account data",
middleware: [auth(meta.auth, meta.permissions)],
request: {
params: schemas.param,
},
responses: {
200: {
description: "Account data",
content: {
"application/json": {
schema: User.schema,
},
},
},
404: {
description: "User not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { id } = context.req.valid("param");
const { user } = context.get("auth");
const foundUser = await User.fromId(id);
if (!foundUser) {
return context.json({ error: "User not found" }, 404);
}
return context.json(foundUser.toApi(user?.id === foundUser.id), 200);
}),
);

View file

@ -0,0 +1,78 @@
import { afterAll, describe, expect, test } from "bun:test";
import type { Relationship as ApiRelationship } from "@versia/client/types";
import { fakeRequest, getTestUsers } from "~/tests/utils";
import { meta } from "./mute.ts";
const { users, tokens, deleteUsers } = await getTestUsers(2);
afterAll(async () => {
await deleteUsers();
});
// /api/v1/accounts/:id/mute
describe(meta.route, () => {
test("should return 401 if not authenticated", async () => {
const response = await fakeRequest(
meta.route.replace(":id", users[1].id),
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
},
);
expect(response.status).toBe(401);
});
test("should return 404 if user not found", async () => {
const response = await fakeRequest(
meta.route.replace(":id", "00000000-0000-0000-0000-000000000000"),
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
},
);
expect(response.status).toBe(404);
});
test("should mute user", async () => {
const response = await fakeRequest(
meta.route.replace(":id", users[1].id),
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
},
);
expect(response.status).toBe(200);
const relationship = (await response.json()) as ApiRelationship;
expect(relationship.muting).toBe(true);
});
test("should return 200 if user already muted", async () => {
const response = await fakeRequest(
meta.route.replace(":id", users[1].id),
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
},
);
expect(response.status).toBe(200);
const relationship = (await response.json()) as ApiRelationship;
expect(relationship.muting).toBe(true);
});
});

View file

@ -0,0 +1,115 @@
import { apiRoute, applyConfig, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { Relationship, User } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables";
import { z } from "zod";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
ratelimits: {
max: 30,
duration: 60,
},
route: "/api/v1/accounts/:id/mute",
auth: {
required: true,
oauthPermissions: ["write:mutes"],
},
permissions: {
required: [
RolePermissions.ManageOwnMutes,
RolePermissions.ViewAccounts,
],
},
});
export const schemas = {
param: z.object({
id: z.string().uuid(),
}),
json: z.object({
notifications: z.boolean().optional(),
duration: z
.number()
.int()
.min(60)
.max(60 * 60 * 24 * 365 * 5)
.optional(),
}),
};
const route = createRoute({
method: "post",
path: "/api/v1/accounts/{id}/mute",
summary: "Mute user",
description: "Mute a user",
middleware: [auth(meta.auth, meta.permissions)],
request: {
params: schemas.param,
body: {
content: {
"application/json": {
schema: schemas.json,
},
},
},
},
responses: {
200: {
description: "Updated relationship",
content: {
"application/json": {
schema: Relationship.schema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
404: {
description: "User not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { id } = context.req.valid("param");
const { user } = context.get("auth");
// TODO: Add duration support
const { notifications } = context.req.valid("json");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
const otherUser = await User.fromId(id);
if (!otherUser) {
return context.json({ error: "User not found" }, 404);
}
const foundRelationship = await Relationship.fromOwnerAndSubject(
user,
otherUser,
);
// TODO: Implement duration
await foundRelationship.update({
muting: true,
mutingNotifications: notifications ?? true,
});
return context.json(foundRelationship.toApi(), 200);
}),
);

View file

@ -0,0 +1,106 @@
import { apiRoute, applyConfig, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { Relationship, User } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables";
import { z } from "zod";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
ratelimits: {
max: 30,
duration: 60,
},
route: "/api/v1/accounts/:id/note",
auth: {
required: true,
oauthPermissions: ["write:accounts"],
},
permissions: {
required: [
RolePermissions.ManageOwnAccount,
RolePermissions.ViewAccounts,
],
},
});
export const schemas = {
param: z.object({
id: z.string().uuid(),
}),
json: z.object({
comment: z.string().min(0).max(5000).trim().optional(),
}),
};
const route = createRoute({
method: "post",
path: "/api/v1/accounts/{id}/note",
summary: "Set note",
description: "Set a note on a user's profile, visible only to you",
middleware: [auth(meta.auth, meta.permissions)],
request: {
params: schemas.param,
body: {
content: {
"application/json": {
schema: schemas.json,
},
},
},
},
responses: {
200: {
description: "Updated relationship",
content: {
"application/json": {
schema: Relationship.schema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
404: {
description: "User not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { id } = context.req.valid("param");
const { user } = context.get("auth");
const { comment } = context.req.valid("json");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
const otherUser = await User.fromId(id);
if (!otherUser) {
return context.json({ error: "User not found" }, 404);
}
const foundRelationship = await Relationship.fromOwnerAndSubject(
user,
otherUser,
);
await foundRelationship.update({
note: comment,
});
return context.json(foundRelationship.toApi(), 200);
}),
);

View file

@ -0,0 +1,95 @@
import { apiRoute, applyConfig, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { Relationship, User } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables";
import { z } from "zod";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
ratelimits: {
max: 30,
duration: 60,
},
route: "/api/v1/accounts/:id/pin",
auth: {
required: true,
oauthPermissions: ["write:accounts"],
},
permissions: {
required: [
RolePermissions.ManageOwnAccount,
RolePermissions.ViewAccounts,
],
},
});
export const schemas = {
param: z.object({
id: z.string().uuid(),
}),
};
const route = createRoute({
method: "post",
path: "/api/v1/accounts/{id}/pin",
summary: "Pin user",
description: "Pin a user to your profile",
middleware: [auth(meta.auth, meta.permissions)],
request: {
params: schemas.param,
},
responses: {
200: {
description: "Updated relationship",
content: {
"application/json": {
schema: Relationship.schema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
404: {
description: "User not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { id } = context.req.valid("param");
const { user } = context.get("auth");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
const otherUser = await User.fromId(id);
if (!otherUser) {
return context.json({ error: "User not found" }, 404);
}
const foundRelationship = await Relationship.fromOwnerAndSubject(
user,
otherUser,
);
await foundRelationship.update({
endorsed: true,
});
return context.json(foundRelationship.toApi(), 200);
}),
);

View file

@ -0,0 +1,97 @@
import { apiRoute, applyConfig, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { User } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables";
import { z } from "zod";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
ratelimits: {
max: 4,
duration: 60,
},
route: "/api/v1/accounts/:id/refetch",
auth: {
required: true,
oauthPermissions: ["write:accounts"],
},
permissions: {
required: [RolePermissions.ViewAccounts],
},
});
export const schemas = {
param: z.object({
id: z.string().uuid(),
}),
};
const route = createRoute({
method: "post",
path: "/api/v1/accounts/{id}/refetch",
summary: "Refetch user",
description: "Refetch a user's profile from the remote server",
middleware: [auth(meta.auth, meta.permissions)],
request: {
params: schemas.param,
},
responses: {
200: {
description: "Updated user data",
content: {
"application/json": {
schema: User.schema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
404: {
description: "User not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
400: {
description: "User is local",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { id } = context.req.valid("param");
const { user } = context.get("auth");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
const otherUser = await User.fromId(id);
if (!otherUser) {
return context.json({ error: "User not found" }, 404);
}
if (otherUser.isLocal()) {
return context.json({ error: "Cannot refetch a local user" }, 400);
}
const newUser = await otherUser.updateFromRemote();
return context.json(newUser.toApi(false), 200);
}),
);

View file

@ -0,0 +1,102 @@
import { apiRoute, applyConfig, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { Relationship, User } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables";
import { z } from "zod";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
ratelimits: {
max: 30,
duration: 60,
},
route: "/api/v1/accounts/:id/remove_from_followers",
auth: {
required: true,
oauthPermissions: ["write:follows"],
},
permissions: {
required: [
RolePermissions.ManageOwnFollows,
RolePermissions.ViewAccounts,
],
},
});
export const schemas = {
param: z.object({
id: z.string().uuid(),
}),
};
const route = createRoute({
method: "post",
path: "/api/v1/accounts/{id}/remove_from_followers",
summary: "Remove user from followers",
description: "Remove a user from your followers",
middleware: [auth(meta.auth, meta.permissions)],
request: {
params: schemas.param,
},
responses: {
200: {
description: "Updated relationship",
content: {
"application/json": {
schema: Relationship.schema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
404: {
description: "User not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { id } = context.req.valid("param");
const { user: self } = context.get("auth");
if (!self) {
return context.json({ error: "Unauthorized" }, 401);
}
const otherUser = await User.fromId(id);
if (!otherUser) {
return context.json({ error: "User not found" }, 404);
}
const oppositeRelationship = await Relationship.fromOwnerAndSubject(
otherUser,
self,
);
if (oppositeRelationship.data.following) {
await oppositeRelationship.update({
following: false,
});
}
const foundRelationship = await Relationship.fromOwnerAndSubject(
self,
otherUser,
);
return context.json(foundRelationship.toApi(), 200);
}),
);

View file

@ -0,0 +1,152 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { Status as ApiStatus } from "@versia/client/types";
import { fakeRequest, getTestStatuses, getTestUsers } from "~/tests/utils.ts";
import { meta } from "./statuses.ts";
const { users, tokens, deleteUsers } = await getTestUsers(5);
const timeline = (await getTestStatuses(40, users[1])).toReversed();
const timeline2 = (await getTestStatuses(40, users[2])).toReversed();
afterAll(async () => {
await deleteUsers();
});
beforeAll(async () => {
const response = await fakeRequest(
`/api/v1/statuses/${timeline2[0].id}/reblog`,
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[1].data.accessToken}`,
},
},
);
expect(response.status).toBe(201);
});
// /api/v1/accounts/:id/statuses
describe(meta.route, () => {
test("should return 200 with statuses", async () => {
const response = await fakeRequest(
meta.route.replace(":id", users[1].id),
{
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response.status).toBe(200);
const data = (await response.json()) as ApiStatus[];
expect(data.length).toBe(20);
// Should have reblogs
expect(data[0].reblog).toBeDefined();
});
test("should exclude reblogs", async () => {
const response = await fakeRequest(
`${meta.route.replace(":id", users[1].id)}?exclude_reblogs=true`,
{
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response.status).toBe(200);
const data = (await response.json()) as ApiStatus[];
expect(data.length).toBe(20);
// Should not have reblogs
expect(data[0].reblog).toBeNull();
});
test("should exclude replies", async () => {
// Create reply
const replyResponse = await fakeRequest("/api/v1/statuses", {
method: "POST",
headers: {
Authorization: `Bearer ${tokens[1].data.accessToken}`,
},
body: new URLSearchParams({
status: "Reply",
in_reply_to_id: timeline[0].id,
local_only: "true",
}),
});
expect(replyResponse.status).toBe(201);
const response = await fakeRequest(
`${meta.route.replace(":id", users[1].id)}?exclude_replies=true`,
{
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response.status).toBe(200);
const data = (await response.json()) as ApiStatus[];
expect(data.length).toBe(20);
// Should not have replies
expect(data[0].in_reply_to_id).toBeNull();
});
test("should only include pins", async () => {
const response = await fakeRequest(
`${meta.route.replace(":id", users[1].id)}?pinned=true`,
{
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response.status).toBe(200);
const data = (await response.json()) as ApiStatus[];
expect(data.length).toBe(0);
// Create pin
const pinResponse = await fakeRequest(
`/api/v1/statuses/${timeline[3].id}/pin`,
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[1].data.accessToken}`,
},
},
);
expect(pinResponse.status).toBe(200);
const response2 = await fakeRequest(
`${meta.route.replace(":id", users[1].id)}?pinned=true`,
{
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response2.status).toBe(200);
const data2 = (await response2.json()) as ApiStatus[];
expect(data2.length).toBe(1);
});
});

View file

@ -0,0 +1,141 @@
import { apiRoute, applyConfig, auth, idValidator } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { Note, Timeline, User } from "@versia/kit/db";
import { Notes, RolePermissions } from "@versia/kit/tables";
import { and, eq, gt, gte, isNull, lt, sql } from "drizzle-orm";
import { z } from "zod";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
ratelimits: {
max: 30,
duration: 60,
},
route: "/api/v1/accounts/:id/statuses",
auth: {
required: false,
oauthPermissions: ["read:statuses"],
},
permissions: {
required: [RolePermissions.ViewNotes, RolePermissions.ViewAccounts],
},
});
export const schemas = {
param: z.object({
id: z.string().uuid(),
}),
query: z.object({
max_id: z.string().regex(idValidator).optional(),
since_id: z.string().regex(idValidator).optional(),
min_id: z.string().regex(idValidator).optional(),
limit: z.coerce.number().int().min(1).max(40).optional().default(20),
only_media: z
.string()
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
.optional(),
exclude_replies: z
.string()
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
.optional(),
exclude_reblogs: z
.string()
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
.optional(),
pinned: z
.string()
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
.optional(),
tagged: z
.string()
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
.optional(),
}),
};
const route = createRoute({
method: "get",
path: "/api/v1/accounts/{id}/statuses",
summary: "Get account statuses",
description: "Gets an paginated list of statuses by the specified account",
middleware: [auth(meta.auth, meta.permissions)],
request: {
params: schemas.param,
query: schemas.query,
},
responses: {
200: {
description: "A list of statuses by the specified account",
content: {
"application/json": {
schema: z.array(Note.schema),
},
},
headers: {
Link: {
description: "Links to the next and previous pages",
},
},
},
404: {
description: "User not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { id } = context.req.valid("param");
const { user } = context.get("auth");
const otherUser = await User.fromId(id);
if (!otherUser) {
return context.json({ error: "User not found" }, 404);
}
const {
max_id,
min_id,
since_id,
limit,
exclude_reblogs,
only_media,
exclude_replies,
pinned,
} = context.req.valid("query");
const { objects, link } = await Timeline.getNoteTimeline(
and(
max_id ? lt(Notes.id, max_id) : undefined,
since_id ? gte(Notes.id, since_id) : undefined,
min_id ? gt(Notes.id, min_id) : undefined,
eq(Notes.authorId, id),
only_media
? sql`EXISTS (SELECT 1 FROM "Attachments" WHERE "Attachments"."noteId" = ${Notes.id})`
: undefined,
pinned
? sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."noteId" = ${Notes.id} AND "UserToPinnedNotes"."userId" = ${otherUser.id})`
: undefined,
exclude_reblogs ? isNull(Notes.reblogId) : undefined,
exclude_replies ? isNull(Notes.replyId) : undefined,
),
limit,
context.req.url,
user?.id,
);
return context.json(
await Promise.all(objects.map((note) => note.toApi(otherUser))),
200,
{
link,
},
);
}),
);

View file

@ -0,0 +1,97 @@
import { apiRoute, applyConfig, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { Relationship, User } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables";
import { z } from "zod";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
ratelimits: {
max: 30,
duration: 60,
},
route: "/api/v1/accounts/:id/unblock",
auth: {
required: true,
oauthPermissions: ["write:blocks"],
},
permissions: {
required: [
RolePermissions.ManageOwnBlocks,
RolePermissions.ViewAccounts,
],
},
});
export const schemas = {
param: z.object({
id: z.string().uuid(),
}),
};
const route = createRoute({
method: "post",
path: "/api/v1/accounts/{id}/unblock",
summary: "Unblock user",
description: "Unblock a user",
middleware: [auth(meta.auth, meta.permissions)],
request: {
params: schemas.param,
},
responses: {
200: {
description: "Updated relationship",
content: {
"application/json": {
schema: Relationship.schema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
404: {
description: "User not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { id } = context.req.valid("param");
const { user } = context.get("auth");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
const otherUser = await User.fromId(id);
if (!otherUser) {
return context.json({ error: "User not found" }, 404);
}
const foundRelationship = await Relationship.fromOwnerAndSubject(
user,
otherUser,
);
if (foundRelationship.data.blocking) {
await foundRelationship.update({
blocking: false,
});
}
return context.json(foundRelationship.toApi(), 200);
}),
);

View file

@ -0,0 +1,103 @@
import { apiRoute, applyConfig, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { Relationship, User } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables";
import { z } from "zod";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
ratelimits: {
max: 30,
duration: 60,
},
route: "/api/v1/accounts/:id/unfollow",
auth: {
required: true,
oauthPermissions: ["write:follows"],
},
permissions: {
required: [
RolePermissions.ManageOwnFollows,
RolePermissions.ViewAccounts,
],
},
});
export const schemas = {
param: z.object({
id: z.string().uuid(),
}),
};
const route = createRoute({
method: "post",
path: "/api/v1/accounts/{id}/unfollow",
summary: "Unfollow user",
description: "Unfollow a user",
middleware: [auth(meta.auth, meta.permissions)],
request: {
params: schemas.param,
},
responses: {
200: {
description: "Updated relationship",
content: {
"application/json": {
schema: Relationship.schema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
404: {
description: "User not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
500: {
description: "Failed to unfollow user during federation",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { id } = context.req.valid("param");
const { user: self } = context.get("auth");
if (!self) {
return context.json({ error: "Unauthorized" }, 401);
}
const otherUser = await User.fromId(id);
if (!otherUser) {
return context.json({ error: "User not found" }, 404);
}
const foundRelationship = await Relationship.fromOwnerAndSubject(
self,
otherUser,
);
if (!(await self.unfollow(otherUser, foundRelationship))) {
return context.json({ error: "Failed to unfollow user" }, 500);
}
return context.json(foundRelationship.toApi(), 200);
}),
);

View file

@ -0,0 +1,77 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { Relationship as ApiRelationship } from "@versia/client/types";
import { fakeRequest, getTestUsers } from "~/tests/utils";
import { meta } from "./unmute.ts";
const { users, tokens, deleteUsers } = await getTestUsers(2);
afterAll(async () => {
await deleteUsers();
});
beforeAll(async () => {
await fakeRequest(`/api/v1/accounts/${users[0].id}/mute`, {
method: "POST",
headers: {
Authorization: `Bearer ${tokens[1].data.accessToken}`,
},
});
});
// /api/v1/accounts/:id/unmute
describe(meta.route, () => {
test("should return 401 if not authenticated", async () => {
const response = await fakeRequest(
meta.route.replace(":id", users[1].id),
{
method: "POST",
},
);
expect(response.status).toBe(401);
});
test("should return 404 if user not found", async () => {
const response = await fakeRequest(
meta.route.replace(":id", "00000000-0000-0000-0000-000000000000"),
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response.status).toBe(404);
});
test("should unmute user", async () => {
const response = await fakeRequest(
meta.route.replace(":id", users[1].id),
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response.status).toBe(200);
const relationship = (await response.json()) as ApiRelationship;
expect(relationship.muting).toBe(false);
});
test("should return 200 if user already unmuted", async () => {
const response = await fakeRequest(
meta.route.replace(":id", users[1].id),
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response.status).toBe(200);
const relationship = (await response.json()) as ApiRelationship;
expect(relationship.muting).toBe(false);
});
});

View file

@ -0,0 +1,98 @@
import { apiRoute, applyConfig, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { Relationship, User } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables";
import { z } from "zod";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
ratelimits: {
max: 30,
duration: 60,
},
route: "/api/v1/accounts/:id/unmute",
auth: {
required: true,
oauthPermissions: ["write:mutes"],
},
permissions: {
required: [
RolePermissions.ManageOwnMutes,
RolePermissions.ViewAccounts,
],
},
});
export const schemas = {
param: z.object({
id: z.string().uuid(),
}),
};
const route = createRoute({
method: "post",
path: "/api/v1/accounts/{id}/unmute",
summary: "Unmute user",
description: "Unmute a user",
middleware: [auth(meta.auth, meta.permissions)],
request: {
params: schemas.param,
},
responses: {
200: {
description: "Updated relationship",
content: {
"application/json": {
schema: Relationship.schema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
404: {
description: "User not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { id } = context.req.valid("param");
const { user: self } = context.get("auth");
if (!self) {
return context.json({ error: "Unauthorized" }, 401);
}
const user = await User.fromId(id);
if (!user) {
return context.json({ error: "User not found" }, 404);
}
const foundRelationship = await Relationship.fromOwnerAndSubject(
self,
user,
);
if (foundRelationship.data.muting) {
await foundRelationship.update({
muting: false,
mutingNotifications: false,
});
}
return context.json(foundRelationship.toApi(), 200);
}),
);

View file

@ -0,0 +1,97 @@
import { apiRoute, applyConfig, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { Relationship, User } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables";
import { z } from "zod";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
ratelimits: {
max: 30,
duration: 60,
},
route: "/api/v1/accounts/:id/unpin",
auth: {
required: true,
oauthPermissions: ["write:accounts"],
},
permissions: {
required: [
RolePermissions.ManageOwnAccount,
RolePermissions.ViewAccounts,
],
},
});
export const schemas = {
param: z.object({
id: z.string().uuid(),
}),
};
const route = createRoute({
method: "post",
path: "/api/v1/accounts/{id}/unpin",
summary: "Unpin user",
description: "Unpin a user from your profile",
middleware: [auth(meta.auth, meta.permissions)],
request: {
params: schemas.param,
},
responses: {
200: {
description: "Updated relationship",
content: {
"application/json": {
schema: Relationship.schema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
404: {
description: "User not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { id } = context.req.valid("param");
const { user: self } = context.get("auth");
if (!self) {
return context.json({ error: "Unauthorized" }, 401);
}
const otherUser = await User.fromId(id);
if (!otherUser) {
return context.json({ error: "User not found" }, 404);
}
const foundRelationship = await Relationship.fromOwnerAndSubject(
self,
otherUser,
);
if (foundRelationship.data.endorsed) {
await foundRelationship.update({
endorsed: false,
});
}
return context.json(foundRelationship.toApi(), 200);
}),
);

View file

@ -0,0 +1,156 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { fakeRequest, getTestUsers } from "~/tests/utils.ts";
import { meta } from "./index.ts";
const { users, tokens, deleteUsers } = await getTestUsers(5);
beforeAll(async () => {
// Create followers relationships
const result1 = await fakeRequest(
`/api/v1/accounts/${users[1].id}/follow`,
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(result1.status).toBe(200);
const result2 = await fakeRequest(
`/api/v1/accounts/${users[2].id}/follow`,
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(result2.status).toBe(200);
const result3 = await fakeRequest(
`/api/v1/accounts/${users[3].id}/follow`,
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(result3.status).toBe(200);
const result4 = await fakeRequest(
`/api/v1/accounts/${users[2].id}/follow`,
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[1].data.accessToken}`,
},
},
);
expect(result4.status).toBe(200);
const result5 = await fakeRequest(
`/api/v1/accounts/${users[3].id}/follow`,
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[1].data.accessToken}`,
},
},
);
expect(result5.status).toBe(200);
const result6 = await fakeRequest(
`/api/v1/accounts/${users[3].id}/follow`,
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[2].data.accessToken}`,
},
},
);
expect(result6.status).toBe(200);
});
afterAll(async () => {
await deleteUsers();
});
describe(meta.route, () => {
test("should return 0 familiar followers", async () => {
const response = await fakeRequest(`${meta.route}?id=${users[4].id}`, {
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
});
expect(response.status).toBe(200);
const data = await response.json();
expect(data.length).toBe(1);
expect(data[0].id).toBe(users[4].id);
expect(data[0].accounts).toBeArrayOfSize(0);
});
test("should return 1 familiar follower", async () => {
const response = await fakeRequest(`${meta.route}?id=${users[2].id}`, {
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
});
expect(response.status).toBe(200);
const data = await response.json();
expect(data.length).toBe(1);
expect(data[0].id).toBe(users[2].id);
expect(data[0].accounts[0].id).toBe(users[1].id);
});
test("should return 2 familiar followers", async () => {
const response = await fakeRequest(`${meta.route}?id=${users[3].id}`, {
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
});
expect(response.status).toBe(200);
const data = await response.json();
expect(data.length).toBe(1);
expect(data[0].id).toBe(users[3].id);
expect(data[0].accounts).toBeArrayOfSize(2);
expect(data[0].accounts[0].id).toBe(users[2].id);
expect(data[0].accounts[1].id).toBe(users[1].id);
});
test("should work with multiple ids", async () => {
const response = await fakeRequest(
`${meta.route}?id[]=${users[2].id}&id[]=${users[3].id}&id[]=${users[4].id}`,
{
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.length).toBe(3);
expect(data[0].id).toBe(users[2].id);
expect(data[0].accounts[0].id).toBe(users[1].id);
expect(data[1].id).toBe(users[3].id);
expect(data[1].accounts[0].id).toBe(users[2].id);
expect(data[1].accounts[1].id).toBe(users[1].id);
expect(data[2].id).toBe(users[4].id);
expect(data[2].accounts).toBeArrayOfSize(0);
});
});

View file

@ -0,0 +1,111 @@
import { apiRoute, applyConfig, auth, qsQuery } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { User, db } from "@versia/kit/db";
import { RolePermissions, type Users } from "@versia/kit/tables";
import { type InferSelectModel, sql } from "drizzle-orm";
import { z } from "zod";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
route: "/api/v1/accounts/familiar_followers",
ratelimits: {
max: 5,
duration: 60,
},
auth: {
required: true,
oauthPermissions: ["read:follows"],
},
permissions: {
required: [RolePermissions.ManageOwnFollows],
},
});
export const schemas = {
query: z.object({
id: z
.array(z.string().uuid())
.min(1)
.max(10)
.or(z.string().uuid())
.transform((v) => (Array.isArray(v) ? v : [v])),
}),
};
const route = createRoute({
method: "get",
path: "/api/v1/accounts/familiar_followers",
summary: "Get familiar followers",
description:
"Obtain a list of all accounts that follow a given account, filtered for accounts you follow.",
middleware: [auth(meta.auth, meta.permissions), qsQuery()],
request: {
query: schemas.query,
},
responses: {
200: {
description: "Familiar followers",
content: {
"application/json": {
schema: z.array(
z.object({
id: z.string().uuid(),
accounts: z.array(User.schema),
}),
),
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { user: self } = context.get("auth");
const { id: ids } = context.req.valid("query");
if (!self) {
return context.json({ error: "Unauthorized" }, 401);
}
// Find followers of the accounts in "ids", that you also follow
const finalUsers = await Promise.all(
ids.map(async (id) => ({
id,
accounts: await User.fromIds(
(
await db.execute(sql<InferSelectModel<typeof Users>>`
SELECT "Users"."id" FROM "Users"
INNER JOIN "Relationships" AS "SelfFollowing"
ON "SelfFollowing"."subjectId" = "Users"."id"
WHERE "SelfFollowing"."ownerId" = ${self.id}
AND "SelfFollowing"."following" = true
AND EXISTS (
SELECT 1 FROM "Relationships" AS "IdsFollowers"
WHERE "IdsFollowers"."subjectId" = ${id}
AND "IdsFollowers"."ownerId" = "Users"."id"
AND "IdsFollowers"."following" = true
)
`)
).rows.map((u) => u.id as string),
),
})),
);
return context.json(
finalUsers.map((u) => ({
...u,
accounts: u.accounts.map((a) => a.toApi()),
})),
200,
);
}),
);

View file

@ -0,0 +1,33 @@
import { afterAll, describe, expect, test } from "bun:test";
import type { Account as ApiAccount } from "@versia/client/types";
import { fakeRequest, getTestUsers } from "~/tests/utils";
import { meta } from "./index.ts";
const { users, deleteUsers } = await getTestUsers(5);
afterAll(async () => {
await deleteUsers();
});
// /api/v1/accounts/id
describe(meta.route, () => {
test("should correctly get user from username", async () => {
const response = await fakeRequest(
`${meta.route}?username=${users[0].data.username}`,
);
expect(response.status).toBe(200);
const data = (await response.json()) as ApiAccount;
expect(data.id).toBe(users[0].id);
});
test("should return 404 for non-existent user", async () => {
const response = await fakeRequest(
`${meta.route}?username=${users[0].data.username}-nonexistent`,
);
expect(response.status).toBe(404);
});
});

View file

@ -0,0 +1,73 @@
import { apiRoute, applyConfig, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { User } from "@versia/kit/db";
import { RolePermissions, Users } from "@versia/kit/tables";
import { and, eq, isNull } from "drizzle-orm";
import { z } from "zod";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
ratelimits: {
max: 30,
duration: 60,
},
route: "/api/v1/accounts/id",
auth: {
required: false,
oauthPermissions: [],
},
permissions: {
required: [RolePermissions.Search],
},
});
export const schemas = {
query: z.object({
username: z.string().min(1).max(512),
}),
};
const route = createRoute({
method: "get",
path: "/api/v1/accounts/id",
summary: "Get account by username",
description: "Get an account by username",
middleware: [auth(meta.auth, meta.permissions)],
request: {
query: schemas.query,
},
responses: {
200: {
description: "Account",
content: {
"application/json": {
schema: User.schema,
},
},
},
404: {
description: "Not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { username } = context.req.valid("query");
const user = await User.fromSql(
and(eq(Users.username, username), isNull(Users.instanceId)),
);
if (!user) {
return context.json({ error: "User not found" }, 404);
}
return context.json(user.toApi(), 200);
}),
);

View file

@ -0,0 +1,223 @@
import { afterEach, describe, expect, test } from "bun:test";
import { randomString } from "@/math";
import { db } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { eq } from "drizzle-orm";
import { fakeRequest, getSolvedChallenge } from "~/tests/utils";
import { meta } from "./index.ts";
const username = randomString(10, "hex");
const username2 = randomString(10, "hex");
afterEach(async () => {
await db.delete(Users).where(eq(Users.username, username));
await db.delete(Users).where(eq(Users.username, username2));
});
// /api/v1/statuses
describe(meta.route, () => {
test("should create a new account", async () => {
const response = await fakeRequest(meta.route, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Challenge-Solution": await getSolvedChallenge(),
},
body: JSON.stringify({
username,
email: "bob@gamer.com",
password: "password",
agreement: "true",
locale: "en",
reason: "testing",
}),
});
expect(response.ok).toBe(true);
});
test("should refuse invalid emails", async () => {
const response = await fakeRequest(meta.route, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Challenge-Solution": await getSolvedChallenge(),
},
body: JSON.stringify({
username,
email: "bob",
password: "password",
agreement: "true",
locale: "en",
reason: "testing",
}),
});
expect(response.status).toBe(422);
});
test("should require a password", async () => {
const response = await fakeRequest(meta.route, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Challenge-Solution": await getSolvedChallenge(),
},
body: JSON.stringify({
username,
email: "contatc@bob.com",
agreement: "true",
locale: "en",
reason: "testing",
}),
});
expect(response.status).toBe(422);
});
test("should not allow a previously registered email", async () => {
await fakeRequest(meta.route, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Challenge-Solution": await getSolvedChallenge(),
},
body: JSON.stringify({
username,
email: "contact@george.com",
password: "password",
agreement: "true",
locale: "en",
reason: "testing",
}),
});
const response = await fakeRequest(meta.route, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Challenge-Solution": await getSolvedChallenge(),
},
body: JSON.stringify({
username: username2,
email: "contact@george.com",
password: "password",
agreement: "true",
locale: "en",
reason: "testing",
}),
});
expect(response.status).toBe(422);
});
test("should not allow a previously registered email (case insensitive)", async () => {
await fakeRequest(meta.route, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Challenge-Solution": await getSolvedChallenge(),
},
body: JSON.stringify({
username,
email: "contact@george.com",
password: "password",
agreement: "true",
locale: "en",
reason: "testing",
}),
});
const response = await fakeRequest(meta.route, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Challenge-Solution": await getSolvedChallenge(),
},
body: JSON.stringify({
username: username2,
email: "CONTACT@george.CoM",
password: "password",
agreement: "true",
locale: "en",
reason: "testing",
}),
});
expect(response.status).toBe(422);
});
test("should not allow invalid usernames (not a-z_0-9)", async () => {
const response1 = await fakeRequest(meta.route, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Challenge-Solution": await getSolvedChallenge(),
},
body: JSON.stringify({
username: "bob$",
email: "contact@bob.com",
password: "password",
agreement: "true",
locale: "en",
reason: "testing",
}),
});
expect(response1.status).toBe(422);
const response2 = await fakeRequest(meta.route, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Challenge-Solution": await getSolvedChallenge(),
},
body: JSON.stringify({
username: "bob-markey",
email: "contact@bob.com",
password: "password",
agreement: "true",
locale: "en",
reason: "testing",
}),
});
expect(response2.status).toBe(422);
const response3 = await fakeRequest(meta.route, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Challenge-Solution": await getSolvedChallenge(),
},
body: JSON.stringify({
username: "bob markey",
email: "contact@bob.com",
password: "password",
agreement: "true",
locale: "en",
reason: "testing",
}),
});
expect(response3.status).toBe(422);
const response4 = await fakeRequest(meta.route, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Challenge-Solution": await getSolvedChallenge(),
},
body: JSON.stringify({
username: "BOB",
email: "contact@bob.com",
password: "password",
agreement: "true",
locale: "en",
reason: "testing",
}),
});
expect(response4.status).toBe(422);
});
});

View file

@ -0,0 +1,342 @@
import { apiRoute, applyConfig, auth, jsonOrForm } from "@/api";
import { tempmailDomains } from "@/tempmail";
import { createRoute } from "@hono/zod-openapi";
import { User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { and, eq, isNull } from "drizzle-orm";
import ISO6391 from "iso-639-1";
import { z } from "zod";
import { config } from "~/packages/config-manager";
export const meta = applyConfig({
route: "/api/v1/accounts",
ratelimits: {
max: 2,
duration: 60,
},
auth: {
required: false,
oauthPermissions: ["write:accounts"],
},
challenge: {
required: true,
},
});
export const schemas = {
json: z.object({
username: z.string(),
email: z.string().toLowerCase(),
password: z.string().optional(),
agreement: z
.string()
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
.or(z.boolean()),
locale: z.string(),
reason: z.string(),
}),
};
const route = createRoute({
method: "post",
path: "/api/v1/accounts",
summary: "Create account",
description: "Register a new account",
middleware: [
auth(meta.auth, meta.permissions, meta.challenge),
jsonOrForm(),
],
request: {
body: {
content: {
"application/json": {
schema: schemas.json,
},
"multipart/form-data": {
schema: schemas.json,
},
"application/x-www-form-urlencoded": {
schema: schemas.json,
},
},
},
},
responses: {
200: {
description: "Account created",
},
422: {
description: "Validation failed",
content: {
"application/json": {
schema: z.object({
error: z.string(),
details: z.object({
username: z.array(
z.object({
error: z.enum([
"ERR_BLANK",
"ERR_INVALID",
"ERR_TOO_LONG",
"ERR_TOO_SHORT",
"ERR_BLOCKED",
"ERR_TAKEN",
"ERR_RESERVED",
"ERR_ACCEPTED",
"ERR_INCLUSION",
]),
description: z.string(),
}),
),
email: z.array(
z.object({
error: z.enum([
"ERR_BLANK",
"ERR_INVALID",
"ERR_BLOCKED",
"ERR_TAKEN",
]),
description: z.string(),
}),
),
password: z.array(
z.object({
error: z.enum([
"ERR_BLANK",
"ERR_INVALID",
"ERR_TOO_LONG",
"ERR_TOO_SHORT",
]),
description: z.string(),
}),
),
agreement: z.array(
z.object({
error: z.enum(["ERR_ACCEPTED"]),
description: z.string(),
}),
),
locale: z.array(
z.object({
error: z.enum(["ERR_BLANK", "ERR_INVALID"]),
description: z.string(),
}),
),
reason: z.array(
z.object({
error: z.enum(["ERR_BLANK"]),
description: z.string(),
}),
),
}),
}),
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const form = context.req.valid("json");
const { username, email, password, agreement, locale } =
context.req.valid("json");
if (!config.signups.registration) {
return context.json(
{
error: "Registration is disabled",
},
422,
);
}
const errors: {
details: Record<
string,
{
error:
| "ERR_BLANK"
| "ERR_INVALID"
| "ERR_TOO_LONG"
| "ERR_TOO_SHORT"
| "ERR_BLOCKED"
| "ERR_TAKEN"
| "ERR_RESERVED"
| "ERR_ACCEPTED"
| "ERR_INCLUSION";
description: string;
}[]
>;
} = {
details: {
password: [],
username: [],
email: [],
agreement: [],
locale: [],
reason: [],
},
};
// Check if fields are blank
for (const value of [
"username",
"email",
"password",
"agreement",
"locale",
"reason",
]) {
// @ts-expect-error We don't care about the type here
if (!form[value]) {
errors.details[value].push({
error: "ERR_BLANK",
description: "can't be blank",
});
}
}
// Check if username is valid
if (!username?.match(/^[a-z0-9_]+$/)) {
errors.details.username.push({
error: "ERR_INVALID",
description:
"must only contain lowercase letters, numbers, and underscores",
});
}
// Check if username doesnt match filters
if (config.filters.username.some((filter) => username?.match(filter))) {
errors.details.username.push({
error: "ERR_INVALID",
description: "contains blocked words",
});
}
// Check if username is too long
if ((username?.length ?? 0) > config.validation.max_username_size) {
errors.details.username.push({
error: "ERR_TOO_LONG",
description: `is too long (maximum is ${config.validation.max_username_size} characters)`,
});
}
// Check if username is too short
if ((username?.length ?? 0) < 3) {
errors.details.username.push({
error: "ERR_TOO_SHORT",
description: "is too short (minimum is 3 characters)",
});
}
// Check if username is reserved
if (config.validation.username_blacklist.includes(username ?? "")) {
errors.details.username.push({
error: "ERR_RESERVED",
description: "is reserved",
});
}
// Check if username is taken
if (
await User.fromSql(
and(eq(Users.username, username)),
isNull(Users.instanceId),
)
) {
errors.details.username.push({
error: "ERR_TAKEN",
description: "is already taken",
});
}
// Check if email is valid
if (
!email?.match(
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,
)
) {
errors.details.email.push({
error: "ERR_INVALID",
description: "must be a valid email address",
});
}
// Check if email is blocked
if (
config.validation.email_blacklist.includes(email) ||
(config.validation.blacklist_tempmail &&
tempmailDomains.domains.includes((email ?? "").split("@")[1]))
) {
errors.details.email.push({
error: "ERR_BLOCKED",
description: "is from a blocked email provider",
});
}
// Check if email is taken
if (await User.fromSql(eq(Users.email, email))) {
errors.details.email.push({
error: "ERR_TAKEN",
description: "is already taken",
});
}
// Check if agreement is accepted
if (!agreement) {
errors.details.agreement.push({
error: "ERR_ACCEPTED",
description: "must be accepted",
});
}
if (!locale) {
errors.details.locale.push({
error: "ERR_BLANK",
description: "can't be blank",
});
}
if (!ISO6391.validate(locale ?? "")) {
errors.details.locale.push({
error: "ERR_INVALID",
description: "must be a valid ISO 639-1 code",
});
}
// If any errors are present, return them
if (Object.values(errors.details).some((value) => value.length > 0)) {
// Error is something like "Validation failed: Password can't be blank, Username must contain only letters, numbers and underscores, Agreement must be accepted"
const errorsText = Object.entries(errors.details)
.filter(([_, errors]) => errors.length > 0)
.map(
([name, errors]) =>
`${name} ${errors
.map((error) => error.description)
.join(", ")}`,
)
.join(", ");
return context.json(
{
error: `Validation failed: ${errorsText}`,
details: Object.fromEntries(
Object.entries(errors.details).filter(
([_, errors]) => errors.length > 0,
),
),
},
422,
);
}
await User.fromDataLocal({
username: username ?? "",
password: password ?? "",
email: email ?? "",
});
return context.newResponse(null, 200);
}),
);

View file

@ -0,0 +1,37 @@
import { afterAll, describe, expect, test } from "bun:test";
import type { Account as ApiAccount } from "@versia/client/types";
import { fakeRequest, getTestUsers } from "~/tests/utils";
import { meta } from "./index.ts";
const { users, tokens, deleteUsers } = await getTestUsers(5);
afterAll(async () => {
await deleteUsers();
});
// /api/v1/accounts/lookup
describe(meta.route, () => {
test("should return 200 with users", async () => {
const response = await fakeRequest(
`${meta.route}?acct=${users[0].data.username}`,
{
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response.status).toBe(200);
const data = (await response.json()) as ApiAccount[];
expect(data).toEqual(
expect.objectContaining({
id: users[0].id,
username: users[0].data.username,
display_name: users[0].data.displayName,
avatar: expect.any(String),
header: expect.any(String),
}),
);
});
});

View file

@ -0,0 +1,128 @@
import { apiRoute, applyConfig, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { User } from "@versia/kit/db";
import { RolePermissions, Users } from "@versia/kit/tables";
import { eq } from "drizzle-orm";
import {
anyOf,
charIn,
createRegExp,
digit,
exactly,
global,
letter,
maybe,
oneOrMore,
} from "magic-regexp";
import { z } from "zod";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
ratelimits: {
max: 30,
duration: 60,
},
route: "/api/v1/accounts/lookup",
auth: {
required: false,
oauthPermissions: [],
},
permissions: {
required: [RolePermissions.Search],
},
});
export const schemas = {
query: z.object({
acct: z.string().min(1).max(512),
}),
};
const route = createRoute({
method: "get",
path: "/api/v1/accounts/lookup",
summary: "Lookup account",
description: "Lookup an account by acct",
middleware: [auth(meta.auth, meta.permissions)],
request: {
query: schemas.query,
},
responses: {
200: {
description: "Account",
content: {
"application/json": {
schema: User.schema,
},
},
},
404: {
description: "Not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { acct } = context.req.valid("query");
const { user } = context.get("auth");
// Check if acct is matching format username@domain.com or @username@domain.com
const accountMatches = acct?.trim().match(
createRegExp(
maybe("@"),
oneOrMore(
anyOf(letter.lowercase, digit, charIn("-")),
).groupedAs("username"),
exactly("@"),
oneOrMore(anyOf(letter, digit, charIn("_-.:"))).groupedAs(
"domain",
),
[global],
),
);
if (accountMatches) {
// Remove leading @ if it exists
if (accountMatches[0].startsWith("@")) {
accountMatches[0] = accountMatches[0].slice(1);
}
const [username, domain] = accountMatches[0].split("@");
const manager = await (user ?? User).getFederationRequester();
const uri = await User.webFinger(manager, username, domain);
const foundAccount = await User.resolve(uri);
if (foundAccount) {
return context.json(foundAccount.toApi(), 200);
}
return context.json({ error: "Account not found" }, 404);
}
let username = acct;
if (username.startsWith("@")) {
username = username.slice(1);
}
const account = await User.fromSql(eq(Users.username, username));
if (account) {
return context.json(account.toApi(), 200);
}
return context.json(
{ error: `Account with username ${username} not found` },
404,
);
}),
);

View file

@ -0,0 +1,112 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { db } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { eq } from "drizzle-orm";
import { fakeRequest, getTestUsers } from "~/tests/utils";
import { meta } from "./index.ts";
const { users, tokens, deleteUsers } = await getTestUsers(5);
beforeAll(async () => {
// user0 should be `locked`
// user1 should follow user0
// user0 should follow user2
await db
.update(Users)
.set({ isLocked: true })
.where(eq(Users.id, users[0].id));
const res1 = await fakeRequest(`/api/v1/accounts/${users[0].id}/follow`, {
method: "POST",
headers: {
Authorization: `Bearer ${tokens[1].data.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
expect(res1.ok).toBe(true);
const res2 = await fakeRequest(`/api/v1/accounts/${users[2].id}/follow`, {
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
expect(res2.ok).toBe(true);
});
afterAll(async () => {
await deleteUsers();
});
// /api/v1/accounts/relationships
describe(meta.route, () => {
test("should return 401 if not authenticated", async () => {
const response = await fakeRequest(`${meta.route}?id[]=${users[2].id}`);
expect(response.status).toBe(401);
});
test("should return relationships", async () => {
const response = await fakeRequest(
`${meta.route}?id[]=${users[2].id}`,
{
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: users[2].id,
following: true,
followed_by: false,
blocking: false,
muting: false,
muting_notifications: false,
requested: false,
domain_blocking: false,
endorsed: false,
}),
]),
);
});
test("should be requested_by user1", async () => {
const response = await fakeRequest(
`${meta.route}?id[]=${users[1].id}`,
{
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({
following: false,
followed_by: false,
blocking: false,
muting: false,
muting_notifications: false,
requested_by: true,
domain_blocking: false,
endorsed: false,
}),
]),
);
});
});

View file

@ -0,0 +1,84 @@
import { apiRoute, applyConfig, auth, qsQuery } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { Relationship } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables";
import { z } from "zod";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
route: "/api/v1/accounts/relationships",
ratelimits: {
max: 30,
duration: 60,
},
auth: {
required: true,
oauthPermissions: ["read:follows"],
},
permissions: {
required: [RolePermissions.ManageOwnFollows],
},
});
export const schemas = {
query: z.object({
id: z.array(z.string().uuid()).min(1).max(10).or(z.string().uuid()),
}),
};
const route = createRoute({
method: "get",
path: "/api/v1/accounts/relationships",
summary: "Get relationships",
description: "Get relationships by account ID",
middleware: [auth(meta.auth, meta.permissions), qsQuery()],
request: {
query: schemas.query,
},
responses: {
200: {
description: "Relationships",
content: {
"application/json": {
schema: z.array(Relationship.schema),
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { user: self } = context.get("auth");
const { id } = context.req.valid("query");
const ids = Array.isArray(id) ? id : [id];
if (!self) {
return context.json({ error: "Unauthorized" }, 401);
}
const relationships = await Relationship.fromOwnerAndSubjects(
self,
ids,
);
relationships.sort(
(a, b) =>
ids.indexOf(a.data.subjectId) - ids.indexOf(b.data.subjectId),
);
return context.json(
relationships.map((r) => r.toApi()),
200,
);
}),
);

View file

@ -0,0 +1,39 @@
import { afterAll, describe, expect, test } from "bun:test";
import type { Account as ApiAccount } from "@versia/client/types";
import { fakeRequest, getTestUsers } from "~/tests/utils";
import { meta } from "./index.ts";
const { users, tokens, deleteUsers } = await getTestUsers(5);
afterAll(async () => {
await deleteUsers();
});
// /api/v1/accounts/search
describe(meta.route, () => {
test("should return 200 with users", async () => {
const response = await fakeRequest(
`${meta.route}?q=${users[0].data.username}`,
{
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response.status).toBe(200);
const data = (await response.json()) as ApiAccount[];
expect(data).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: users[0].id,
username: users[0].data.username,
display_name: users[0].data.displayName,
avatar: expect.any(String),
header: expect.any(String),
}),
]),
);
});
});

View file

@ -0,0 +1,155 @@
import { apiRoute, applyConfig, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { User } from "@versia/kit/db";
import { RolePermissions, Users } from "@versia/kit/tables";
import { eq, ilike, not, or, sql } from "drizzle-orm";
import {
anyOf,
charIn,
createRegExp,
digit,
exactly,
global,
letter,
maybe,
oneOrMore,
} from "magic-regexp";
import stringComparison from "string-comparison";
import { z } from "zod";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
route: "/api/v1/accounts/search",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: false,
oauthPermissions: ["read:accounts"],
},
permissions: {
required: [RolePermissions.Search, RolePermissions.ViewAccounts],
},
});
export const schemas = {
query: z.object({
q: z
.string()
.min(1)
.max(512)
.regex(
createRegExp(
maybe("@"),
oneOrMore(
anyOf(letter.lowercase, digit, charIn("-")),
).groupedAs("username"),
maybe(
exactly("@"),
oneOrMore(
anyOf(letter, digit, charIn("_-.:")),
).groupedAs("domain"),
),
[global],
),
),
limit: z.coerce.number().int().min(1).max(80).default(40),
offset: z.coerce.number().int().optional(),
resolve: z
.string()
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
.optional(),
following: z
.string()
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
.optional(),
}),
};
export const route = createRoute({
method: "get",
path: "/api/v1/accounts/search",
summary: "Search accounts",
description: "Search for accounts",
middleware: [auth(meta.auth, meta.permissions)],
request: {
query: schemas.query,
},
responses: {
200: {
description: "Accounts",
content: {
"application/json": {
schema: z.array(User.schema),
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { q, limit, offset, resolve, following } =
context.req.valid("query");
const { user: self } = context.get("auth");
if (!self && following) {
return context.json({ error: "Unauthorized" }, 401);
}
const [username, host] = q.replace(/^@/, "").split("@");
const accounts: User[] = [];
if (resolve && username && host) {
const manager = await (self ?? User).getFederationRequester();
const uri = await User.webFinger(manager, username, host);
const resolvedUser = await User.resolve(uri);
if (resolvedUser) {
accounts.push(resolvedUser);
}
} else {
accounts.push(
...(await User.manyFromSql(
or(
ilike(Users.displayName, `%${q}%`),
ilike(Users.username, `%${q}%`),
following && self
? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${self.id} AND "Relationships"."following" = true)`
: undefined,
self ? not(eq(Users.id, self.id)) : undefined,
),
undefined,
limit,
offset,
)),
);
}
const indexOfCorrectSort = stringComparison.jaccardIndex
.sortMatch(
q,
accounts.map((acct) => acct.getAcct()),
)
.map((sort) => sort.index);
const result = indexOfCorrectSort.map((index) => accounts[index]);
return context.json(
result.map((acct) => acct.toApi()),
200,
);
}),
);

View file

@ -0,0 +1,383 @@
import { apiRoute, applyConfig, auth, jsonOrForm } from "@/api";
import { sanitizedHtmlStrip } from "@/sanitization";
import { createRoute } from "@hono/zod-openapi";
import { Attachment, Emoji, User, db } from "@versia/kit/db";
import { EmojiToUser, RolePermissions, Users } from "@versia/kit/tables";
import { and, eq, isNull } from "drizzle-orm";
import ISO6391 from "iso-639-1";
import { z } from "zod";
import { contentToHtml } from "~/classes/functions/status";
import { MediaManager } from "~/classes/media/media-manager";
import { config } from "~/packages/config-manager/index.ts";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
route: "/api/v1/accounts/update_credentials",
ratelimits: {
max: 2,
duration: 60,
},
auth: {
required: true,
oauthPermissions: ["write:accounts"],
},
permissions: {
required: [RolePermissions.ManageOwnAccount],
},
});
export const schemas = {
json: z.object({
display_name: z
.string()
.min(3)
.trim()
.max(config.validation.max_displayname_size)
.refine(
(s) =>
!config.filters.displayname.some((filter) =>
s.match(filter),
),
"Display name contains blocked words",
)
.optional(),
username: z
.string()
.min(3)
.trim()
.max(config.validation.max_username_size)
.refine(
(s) =>
!config.filters.username.some((filter) => s.match(filter)),
"Username contains blocked words",
)
.optional(),
note: z
.string()
.min(0)
.max(config.validation.max_bio_size)
.trim()
.refine(
(s) => !config.filters.bio.some((filter) => s.match(filter)),
"Bio contains blocked words",
)
.optional(),
avatar: z
.instanceof(File)
.refine(
(v) => v.size <= config.validation.max_avatar_size,
`Avatar must be less than ${config.validation.max_avatar_size} bytes`,
)
.optional(),
header: z
.instanceof(File)
.refine(
(v) => v.size <= config.validation.max_header_size,
`Header must be less than ${config.validation.max_header_size} bytes`,
)
.optional(),
locked: z
.string()
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
.optional(),
bot: z
.string()
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
.optional(),
discoverable: z
.string()
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
.optional(),
source: z
.object({
privacy: z
.enum(["public", "unlisted", "private", "direct"])
.optional(),
sensitive: z
.string()
.transform((v) =>
["true", "1", "on"].includes(v.toLowerCase()),
)
.optional(),
language: z
.enum(ISO6391.getAllCodes() as [string, ...string[]])
.optional(),
})
.optional(),
fields_attributes: z
.array(
z.object({
name: z
.string()
.trim()
.max(config.validation.max_field_name_size),
value: z
.string()
.trim()
.max(config.validation.max_field_value_size),
}),
)
.max(config.validation.max_field_count)
.optional(),
}),
};
const route = createRoute({
method: "patch",
path: "/api/v1/accounts/update_credentials",
summary: "Update credentials",
description: "Update user credentials",
middleware: [auth(meta.auth, meta.permissions), jsonOrForm()],
request: {
body: {
content: {
"application/json": {
schema: schemas.json,
},
},
},
},
responses: {
200: {
description: "Updated user",
content: {
"application/json": {
schema: User.schema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
422: {
description: "Validation error",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
500: {
description: "Couldn't edit user",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { user } = context.get("auth");
const {
display_name,
username,
note,
avatar,
header,
locked,
bot,
discoverable,
source,
fields_attributes,
} = context.req.valid("json");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
const self = user.data;
const sanitizedDisplayName = await sanitizedHtmlStrip(
display_name ?? "",
);
const mediaManager = new MediaManager(config);
if (display_name) {
self.displayName = sanitizedDisplayName;
}
if (note && self.source) {
self.source.note = note;
self.note = await contentToHtml({
"text/markdown": {
content: note,
remote: false,
},
});
}
if (source?.privacy) {
self.source.privacy = source.privacy;
}
if (source?.sensitive) {
self.source.sensitive = source.sensitive;
}
if (source?.language) {
self.source.language = source.language;
}
if (username) {
// Check if username is already taken
const existingUser = await User.fromSql(
and(isNull(Users.instanceId), eq(Users.username, username)),
);
if (existingUser) {
return context.json(
{ error: "Username is already taken" },
422,
);
}
self.username = username;
}
if (avatar) {
const { path } = await mediaManager.addFile(avatar);
self.avatar = Attachment.getUrl(path);
}
if (header) {
const { path } = await mediaManager.addFile(header);
self.header = Attachment.getUrl(path);
}
if (locked) {
self.isLocked = locked;
}
if (bot) {
self.isBot = bot;
}
if (discoverable) {
self.isDiscoverable = discoverable;
}
const fieldEmojis: Emoji[] = [];
if (fields_attributes) {
self.fields = [];
self.source.fields = [];
for (const field of fields_attributes) {
// Can be Markdown or plaintext, also has emojis
const parsedName = await contentToHtml(
{
"text/markdown": {
content: field.name,
remote: false,
},
},
undefined,
true,
);
const parsedValue = await contentToHtml(
{
"text/markdown": {
content: field.value,
remote: false,
},
},
undefined,
true,
);
// Parse emojis
const nameEmojis = await Emoji.parseFromText(parsedName);
const valueEmojis = await Emoji.parseFromText(parsedValue);
fieldEmojis.push(...nameEmojis, ...valueEmojis);
// Replace fields
self.fields.push({
key: {
"text/html": {
content: parsedName,
remote: false,
},
},
value: {
"text/html": {
content: parsedValue,
remote: false,
},
},
});
self.source.fields.push({
name: field.name,
value: field.value,
});
}
}
// Parse emojis
const displaynameEmojis =
await Emoji.parseFromText(sanitizedDisplayName);
const noteEmojis = await Emoji.parseFromText(self.note);
self.emojis = [...displaynameEmojis, ...noteEmojis, ...fieldEmojis]
.map((e) => e.data)
.filter(
// Deduplicate emojis
(emoji, index, self) =>
self.findIndex((e) => e.id === emoji.id) === index,
);
// Connect emojis, if any
// Do it before updating user, so that federation takes that into account
for (const emoji of self.emojis) {
await db
.delete(EmojiToUser)
.where(
and(
eq(EmojiToUser.emojiId, emoji.id),
eq(EmojiToUser.userId, self.id),
),
)
.execute();
await db
.insert(EmojiToUser)
.values({
emojiId: emoji.id,
userId: self.id,
})
.execute();
}
await user.update({
displayName: self.displayName,
username: self.username,
note: self.note,
avatar: self.avatar,
header: self.header,
fields: self.fields,
isLocked: self.isLocked,
isBot: self.isBot,
isDiscoverable: self.isDiscoverable,
source: self.source || undefined,
});
const output = await User.fromId(self.id);
if (!output) {
return context.json({ error: "Couldn't edit user" }, 500);
}
return context.json(output.toApi(), 200);
}),
);

View file

@ -0,0 +1,55 @@
import { apiRoute, applyConfig, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { User } from "@versia/kit/db";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
route: "/api/v1/accounts/verify_credentials",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: true,
oauthPermissions: ["read:accounts"],
},
});
const route = createRoute({
method: "get",
path: "/api/v1/accounts/verify_credentials",
summary: "Verify credentials",
description: "Get your own account information",
middleware: [auth(meta.auth)],
responses: {
200: {
description: "Account",
content: {
"application/json": {
schema: User.schema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, (context) => {
// TODO: Add checks for disabled/unverified accounts
const { user } = context.get("auth");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
return context.json(user.toApi(true), 200);
}),
);

106
api/api/v1/apps/index.ts Normal file
View file

@ -0,0 +1,106 @@
import { apiRoute, applyConfig, jsonOrForm } from "@/api";
import { randomString } from "@/math";
import { createRoute } from "@hono/zod-openapi";
import { Application } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables";
import { z } from "zod";
export const meta = applyConfig({
route: "/api/v1/apps",
ratelimits: {
max: 2,
duration: 60,
},
auth: {
required: false,
},
permissions: {
required: [RolePermissions.ManageOwnApps],
},
});
export const schemas = {
json: z.object({
client_name: z.string().trim().min(1).max(100),
redirect_uris: z
.string()
.min(0)
.max(2000)
.url()
.or(z.literal("urn:ietf:wg:oauth:2.0:oob")),
scopes: z.string().min(1).max(200),
website: z
.string()
.min(0)
.max(2000)
.url()
.optional()
// Allow empty websites because Traewelling decides to give an empty
// value instead of not providing anything at all
.or(z.literal("").transform(() => undefined)),
}),
};
const route = createRoute({
method: "post",
path: "/api/v1/apps",
summary: "Create app",
description: "Create an OAuth2 app",
middleware: [jsonOrForm()],
request: {
body: {
content: {
"application/json": {
schema: schemas.json,
},
},
},
},
responses: {
200: {
description: "App",
content: {
"application/json": {
schema: z.object({
id: z.string().uuid(),
name: z.string(),
website: z.string().nullable(),
client_id: z.string(),
client_secret: z.string(),
redirect_uri: z.string(),
vapid_link: z.string().nullable(),
}),
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { client_name, redirect_uris, scopes, website } =
context.req.valid("json");
const app = await Application.insert({
name: client_name || "",
redirectUri: decodeURI(redirect_uris) || "",
scopes: scopes || "read",
website: website || null,
clientId: randomString(32, "base64url"),
secret: randomString(64, "base64url"),
});
return context.json(
{
id: app.id,
name: app.data.name,
website: app.data.website,
client_id: app.data.clientId,
client_secret: app.data.secret,
redirect_uri: app.data.redirectUri,
vapid_link: app.data.vapidKey,
},
200,
);
}),
);

View file

@ -0,0 +1,75 @@
import { apiRoute, applyConfig, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { Application } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
route: "/api/v1/apps/verify_credentials",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: true,
},
permissions: {
required: [RolePermissions.ManageOwnApps],
},
});
const route = createRoute({
method: "get",
path: "/api/v1/apps/verify_credentials",
summary: "Verify credentials",
description: "Get your own application information",
middleware: [auth(meta.auth, meta.permissions)],
responses: {
200: {
description: "Application",
content: {
"application/json": {
schema: Application.schema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { user, token } = context.get("auth");
if (!token) {
return context.json({ error: "Unauthorized" }, 401);
}
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
const application = await Application.getFromToken(
token.data.accessToken,
);
if (!application) {
return context.json({ error: "Unauthorized" }, 401);
}
return context.json(
{
...application.toApi(),
redirect_uris: application.data.redirectUri,
scopes: application.data.scopes,
},
200,
);
}),
);

View file

@ -0,0 +1,91 @@
import { apiRoute, applyConfig, auth, idValidator } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { Timeline, User } from "@versia/kit/db";
import { RolePermissions, Users } from "@versia/kit/tables";
import { and, gt, gte, lt, sql } from "drizzle-orm";
import { z } from "zod";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
route: "/api/v1/blocks",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: true,
oauthPermissions: ["read:blocks"],
},
permissions: {
required: [RolePermissions.ManageOwnBlocks],
},
});
export const schemas = {
query: z.object({
max_id: z.string().regex(idValidator).optional(),
since_id: z.string().regex(idValidator).optional(),
min_id: z.string().regex(idValidator).optional(),
limit: z.coerce.number().int().min(1).max(80).default(40),
}),
};
const route = createRoute({
method: "get",
path: "/api/v1/blocks",
summary: "Get blocks",
description: "Get users you have blocked",
middleware: [auth(meta.auth, meta.permissions)],
request: {
query: schemas.query,
},
responses: {
200: {
description: "Blocks",
content: {
"application/json": {
schema: z.array(User.schema),
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { max_id, since_id, min_id, limit } = context.req.valid("query");
const { user } = context.get("auth");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
const { objects: blocks, link } = await Timeline.getUserTimeline(
and(
max_id ? lt(Users.id, max_id) : undefined,
since_id ? gte(Users.id, since_id) : undefined,
min_id ? gt(Users.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."blocking" = true)`,
),
limit,
context.req.url,
);
return context.json(
blocks.map((u) => u.toApi()),
200,
{
Link: link,
},
);
}),
);

View file

@ -1,15 +1,19 @@
import { describe, expect, test } from "bun:test";
import { generateClient } from "@versia-server/tests";
import { fakeRequest } from "~/tests/utils";
import { meta } from "./index.ts";
// /api/v1/challenges
describe("/api/v1/challenges", () => {
describe(meta.route, () => {
test("should get a challenge", async () => {
await using client = await generateClient();
const response = await fakeRequest(meta.route, {
method: "POST",
});
const { data, ok } = await client.getChallenge();
expect(response.status).toBe(200);
expect(ok).toBe(true);
expect(data).toMatchObject({
const body = await response.json();
expect(body).toMatchObject({
id: expect.any(String),
algorithm: expect.any(String),
challenge: expect.any(String),

View file

@ -0,0 +1,73 @@
import { apiRoute, applyConfig, auth } from "@/api";
import { generateChallenge } from "@/challenges";
import { createRoute, z } from "@hono/zod-openapi";
import { config } from "~/packages/config-manager";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
route: "/api/v1/challenges",
ratelimits: {
max: 10,
duration: 60,
},
auth: {
required: false,
},
permissions: {
required: [],
},
});
const route = createRoute({
method: "post",
path: "/api/v1/challenges",
summary: "Generate a challenge",
description: "Generate a challenge to solve",
middleware: [auth(meta.auth, meta.permissions)],
responses: {
200: {
description: "Challenge",
content: {
"application/json": {
schema: z.object({
id: z.string(),
algorithm: z.enum(["SHA-1", "SHA-256", "SHA-512"]),
challenge: z.string(),
maxnumber: z.number().optional(),
salt: z.string(),
signature: z.string(),
}),
},
},
},
400: {
description: "Challenges are disabled",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
if (!config.validation.challenges.enabled) {
return context.json(
{ error: "Challenges are disabled in config" },
400,
);
}
const result = await generateChallenge();
return context.json(
{
id: result.id,
...result.challenge,
},
200,
);
}),
);

View file

@ -0,0 +1,155 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { db } from "@versia/kit/db";
import { Emojis } from "@versia/kit/tables";
import { inArray } from "drizzle-orm";
import { fakeRequest, getTestUsers } from "~/tests/utils";
import { meta } from "./index.ts";
const { users, tokens, deleteUsers } = await getTestUsers(2);
// Make user 2 an admin
beforeAll(async () => {
await users[1].update({ isAdmin: true });
// Upload one emoji as admin, then one as each user
const response = await fakeRequest("/api/v1/emojis", {
headers: {
Authorization: `Bearer ${tokens[1].data.accessToken}`,
"Content-Type": "application/json",
},
method: "POST",
body: JSON.stringify({
shortcode: "test1",
element: "https://cdn.versia.social/logo.webp",
global: true,
}),
});
expect(response.status).toBe(200);
await fakeRequest("/api/v1/emojis", {
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
"Content-Type": "application/json",
},
method: "POST",
body: JSON.stringify({
shortcode: "test2",
element: "https://cdn.versia.social/logo.webp",
}),
});
await fakeRequest("/api/v1/emojis", {
headers: {
Authorization: `Bearer ${tokens[1].data.accessToken}`,
"Content-Type": "application/json",
},
method: "POST",
body: JSON.stringify({
shortcode: "test3",
element: "https://cdn.versia.social/logo.webp",
}),
});
});
afterAll(async () => {
await deleteUsers();
await db
.delete(Emojis)
.where(inArray(Emojis.shortcode, ["test1", "test2", "test3"]));
});
describe(meta.route, () => {
test("should return all global emojis", async () => {
const response = await fakeRequest(meta.route, {
headers: {
Authorization: `Bearer ${tokens[1].data.accessToken}`,
},
});
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain(
"application/json",
);
const emojis = await response.json();
// Should contain test1 and test2, but not test2
expect(emojis).toContainEqual(
expect.objectContaining({
shortcode: "test1",
}),
);
expect(emojis).not.toContainEqual(
expect.objectContaining({
shortcode: "test2",
}),
);
expect(emojis).toContainEqual(
expect.objectContaining({
shortcode: "test3",
}),
);
});
test("should return all user emojis", async () => {
const response = await fakeRequest(meta.route, {
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
});
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain(
"application/json",
);
const emojis = await response.json();
// Should contain test1 and test2, but not test3
expect(emojis).toContainEqual(
expect.objectContaining({
shortcode: "test1",
}),
);
expect(emojis).toContainEqual(
expect.objectContaining({
shortcode: "test2",
}),
);
expect(emojis).not.toContainEqual(
expect.objectContaining({
shortcode: "test3",
}),
);
});
test("should return all global emojis when signed out", async () => {
const response = await fakeRequest(meta.route);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain(
"application/json",
);
const emojis = await response.json();
// Should contain test1, but not test2 or test3
expect(emojis).toContainEqual(
expect.objectContaining({
shortcode: "test1",
}),
);
expect(emojis).not.toContainEqual(
expect.objectContaining({
shortcode: "test2",
}),
);
expect(emojis).not.toContainEqual(
expect.objectContaining({
shortcode: "test3",
}),
);
});
});

View file

@ -0,0 +1,58 @@
import { apiRoute, applyConfig, auth } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { Emoji } from "@versia/kit/db";
import { Emojis, RolePermissions } from "@versia/kit/tables";
import { and, eq, isNull, or } from "drizzle-orm";
export const meta = applyConfig({
route: "/api/v1/custom_emojis",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: false,
},
permissions: {
required: [RolePermissions.ViewEmojis],
},
});
const route = createRoute({
method: "get",
path: "/api/v1/custom_emojis",
summary: "Get custom emojis",
description: "Get custom emojis",
middleware: [auth(meta.auth, meta.permissions)],
responses: {
200: {
description: "Emojis",
content: {
"application/json": {
schema: z.array(Emoji.schema),
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { user } = context.get("auth");
const emojis = await Emoji.manyFromSql(
and(
isNull(Emojis.instanceId),
or(
isNull(Emojis.ownerId),
user ? eq(Emojis.ownerId, user.id) : undefined,
),
),
);
return context.json(
emojis.map((emoji) => emoji.toApi()),
200,
);
}),
);

View file

@ -0,0 +1,166 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { db } from "@versia/kit/db";
import { Emojis } from "@versia/kit/tables";
import { inArray } from "drizzle-orm";
import { fakeRequest, getTestUsers } from "~/tests/utils";
import { meta } from "./index.ts";
const { users, tokens, deleteUsers } = await getTestUsers(2);
let id = "";
// Make user 2 an admin
beforeAll(async () => {
await users[1].update({ isAdmin: true });
// Create an emoji
const response = await fakeRequest("/api/v1/emojis", {
headers: {
Authorization: `Bearer ${tokens[1].data.accessToken}`,
"Content-Type": "application/json",
},
method: "POST",
body: JSON.stringify({
shortcode: "test",
element: "https://cdn.versia.social/logo.webp",
global: true,
}),
});
expect(response.ok).toBe(true);
const emoji = await response.json();
id = emoji.id;
});
afterAll(async () => {
await deleteUsers();
await db
.delete(Emojis)
.where(inArray(Emojis.shortcode, ["test", "test2", "test3", "test4"]));
});
// /api/v1/emojis/:id (PATCH, DELETE, GET)
describe(meta.route, () => {
test("should return 401 if not authenticated", async () => {
const response = await fakeRequest(meta.route.replace(":id", id), {
method: "GET",
});
expect(response.status).toBe(401);
});
test("should return 404 if emoji does not exist", async () => {
const response = await fakeRequest(
meta.route.replace(":id", "00000000-0000-0000-0000-000000000000"),
{
headers: {
Authorization: `Bearer ${tokens[1].data.accessToken}`,
},
method: "GET",
},
);
expect(response.status).toBe(404);
});
test("should not work if the user is trying to update an emoji they don't own", async () => {
const response = await fakeRequest(meta.route.replace(":id", id), {
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
"Content-Type": "application/json",
},
method: "PATCH",
body: JSON.stringify({
shortcode: "test2",
}),
});
expect(response.status).toBe(403);
});
test("should return the emoji", async () => {
const response = await fakeRequest(meta.route.replace(":id", id), {
headers: {
Authorization: `Bearer ${tokens[1].data.accessToken}`,
},
method: "GET",
});
expect(response.ok).toBe(true);
const emoji = await response.json();
expect(emoji.shortcode).toBe("test");
});
test("should update the emoji", async () => {
const response = await fakeRequest(meta.route.replace(":id", id), {
headers: {
Authorization: `Bearer ${tokens[1].data.accessToken}`,
"Content-Type": "application/json",
},
method: "PATCH",
body: JSON.stringify({
shortcode: "test2",
}),
});
expect(response.ok).toBe(true);
const emoji = await response.json();
expect(emoji.shortcode).toBe("test2");
});
test("should update the emoji with another url, but keep the shortcode", async () => {
const response = await fakeRequest(meta.route.replace(":id", id), {
headers: {
Authorization: `Bearer ${tokens[1].data.accessToken}`,
"Content-Type": "application/json",
},
method: "PATCH",
body: JSON.stringify({
element: "https://avatars.githubusercontent.com/u/30842467?v=4",
}),
});
expect(response.ok).toBe(true);
const emoji = await response.json();
expect(emoji.shortcode).toBe("test2");
});
test("should update the emoji to be non-global", async () => {
const response = await fakeRequest(meta.route.replace(":id", id), {
headers: {
Authorization: `Bearer ${tokens[1].data.accessToken}`,
"Content-Type": "application/json",
},
method: "PATCH",
body: JSON.stringify({
global: false,
}),
});
expect(response.ok).toBe(true);
// Check if the other user can see it
const response2 = await fakeRequest("/api/v1/custom_emojis", {
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
method: "GET",
});
expect(response2.ok).toBe(true);
const emojis = await response2.json();
expect(emojis).not.toContainEqual(expect.objectContaining({ id }));
});
test("should delete the emoji", async () => {
const response = await fakeRequest(meta.route.replace(":id", id), {
headers: {
Authorization: `Bearer ${tokens[1].data.accessToken}`,
},
method: "DELETE",
});
expect(response.status).toBe(204);
});
});

View file

@ -0,0 +1,354 @@
import { apiRoute, applyConfig, auth, emojiValidator, jsonOrForm } from "@/api";
import { mimeLookup } from "@/content_types";
import { createRoute } from "@hono/zod-openapi";
import { Attachment, Emoji, db } from "@versia/kit/db";
import { Emojis, RolePermissions } from "@versia/kit/tables";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { MediaManager } from "~/classes/media/media-manager";
import { config } from "~/packages/config-manager";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
route: "/api/v1/emojis/:id",
ratelimits: {
max: 30,
duration: 60,
},
auth: {
required: true,
},
permissions: {
required: [RolePermissions.ManageOwnEmojis, RolePermissions.ViewEmojis],
},
});
export const schemas = {
param: z.object({
id: z.string().uuid(),
}),
json: z
.object({
shortcode: z
.string()
.trim()
.min(1)
.max(64)
.regex(
emojiValidator,
"Shortcode must only contain letters (any case), numbers, dashes or underscores.",
),
element: z
.string()
.trim()
.min(1)
.max(2000)
.url()
.or(z.instanceof(File)),
category: z.string().max(64).optional(),
alt: z.string().max(1000).optional(),
global: z
.string()
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
.or(z.boolean())
.optional(),
})
.partial(),
};
const routeGet = createRoute({
method: "get",
path: "/api/v1/emojis/{id}",
summary: "Get emoji data",
middleware: [auth(meta.auth, meta.permissions)],
request: {
params: schemas.param,
},
responses: {
200: {
description: "Emoji",
content: {
"application/json": {
schema: Emoji.schema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
403: {
description: "Insufficient credentials",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
404: {
description: "Emoji not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
const routePatch = createRoute({
method: "patch",
path: "/api/v1/emojis/{id}",
summary: "Modify emoji",
middleware: [auth(meta.auth, meta.permissions), jsonOrForm()],
request: {
params: schemas.param,
body: {
content: {
"application/json": {
schema: schemas.json,
},
"application/x-www-form-urlencoded": {
schema: schemas.json,
},
"multipart/form-data": {
schema: schemas.json,
},
},
},
},
responses: {
200: {
description: "Emoji modified",
content: {
"application/json": {
schema: Emoji.schema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
403: {
description: "Insufficient credentials",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
404: {
description: "Emoji not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
422: {
description: "Invalid form data",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
const routeDelete = createRoute({
method: "delete",
path: "/api/v1/emojis/{id}",
summary: "Delete emoji",
middleware: [auth(meta.auth, meta.permissions)],
request: {
params: schemas.param,
},
responses: {
204: {
description: "Emoji deleted",
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
404: {
description: "Emoji not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) => {
app.openapi(routeGet, async (context) => {
const { id } = context.req.valid("param");
const { user } = context.get("auth");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
const emoji = await Emoji.fromId(id);
if (!emoji) {
return context.json({ error: "Emoji not found" }, 404);
}
// Check if user is admin
if (
!user.hasPermission(RolePermissions.ManageEmojis) &&
emoji.data.ownerId !== user.data.id
) {
return context.json(
{
error: `You cannot modify this emoji, as it is either global, not owned by you, or you do not have the '${RolePermissions.ManageEmojis}' permission to manage global emojis`,
},
403,
);
}
return context.json(emoji.toApi(), 200);
});
app.openapi(routePatch, async (context) => {
const { id } = context.req.valid("param");
const { user } = context.get("auth");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
const emoji = await Emoji.fromId(id);
if (!emoji) {
return context.json({ error: "Emoji not found" }, 404);
}
// Check if user is admin
if (
!user.hasPermission(RolePermissions.ManageEmojis) &&
emoji.data.ownerId !== user.data.id
) {
return context.json(
{
error: `You cannot modify this emoji, as it is either global, not owned by you, or you do not have the '${RolePermissions.ManageEmojis}' permission to manage global emojis`,
},
403,
);
}
const mediaManager = new MediaManager(config);
const {
global: emojiGlobal,
alt,
category,
element,
shortcode,
} = context.req.valid("json");
if (!user.hasPermission(RolePermissions.ManageEmojis) && emojiGlobal) {
return context.json(
{
error: `Only users with the '${RolePermissions.ManageEmojis}' permission can make an emoji global or not`,
},
401,
);
}
const modified = structuredClone(emoji.data);
if (element) {
// Check of emoji is an image
let contentType =
element instanceof File
? element.type
: await mimeLookup(element);
if (!contentType.startsWith("image/")) {
return context.json(
{
error: `Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`,
},
422,
);
}
let url = "";
if (element instanceof File) {
const uploaded = await mediaManager.addFile(element);
url = uploaded.path;
contentType = uploaded.uploadedFile.type;
} else {
url = element;
}
modified.url = Attachment.getUrl(url);
modified.contentType = contentType;
}
modified.shortcode = shortcode ?? modified.shortcode;
modified.alt = alt ?? modified.alt;
modified.category = category ?? modified.category;
modified.ownerId = emojiGlobal ? null : user.data.id;
await emoji.update(modified);
return context.json(emoji.toApi(), 200);
});
app.openapi(routeDelete, async (context) => {
const { id } = context.req.valid("param");
const { user } = context.get("auth");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
const emoji = await Emoji.fromId(id);
if (!emoji) {
return context.json({ error: "Emoji not found" }, 404);
}
// Check if user is admin
if (
!user.hasPermission(RolePermissions.ManageEmojis) &&
emoji.data.ownerId !== user.data.id
) {
return context.json(
{
error: `You cannot delete this emoji, as it is either global, not owned by you, or you do not have the '${RolePermissions.ManageEmojis}' permission to manage global emojis`,
},
403,
);
}
const mediaManager = new MediaManager(config);
await mediaManager.deleteFileByUrl(emoji.data.url);
await db.delete(Emojis).where(eq(Emojis.id, id));
return context.newResponse(null, 204);
});
});

View file

@ -0,0 +1,185 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { db } from "@versia/kit/db";
import { Emojis } from "@versia/kit/tables";
import { inArray } from "drizzle-orm";
import sharp from "sharp";
import { fakeRequest, getTestUsers } from "~/tests/utils";
import { meta } from "./index.ts";
const { users, tokens, deleteUsers } = await getTestUsers(3);
// Make user 2 an admin
beforeAll(async () => {
await users[1].update({ isAdmin: true });
});
afterAll(async () => {
await deleteUsers();
await db
.delete(Emojis)
.where(inArray(Emojis.shortcode, ["test1", "test2", "test3", "test4"]));
});
const createImage = async (name: string): Promise<File> => {
const inputBuffer = await sharp({
create: {
width: 100,
height: 100,
channels: 3,
background: { r: 255, g: 0, b: 0 },
},
})
.png()
.toBuffer();
return new File([inputBuffer], name, {
type: "image/png",
});
};
describe(meta.route, () => {
test("should return 401 if not authenticated", async () => {
const response = await fakeRequest(meta.route, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
shortcode: "test",
element: "https://cdn.versia.social/logo.webp",
}),
});
expect(response.status).toBe(401);
});
describe("Admin tests", () => {
test("should upload a file and create an emoji", async () => {
const formData = new FormData();
formData.append("shortcode", "test1");
formData.append("element", await createImage("test.png"));
formData.append("global", "true");
const response = await fakeRequest(meta.route, {
method: "POST",
headers: {
Authorization: `Bearer ${tokens[1].data.accessToken}`,
},
body: formData,
});
expect(response.ok).toBe(true);
const emoji = await response.json();
expect(emoji.shortcode).toBe("test1");
expect(emoji.url).toContain("/media/proxy");
});
test("should try to upload a non-image", async () => {
const formData = new FormData();
formData.append("shortcode", "test2");
formData.append("element", new File(["test"], "test.txt"));
const response = await fakeRequest(meta.route, {
method: "POST",
headers: {
Authorization: `Bearer ${tokens[1].data.accessToken}`,
},
body: formData,
});
expect(response.status).toBe(422);
});
test("should upload an emoji by url", async () => {
const response = await fakeRequest(meta.route, {
method: "POST",
headers: {
Authorization: `Bearer ${tokens[1].data.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
shortcode: "test3",
element: "https://cdn.versia.social/logo.webp",
}),
});
expect(response.ok).toBe(true);
const emoji = await response.json();
expect(emoji.shortcode).toBe("test3");
expect(emoji.url).toContain("/media/proxy/");
});
test("should fail when uploading an already existing emoji", async () => {
const formData = new FormData();
formData.append("shortcode", "test1");
formData.append("element", await createImage("test-image.png"));
const response = await fakeRequest(meta.route, {
method: "POST",
headers: {
Authorization: `Bearer ${tokens[1].data.accessToken}`,
},
body: formData,
});
expect(response.status).toBe(422);
});
});
describe("User tests", () => {
test("should upload a file and create an emoji", async () => {
const formData = new FormData();
formData.append("shortcode", "test4");
formData.append("element", await createImage("test-image.png"));
const response = await fakeRequest(meta.route, {
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
body: formData,
});
expect(response.ok).toBe(true);
const emoji = await response.json();
expect(emoji.shortcode).toBe("test4");
expect(emoji.url).toContain("/media/proxy/");
});
test("should fail when uploading an already existing global emoji", async () => {
const formData = new FormData();
formData.append("shortcode", "test1");
formData.append("element", await createImage("test-image.png"));
const response = await fakeRequest(meta.route, {
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
body: formData,
});
expect(response.status).toBe(422);
});
test("should create an emoji as another user with the same shortcode", async () => {
const formData = new FormData();
formData.append("shortcode", "test4");
formData.append("element", await createImage("test-image.png"));
const response = await fakeRequest(meta.route, {
method: "POST",
headers: {
Authorization: `Bearer ${tokens[2].data.accessToken}`,
},
body: formData,
});
expect(response.ok).toBe(true);
const emoji = await response.json();
expect(emoji.shortcode).toBe("test4");
expect(emoji.url).toContain("/media/proxy/");
});
});
});

178
api/api/v1/emojis/index.ts Normal file
View file

@ -0,0 +1,178 @@
import { apiRoute, applyConfig, auth, emojiValidator, jsonOrForm } from "@/api";
import { mimeLookup } from "@/content_types";
import { createRoute } from "@hono/zod-openapi";
import { Attachment, Emoji } from "@versia/kit/db";
import { Emojis, RolePermissions } from "@versia/kit/tables";
import { and, eq, isNull, or } from "drizzle-orm";
import { z } from "zod";
import { MediaManager } from "~/classes/media/media-manager";
import { config } from "~/packages/config-manager";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
route: "/api/v1/emojis",
ratelimits: {
max: 30,
duration: 60,
},
auth: {
required: true,
},
permissions: {
required: [RolePermissions.ManageOwnEmojis, RolePermissions.ViewEmojis],
},
});
export const schemas = {
json: z.object({
shortcode: z
.string()
.trim()
.min(1)
.max(64)
.regex(
emojiValidator,
"Shortcode must only contain letters (any case), numbers, dashes or underscores.",
),
element: z
.string()
.trim()
.min(1)
.max(2000)
.url()
.or(z.instanceof(File)),
category: z.string().max(64).optional(),
alt: z.string().max(1000).optional(),
global: z
.string()
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
.or(z.boolean())
.optional(),
}),
};
const route = createRoute({
method: "post",
path: "/api/v1/emojis",
summary: "Upload emoji",
description: "Upload an emoji",
middleware: [auth(meta.auth, meta.permissions), jsonOrForm()],
request: {
body: {
content: {
"application/json": {
schema: schemas.json,
},
"multipart/form-data": {
schema: schemas.json,
},
"application/x-www-form-urlencoded": {
schema: schemas.json,
},
},
},
},
responses: {
200: {
description: "uploaded emoji",
content: {
"application/json": {
schema: Emoji.schema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
422: {
description: "Invalid data",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { shortcode, element, alt, global, category } =
context.req.valid("json");
const { user } = context.get("auth");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
if (!user.hasPermission(RolePermissions.ManageEmojis) && global) {
return context.json(
{
error: `Only users with the '${RolePermissions.ManageEmojis}' permission can upload global emojis`,
},
401,
);
}
// Check if emoji already exists
const existing = await Emoji.fromSql(
and(
eq(Emojis.shortcode, shortcode),
isNull(Emojis.instanceId),
or(eq(Emojis.ownerId, user.id), isNull(Emojis.ownerId)),
),
);
if (existing) {
return context.json(
{
error: `An emoji with the shortcode ${shortcode} already exists, either owned by you or global.`,
},
422,
);
}
let url = "";
// Check of emoji is an image
let contentType =
element instanceof File ? element.type : await mimeLookup(element);
if (!contentType.startsWith("image/")) {
return context.json(
{
error: `Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`,
},
422,
);
}
if (element instanceof File) {
const mediaManager = new MediaManager(config);
const uploaded = await mediaManager.addFile(element);
url = uploaded.path;
contentType = uploaded.uploadedFile.type;
} else {
url = element;
}
const emoji = await Emoji.insert({
shortcode,
url: Attachment.getUrl(url),
visibleInPicker: true,
ownerId: global ? null : user.id,
category,
contentType,
alt,
});
return context.json(emoji.toApi(), 200);
}),
);

View file

@ -0,0 +1,90 @@
import { apiRoute, applyConfig, auth, idValidator } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { Note, Timeline } from "@versia/kit/db";
import { Notes, RolePermissions } from "@versia/kit/tables";
import { and, gt, gte, lt, sql } from "drizzle-orm";
import { z } from "zod";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
route: "/api/v1/favourites",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: true,
},
permissions: {
required: [RolePermissions.ManageOwnLikes],
},
});
export const schemas = {
query: z.object({
max_id: z.string().regex(idValidator).optional(),
since_id: z.string().regex(idValidator).optional(),
min_id: z.string().regex(idValidator).optional(),
limit: z.coerce.number().int().min(1).max(80).default(40),
}),
};
const route = createRoute({
method: "get",
path: "/api/v1/favourites",
summary: "Get favourites",
middleware: [auth(meta.auth, meta.permissions)],
request: {
query: schemas.query,
},
responses: {
200: {
description: "Favourites",
content: {
"application/json": {
schema: z.array(Note.schema),
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { max_id, since_id, min_id, limit } = context.req.valid("query");
const { user } = context.get("auth");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
const { objects: favourites, link } = await Timeline.getNoteTimeline(
and(
max_id ? lt(Notes.id, max_id) : undefined,
since_id ? gte(Notes.id, since_id) : undefined,
min_id ? gt(Notes.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = ${Notes.id} AND "Likes"."likerId" = ${user.id})`,
),
limit,
context.req.url,
user?.id,
);
return context.json(
await Promise.all(favourites.map((note) => note.toApi(user))),
200,
{
Link: link,
},
);
}),
);

View file

@ -0,0 +1,103 @@
import { apiRoute, applyConfig, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { Relationship, User } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables";
import { z } from "zod";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
route: "/api/v1/follow_requests/:account_id/authorize",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: true,
},
permissions: {
required: [RolePermissions.ManageOwnFollows],
},
});
export const schemas = {
param: z.object({
account_id: z.string().uuid(),
}),
};
const route = createRoute({
method: "post",
path: "/api/v1/follow_requests/{account_id}/authorize",
summary: "Authorize follow request",
middleware: [auth(meta.auth, meta.permissions)],
request: {
params: schemas.param,
},
responses: {
200: {
description: "Relationship",
content: {
"application/json": {
schema: Relationship.schema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
404: {
description: "Account not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { user } = context.get("auth");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
const { account_id } = context.req.valid("param");
const account = await User.fromId(account_id);
if (!account) {
return context.json({ error: "Account not found" }, 404);
}
const oppositeRelationship = await Relationship.fromOwnerAndSubject(
account,
user,
);
await oppositeRelationship.update({
requested: false,
following: true,
});
const foundRelationship = await Relationship.fromOwnerAndSubject(
user,
account,
);
// Check if accepting remote follow
if (account.isRemote()) {
// Federate follow accept
await user.sendFollowAccept(account);
}
return context.json(foundRelationship.toApi(), 200);
}),
);

View file

@ -0,0 +1,103 @@
import { apiRoute, applyConfig, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { Relationship, User } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables";
import { z } from "zod";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
route: "/api/v1/follow_requests/:account_id/reject",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: true,
},
permissions: {
required: [RolePermissions.ManageOwnFollows],
},
});
export const schemas = {
param: z.object({
account_id: z.string().uuid(),
}),
};
const route = createRoute({
method: "post",
path: "/api/v1/follow_requests/{account_id}/reject",
summary: "Reject follow request",
middleware: [auth(meta.auth, meta.permissions)],
request: {
params: schemas.param,
},
responses: {
200: {
description: "Relationship",
content: {
"application/json": {
schema: Relationship.schema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
404: {
description: "Account not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { user } = context.get("auth");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
const { account_id } = context.req.valid("param");
const account = await User.fromId(account_id);
if (!account) {
return context.json({ error: "Account not found" }, 404);
}
const oppositeRelationship = await Relationship.fromOwnerAndSubject(
account,
user,
);
await oppositeRelationship.update({
requested: false,
following: false,
});
const foundRelationship = await Relationship.fromOwnerAndSubject(
user,
account,
);
// Check if rejecting remote follow
if (account.isRemote()) {
// Federate follow reject
await user.sendFollowReject(account);
}
return context.json(foundRelationship.toApi(), 200);
}),
);

View file

@ -0,0 +1,90 @@
import { apiRoute, applyConfig, auth, idValidator } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { Timeline, User } from "@versia/kit/db";
import { RolePermissions, Users } from "@versia/kit/tables";
import { and, gt, gte, lt, sql } from "drizzle-orm";
import { z } from "zod";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
route: "/api/v1/follow_requests",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: true,
},
permissions: {
required: [RolePermissions.ManageOwnFollows],
},
});
export const schemas = {
query: z.object({
max_id: z.string().regex(idValidator).optional(),
since_id: z.string().regex(idValidator).optional(),
min_id: z.string().regex(idValidator).optional(),
limit: z.coerce.number().int().min(1).max(80).default(40),
}),
};
const route = createRoute({
method: "get",
path: "/api/v1/follow_requests",
summary: "Get follow requests",
middleware: [auth(meta.auth, meta.permissions)],
request: {
query: schemas.query,
},
responses: {
200: {
description: "Follow requests",
content: {
"application/json": {
schema: z.array(User.schema),
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { max_id, since_id, min_id, limit } = context.req.valid("query");
const { user } = context.get("auth");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
const { objects: followRequests, link } =
await Timeline.getUserTimeline(
and(
max_id ? lt(Users.id, max_id) : undefined,
since_id ? gte(Users.id, since_id) : undefined,
min_id ? gt(Users.id, min_id) : undefined,
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${user.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."requested" = true)`,
),
limit,
context.req.url,
);
return context.json(
followRequests.map((u) => u.toApi()),
200,
{
Link: link,
},
);
}),
);

View file

@ -0,0 +1,36 @@
import { apiRoute, applyConfig } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { config } from "~/packages/config-manager";
export const meta = applyConfig({
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 120,
},
route: "/api/v1/frontend/config",
});
const route = createRoute({
method: "get",
path: "/api/v1/frontend/config",
summary: "Get frontend config",
responses: {
200: {
description: "Frontend config",
content: {
"application/json": {
schema: z.record(z.string(), z.any()).default({}),
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, (context) => {
return context.json(config.frontend.settings, 200);
}),
);

View file

@ -0,0 +1,19 @@
import { describe, expect, test } from "bun:test";
import { fakeRequest } from "~/tests/utils";
import { meta } from "./extended_description.ts";
// /api/v1/instance/extended_description
describe(meta.route, () => {
test("should return extended description", async () => {
const response = await fakeRequest(meta.route);
expect(response.status).toBe(200);
const json = await response.json();
expect(json).toEqual({
updated_at: new Date(1970, 0, 0).toISOString(),
content:
'<p>This is a <a href="https://versia.pub">Versia</a> server with the default extended description.</p>\n',
});
});
});

View file

@ -0,0 +1,51 @@
import { apiRoute, applyConfig } from "@/api";
import { renderMarkdownInPath } from "@/markdown";
import { createRoute, z } from "@hono/zod-openapi";
import { config } from "~/packages/config-manager";
export const meta = applyConfig({
route: "/api/v1/instance/extended_description",
ratelimits: {
max: 300,
duration: 60,
},
auth: {
required: false,
},
});
const route = createRoute({
method: "get",
path: "/api/v1/instance/extended_description",
summary: "Get extended description",
responses: {
200: {
description: "Extended description",
content: {
"application/json": {
schema: z.object({
updated_at: z.string(),
content: z.string(),
}),
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { content, lastModified } = await renderMarkdownInPath(
config.instance.extended_description_path ?? "",
"This is a [Versia](https://versia.pub) server with the default extended description.",
);
return context.json(
{
updated_at: lastModified.toISOString(),
content,
},
200,
);
}),
);

View file

@ -0,0 +1,130 @@
import { apiRoute, applyConfig, auth } from "@/api";
import { proxyUrl } from "@/response";
import { createRoute, z } from "@hono/zod-openapi";
import { Instance, Note, User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { and, eq, isNull } from "drizzle-orm";
import manifest from "~/package.json";
import { config } from "~/packages/config-manager";
export const meta = applyConfig({
route: "/api/v1/instance",
ratelimits: {
max: 300,
duration: 60,
},
auth: {
required: false,
},
});
const route = createRoute({
method: "get",
path: "/api/v1/instance",
summary: "Get instance information",
middleware: [auth(meta.auth)],
responses: {
200: {
description: "Instance information",
content: {
// TODO: Add schemas for this response
"application/json": {
schema: z.any(),
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
// Get software version from package.json
const version = manifest.version;
const statusCount = await Note.getCount();
const userCount = await User.getCount();
const contactAccount = await User.fromSql(
and(isNull(Users.instanceId), eq(Users.isAdmin, true)),
);
const knownDomainsCount = await Instance.getCount();
const oidcConfig = config.plugins?.config?.["@versia/openid"] as
| {
forced?: boolean;
providers?: {
id: string;
name: string;
icon: string;
}[];
}
| undefined;
// TODO: fill in more values
return context.json({
approval_required: false,
configuration: {
polls: {
max_characters_per_option:
config.validation.max_poll_option_size,
max_expiration: config.validation.max_poll_duration,
max_options: config.validation.max_poll_options,
min_expiration: config.validation.min_poll_duration,
},
statuses: {
characters_reserved_per_url: 0,
max_characters: config.validation.max_note_size,
max_media_attachments:
config.validation.max_media_attachments,
},
},
description: config.instance.description,
email: "",
invites_enabled: false,
registrations: config.signups.registration,
languages: ["en"],
rules: config.signups.rules.map((r, index) => ({
id: String(index),
text: r,
})),
stats: {
domain_count: knownDomainsCount,
status_count: statusCount,
user_count: userCount,
},
thumbnail: proxyUrl(config.instance.logo),
banner: proxyUrl(config.instance.banner),
title: config.instance.name,
uri: config.http.base_url,
urls: {
streaming_api: "",
},
version: "4.3.0-alpha.3+glitch",
versia_version: version,
// TODO: Put into plugin directly
sso: {
forced: oidcConfig?.forced ?? false,
providers:
oidcConfig?.providers?.map((p) => ({
name: p.name,
icon: proxyUrl(p.icon) || undefined,
id: p.id,
})) ?? [],
},
contact_account: contactAccount?.toApi() || undefined,
} satisfies Record<string, unknown> & {
banner: string | null;
versia_version: string;
sso: {
forced: boolean;
providers: {
id: string;
name: string;
icon?: string;
}[];
};
});
}),
);

View file

@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test";
import { fakeRequest } from "~/tests/utils";
import { meta } from "./privacy_policy.ts";
// /api/v1/instance/privacy_policy
describe(meta.route, () => {
test("should return privacy policy", async () => {
const response = await fakeRequest(meta.route);
expect(response.status).toBe(200);
const json = await response.json();
expect(json).toEqual({
updated_at: new Date(1970, 0, 0).toISOString(),
// This instance has not provided any privacy policy.
content:
"<p>This instance has not provided any privacy policy.</p>\n",
});
});
});

View file

@ -0,0 +1,49 @@
import { apiRoute, applyConfig, auth } from "@/api";
import { renderMarkdownInPath } from "@/markdown";
import { createRoute, z } from "@hono/zod-openapi";
import { config } from "~/packages/config-manager";
export const meta = applyConfig({
route: "/api/v1/instance/privacy_policy",
ratelimits: {
max: 300,
duration: 60,
},
auth: {
required: false,
},
});
const route = createRoute({
method: "get",
path: "/api/v1/instance/privacy_policy",
summary: "Get instance privacy policy",
middleware: [auth(meta.auth)],
responses: {
200: {
description: "Instance privacy policy",
content: {
"application/json": {
schema: z.object({
updated_at: z.string(),
content: z.string(),
}),
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { content, lastModified } = await renderMarkdownInPath(
config.instance.privacy_policy_path ?? "",
"This instance has not provided any privacy policy.",
);
return context.json({
updated_at: lastModified.toISOString(),
content,
});
}),
);

View file

@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test";
import { config } from "~/packages/config-manager/index.ts";
import { fakeRequest } from "~/tests/utils";
import { meta } from "./rules.ts";
// /api/v1/instance/rules
describe(meta.route, () => {
test("should return rules", async () => {
const response = await fakeRequest(meta.route);
expect(response.status).toBe(200);
const json = await response.json();
expect(json).toEqual(
config.signups.rules.map((rule, index) => ({
id: String(index),
text: rule,
hint: "",
})),
);
});
});

View file

@ -0,0 +1,49 @@
import { apiRoute, applyConfig, auth } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { config } from "~/packages/config-manager";
export const meta = applyConfig({
route: "/api/v1/instance/rules",
ratelimits: {
max: 300,
duration: 60,
},
auth: {
required: false,
},
});
const route = createRoute({
method: "get",
path: "/api/v1/instance/rules",
summary: "Get instance rules",
middleware: [auth(meta.auth)],
responses: {
200: {
description: "Instance rules",
content: {
"application/json": {
schema: z.array(
z.object({
id: z.string(),
text: z.string(),
hint: z.string(),
}),
),
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, (context) => {
return context.json(
config.signups.rules.map((rule, index) => ({
id: String(index),
text: rule,
hint: "",
})),
);
}),
);

View file

@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test";
import { fakeRequest } from "~/tests/utils";
import { meta } from "./tos.ts";
// /api/v1/instance/tos
describe(meta.route, () => {
test("should return terms of service", async () => {
const response = await fakeRequest(meta.route);
expect(response.status).toBe(200);
const json = await response.json();
expect(json).toEqual({
updated_at: new Date(1970, 0, 0).toISOString(),
// This instance has not provided any terms of service.
content:
"<p>This instance has not provided any terms of service.</p>\n",
});
});
});

View file

@ -0,0 +1,49 @@
import { apiRoute, applyConfig, auth } from "@/api";
import { renderMarkdownInPath } from "@/markdown";
import { createRoute, z } from "@hono/zod-openapi";
import { config } from "~/packages/config-manager";
export const meta = applyConfig({
route: "/api/v1/instance/tos",
ratelimits: {
max: 300,
duration: 60,
},
auth: {
required: false,
},
});
const route = createRoute({
method: "get",
path: "/api/v1/instance/tos",
summary: "Get instance terms of service",
middleware: [auth(meta.auth)],
responses: {
200: {
description: "Instance terms of service",
content: {
"application/json": {
schema: z.object({
updated_at: z.string(),
content: z.string(),
}),
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { content, lastModified } = await renderMarkdownInPath(
config.instance.tos_path ?? "",
"This instance has not provided any terms of service.",
);
return context.json({
updated_at: lastModified.toISOString(),
content,
});
}),
);

View file

@ -0,0 +1,86 @@
import { afterAll, describe, expect, test } from "bun:test";
import { fakeRequest, getTestStatuses, getTestUsers } from "~/tests/utils";
import { meta } from "./index.ts";
const { users, tokens, deleteUsers } = await getTestUsers(1);
const timeline = await getTestStatuses(10, users[0]);
afterAll(async () => {
await deleteUsers();
});
// /api/v1/markers
describe(meta.route, () => {
test("should return 401 if not authenticated", async () => {
const response = await fakeRequest(meta.route, {
method: "GET",
});
expect(response.status).toBe(401);
});
test("should return empty markers", async () => {
const response = await fakeRequest(
`${meta.route}?${new URLSearchParams([
["timeline[]", "home"],
["timeline[]", "notifications"],
])}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({});
});
test("should create markers", async () => {
const response = await fakeRequest(
`${meta.route}?${new URLSearchParams({
"home[last_read_id]": timeline[0].id,
})}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
home: {
last_read_id: timeline[0].id,
updated_at: expect.any(String),
version: 1,
},
});
});
test("should return markers", async () => {
const response = await fakeRequest(
`${meta.route}?${new URLSearchParams([
["timeline[]", "home"],
["timeline[]", "notifications"],
])}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
},
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
home: {
last_read_id: timeline[0].id,
updated_at: expect.any(String),
version: 1,
},
});
});
});

255
api/api/v1/markers/index.ts Normal file
View file

@ -0,0 +1,255 @@
import { apiRoute, applyConfig, auth, idValidator } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import type { Marker as ApiMarker } from "@versia/client/types";
import { db } from "@versia/kit/db";
import { Markers, RolePermissions } from "@versia/kit/tables";
import { type SQL, and, eq } from "drizzle-orm";
import { z } from "zod";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
route: "/api/v1/markers",
ratelimits: {
max: 100,
duration: 60,
},
auth: {
required: true,
oauthPermissions: ["read:blocks"],
},
permissions: {
required: [RolePermissions.ManageOwnAccount],
},
});
export const schemas = {
markers: z.object({
home: z
.object({
last_read_id: z.string().regex(idValidator),
version: z.number(),
updated_at: z.string(),
})
.nullable()
.optional(),
notifications: z
.object({
last_read_id: z.string().regex(idValidator),
version: z.number(),
updated_at: z.string(),
})
.nullable()
.optional(),
}),
};
const routeGet = createRoute({
method: "get",
path: "/api/v1/markers",
summary: "Get markers",
middleware: [auth(meta.auth, meta.permissions)],
request: {
query: z.object({
"timeline[]": z
.array(z.enum(["home", "notifications"]))
.max(2)
.or(z.enum(["home", "notifications"]))
.optional(),
}),
},
responses: {
200: {
description: "Markers",
content: {
"application/json": {
schema: schemas.markers,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
const routePost = createRoute({
method: "post",
path: "/api/v1/markers",
summary: "Update markers",
middleware: [auth(meta.auth, meta.permissions)],
request: {
query: z.object({
"home[last_read_id]": z.string().regex(idValidator).optional(),
"notifications[last_read_id]": z
.string()
.regex(idValidator)
.optional(),
}),
},
responses: {
200: {
description: "Markers",
content: {
"application/json": {
schema: schemas.markers,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) => {
app.openapi(routeGet, async (context) => {
const { "timeline[]": timelines } = context.req.valid("query");
const { user } = context.get("auth");
const timeline = Array.isArray(timelines) ? timelines : [];
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
if (!timeline) {
return context.json({}, 200);
}
const markers: ApiMarker = {
home: undefined,
notifications: undefined,
};
if (timeline.includes("home")) {
const found = await db.query.Markers.findFirst({
where: (marker, { and, eq }): SQL | undefined =>
and(
eq(marker.userId, user.id),
eq(marker.timeline, "home"),
),
});
const totalCount = await db.$count(
Markers,
and(eq(Markers.userId, user.id), eq(Markers.timeline, "home")),
);
if (found?.noteId) {
markers.home = {
last_read_id: found.noteId,
version: totalCount,
updated_at: new Date(found.createdAt).toISOString(),
};
}
}
if (timeline.includes("notifications")) {
const found = await db.query.Markers.findFirst({
where: (marker, { and, eq }): SQL | undefined =>
and(
eq(marker.userId, user.id),
eq(marker.timeline, "notifications"),
),
});
const totalCount = await db.$count(
Markers,
and(
eq(Markers.userId, user.id),
eq(Markers.timeline, "notifications"),
),
);
if (found?.notificationId) {
markers.notifications = {
last_read_id: found.notificationId,
version: totalCount,
updated_at: new Date(found.createdAt).toISOString(),
};
}
}
return context.json(markers, 200);
});
app.openapi(routePost, async (context) => {
const {
"home[last_read_id]": homeId,
"notifications[last_read_id]": notificationsId,
} = context.req.valid("query");
const { user } = context.get("auth");
if (!user) {
return context.json({ error: "Unauthorized" }, 401);
}
const markers: ApiMarker = {
home: undefined,
notifications: undefined,
};
if (homeId) {
const insertedMarker = (
await db
.insert(Markers)
.values({
userId: user.id,
timeline: "home",
noteId: homeId,
})
.returning()
)[0];
const totalCount = await db.$count(
Markers,
and(eq(Markers.userId, user.id), eq(Markers.timeline, "home")),
);
markers.home = {
last_read_id: homeId,
version: totalCount,
updated_at: new Date(insertedMarker.createdAt).toISOString(),
};
}
if (notificationsId) {
const insertedMarker = (
await db
.insert(Markers)
.values({
userId: user.id,
timeline: "notifications",
notificationId: notificationsId,
})
.returning()
)[0];
const totalCount = await db.$count(
Markers,
and(
eq(Markers.userId, user.id),
eq(Markers.timeline, "notifications"),
),
);
markers.notifications = {
last_read_id: notificationsId,
version: totalCount,
updated_at: new Date(insertedMarker.createdAt).toISOString(),
};
}
return context.json(markers, 200);
});
});

View file

@ -0,0 +1,167 @@
import { apiRoute, applyConfig, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { Attachment } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables";
import { z } from "zod";
import { MediaManager } from "~/classes/media/media-manager";
import { config } from "~/packages/config-manager/index.ts";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
ratelimits: {
max: 10,
duration: 60,
},
route: "/api/v1/media/:id",
auth: {
required: true,
oauthPermissions: ["write:media"],
},
permissions: {
required: [RolePermissions.ManageOwnMedia],
},
});
export const schemas = {
param: z.object({
id: z.string().uuid(),
}),
form: z.object({
thumbnail: z.instanceof(File).optional(),
description: z
.string()
.max(config.validation.max_media_description_size)
.optional(),
focus: z.string().optional(),
}),
};
const routePut = createRoute({
method: "put",
path: "/api/v1/media/{id}",
summary: "Update media",
middleware: [auth(meta.auth, meta.permissions)],
request: {
params: schemas.param,
body: {
content: {
"multipart/form-data": {
schema: schemas.form,
},
},
},
},
responses: {
204: {
description: "Media updated",
content: {
"application/json": {
schema: Attachment.schema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
404: {
description: "Media not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
const routeGet = createRoute({
method: "get",
path: "/api/v1/media/{id}",
summary: "Get media",
middleware: [auth(meta.auth, meta.permissions)],
request: {
params: schemas.param,
},
responses: {
200: {
description: "Media",
content: {
"application/json": {
schema: Attachment.schema,
},
},
},
404: {
description: "Media not found",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) => {
app.openapi(routePut, async (context) => {
const { id } = context.req.valid("param");
const attachment = await Attachment.fromId(id);
if (!attachment) {
return context.json({ error: "Media not found" }, 404);
}
const { description, thumbnail } = context.req.valid("form");
let thumbnailUrl = attachment.data.thumbnailUrl;
const mediaManager = new MediaManager(config);
if (thumbnail) {
const { path } = await mediaManager.addFile(thumbnail);
thumbnailUrl = Attachment.getUrl(path);
}
const descriptionText = description || attachment.data.description;
if (
descriptionText !== attachment.data.description ||
thumbnailUrl !== attachment.data.thumbnailUrl
) {
await attachment.update({
description: descriptionText,
thumbnailUrl,
});
return context.json(attachment.toApi(), 204);
}
return context.json(attachment.toApi(), 204);
});
app.openapi(routeGet, async (context) => {
const { id } = context.req.valid("param");
const attachment = await Attachment.fromId(id);
if (!attachment) {
return context.json({ error: "Media not found" }, 404);
}
return context.json(attachment.toApi(), 200);
});
});

146
api/api/v1/media/index.ts Normal file
View file

@ -0,0 +1,146 @@
import { apiRoute, applyConfig, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi";
import { Attachment } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables";
import sharp from "sharp";
import { z } from "zod";
import { MediaManager } from "~/classes/media/media-manager";
import { config } from "~/packages/config-manager/index.ts";
import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({
ratelimits: {
max: 10,
duration: 60,
},
route: "/api/v1/media",
auth: {
required: true,
oauthPermissions: ["write:media"],
},
permissions: {
required: [RolePermissions.ManageOwnMedia],
},
});
export const schemas = {
form: z.object({
file: z.instanceof(File),
thumbnail: z.instanceof(File).optional(),
description: z
.string()
.max(config.validation.max_media_description_size)
.optional(),
focus: z.string().optional(),
}),
};
const route = createRoute({
method: "post",
path: "/api/v1/media",
summary: "Upload media",
middleware: [auth(meta.auth, meta.permissions)],
request: {
body: {
content: {
"multipart/form-data": {
schema: schemas.form,
},
},
},
},
responses: {
200: {
description: "Attachment",
content: {
"application/json": {
schema: Attachment.schema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
413: {
description: "File too large",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
415: {
description: "Disallowed file type",
content: {
"application/json": {
schema: ErrorSchema,
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { file, thumbnail, description } = context.req.valid("form");
if (file.size > config.validation.max_media_size) {
return context.json(
{
error: `File too large, max size is ${config.validation.max_media_size} bytes`,
},
413,
);
}
if (
config.validation.enforce_mime_types &&
!config.validation.allowed_mime_types.includes(file.type)
) {
return context.json({ error: "Disallowed file type" }, 415);
}
const sha256 = new Bun.SHA256();
const isImage = file.type.startsWith("image/");
const metadata = isImage
? await sharp(await file.arrayBuffer()).metadata()
: null;
const mediaManager = new MediaManager(config);
const { path, blurhash } = await mediaManager.addFile(file);
const url = Attachment.getUrl(path);
let thumbnailUrl = "";
if (thumbnail) {
const { path } = await mediaManager.addFile(thumbnail);
thumbnailUrl = Attachment.getUrl(path);
}
const newAttachment = await Attachment.insert({
url,
thumbnailUrl,
sha256: sha256.update(await file.arrayBuffer()).digest("hex"),
mimeType: file.type,
description: description ?? "",
size: file.size,
blurhash: blurhash ?? undefined,
width: metadata?.width ?? undefined,
height: metadata?.height ?? undefined,
});
// TODO: Add job to process videos and other media
return context.json(newAttachment.toApi(), 200);
}),
);

View file

@ -0,0 +1,83 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { fakeRequest, getTestUsers } from "~/tests/utils";
import { meta } from "./index.ts";
const { users, tokens, deleteUsers } = await getTestUsers(3);
afterAll(async () => {
await deleteUsers();
});
beforeAll(async () => {
const response = await fakeRequest(
`/api/v1/accounts/${users[1].id}/mute`,
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
},
);
expect(response.status).toBe(200);
});
// /api/v1/mutes
describe(meta.route, () => {
test("should return 401 if not authenticated", async () => {
const response = await fakeRequest(
meta.route.replace(":id", users[1].id),
{
method: "GET",
},
);
expect(response.status).toBe(401);
});
test("should return mutes", async () => {
const response = await fakeRequest(meta.route, {
method: "GET",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
});
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: users[1].id,
}),
]),
);
});
test("should return mutes after unmute", async () => {
const response = await fakeRequest(
`/api/v1/accounts/${users[1].id}/unmute`,
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
},
);
expect(response.status).toBe(200);
const response2 = await fakeRequest(meta.route, {
method: "GET",
headers: {
Authorization: `Bearer ${tokens[0].data.accessToken}`,
},
});
expect(response2.status).toBe(200);
const body = await response2.json();
expect(body).toEqual([]);
});
});

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