Merge pull request #34 from versia-pub/refactor/config
Some checks failed
CodeQL Scan / Analyze (javascript-typescript) (push) Failing after 47s
Build Docker Images / lint (push) Successful in 30s
Build Docker Images / check (push) Successful in 1m1s
Build Docker Images / tests (push) Failing after 6s
Build Docker Images / build (server, Dockerfile, ${{ github.repository_owner }}/server) (push) Has been skipped
Build Docker Images / build (worker, Worker.Dockerfile, ${{ github.repository_owner }}/worker) (push) Has been skipped
Deploy Docs to GitHub Pages / build (push) Failing after 12s
Mirror to Codeberg / Mirror (push) Failing after 1s
Deploy Docs to GitHub Pages / Deploy (push) Has been skipped
Nix Build / check (push) Failing after 32m28s

Overhaul config system
This commit is contained in:
Gaspard Wierzbinski 2025-02-15 02:59:06 +01:00 committed by GitHub
commit 131fd1c6e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
118 changed files with 3882 additions and 5289 deletions

View file

@ -1,99 +1,168 @@
[database] # 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
host = "localhost" host = "localhost"
port = 5432 port = 5432
username = "versia" username = "versia"
# Sensitive value
password = "versia" password = "versia"
database = "versia" database = "versia"
# Additional read-only replicas
# [[postgres.replicas]]
# host = "other-host"
# port = 5432
# username = "versia"
# password = "mycoolpassword2"
# database = "replica1"
[redis.queue] [redis.queue]
# A Redis database used for managing queues.
# Required for federation
host = "localhost" host = "localhost"
port = 6379 port = 6379
password = "" # Sensitive value
# password = "test"
database = 0 database = 0
[redis.cache] # A Redis database used for caching SQL queries.
host = "localhost" # Optional, can be the same as the queue instance
port = 6379 # [redis.cache]
password = "" # host = "localhost"
database = 1 # port = 6380
# database = 1
# password = ""
# Search and indexing configuration
[search]
# Enable indexing and searching?
enabled = false enabled = false
[sonic] # Optional if search is disabled
[search.sonic]
host = "localhost" host = "localhost"
port = 40007 port = 40007
# Sensitive value
password = "" password = ""
enabled = false
[signups] [registration]
# Whether to enable registrations or not # Can users sign up freely?
registration = true allow = true
rules = [ # NOT IMPLEMENTED
"Do not harass others", require_approval = false
"Be nice to people", # Message to show to users when registration is disabled
"Don't spam", # message = "ran out of spoons to moderate registrations, sorry"
"Don't post illegal content",
]
[http] [http]
# URL that the instance will be accessible at
base_url = "http://0.0.0.0:8080" 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 = "0.0.0.0"
bind_port = 8080 bind_port = 8080
# Bans IPv4 or IPv6 IPs (wildcards, networks and ranges are supported) # Bans IPv4 or IPv6 IPs (wildcards, networks and ranges are supported)
banned_ips = [] banned_ips = []
# Banned user agents, regex format
banned_user_agents = [
# "curl\/7.68.0",
# "wget\/1.20.3",
]
[smtp] # 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
# The URL to reach the frontend at (should be on a local network)
url = "http://localhost:3000"
[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 server to use for sending emails # SMTP server to use for sending emails
server = "smtp.example.com" # server = "smtp.example.com"
port = 465 # port = 465
username = "test@example.com" # username = "test@example.com"
password = "password123" # Sensitive value
tls = true # password = "password123"
# tls = true
[media] [media]
# Can be "s3" or "local", where "local" uploads the file to the local filesystem # Can be "s3" or "local", where "local" uploads the file to the local filesystem
# If you need to change this value after setting up your instance, you must move all the files # Changing this value will not retroactively apply to existing data
# from one backend to the other manually # Don't forget to fill in the s3 config :3
backend = "local" 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 # If media backend is "local", this is the folder where the files will be stored
local_uploads_folder = "uploads" # Can be any path
uploads_path = "uploads"
[media.conversion] [media.conversion]
# Whether to automatically convert images to another format on upload
convert_images = false convert_images = false
# Can be: "jxl", "webp", "avif", "png", "jpg", "heif" # Can be: "image/jxl", "image/webp", "image/avif", "image/png", "image/jpeg", "image/heif", "image/gif"
# JXL support will likely not work # JXL support will likely not work
convert_to = "webp" convert_to = "image/webp"
# Also convert SVG images?
convert_vectors = false
# [s3] # [s3]
# Can be left blank if you don't use the S3 media backend # Can be left commented if you don't use the S3 media backend
# endpoint = "https://s3-us-west-2.amazonaws.com" # endpoint = "https://s3.example.com"
# access_key = "" # Sensitive value
# secret_access_key = "" # access_key = "XXXXX"
# region = "us-west-2" # Sensitive value
# secret_access_key = "XXX"
# region = "us-east-1"
# bucket_name = "versia" # bucket_name = "versia"
# public_url = "https://cdn.example.com" # public_url = "https://cdn.example.com"
[validation] [validation]
# Self explanatory # Checks user data
max_displayname_size = 50 # Does not retroactively apply to previously entered data
max_bio_size = 160 [validation.accounts]
max_note_size = 5000 max_displayname_characters = 50
max_avatar_size = 5_000_000 max_username_characters = 30
max_header_size = 5_000_000 max_bio_characters = 5000
max_media_size = 40_000_000 max_avatar_bytes = 5_000_000
max_media_attachments = 10 max_header_bytes = 5_000_000
max_media_description_size = 1000 # Regex is allowed here
max_poll_options = 20 disallowed_usernames = [
max_poll_option_size = 500 "well-known",
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", "about",
"activities", "activities",
"api", "api",
@ -119,12 +188,14 @@ username_blacklist = [
"search", "search",
"mfa", "mfa",
] ]
# Whether to blacklist known temporary email providers max_field_count = 10
blacklist_tempmail = false max_field_name_characters = 1000
# Additional email providers to blacklist max_field_value_characters = 1000
email_blacklist = [] max_pinned_notes = 20
# Valid URL schemes, otherwise the URL is parsed as text
url_scheme_whitelist = [ [validation.notes]
max_characters = 5000
allowed_url_schemes = [
"http", "http",
"https", "https",
"ftp", "ftp",
@ -142,76 +213,122 @@ url_scheme_whitelist = [
"mumble", "mumble",
"ssb", "ssb",
"gemini", "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] [validation.challenges]
# "Challenges" (aka captchas) are a way to verify that a user is human # "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 # This means that they do not require any user interaction, instead
# they require the user's computer to do a small amount of work # they require the user's computer to do a small amount of work
enabled = true # The difficulty of the challenge, higher is will take more time to solve
# The difficulty of the challenge, higher is harder
difficulty = 50000 difficulty = 50000
# Challenge expiration time in seconds # Challenge expiration time in seconds
expiration = 300 # 5 minutes expiration = 300 # 5 minutes
# Leave this empty to generate a new key # Leave this empty to generate a new key
# Sensitive value
key = "YBpAV0KZOeM/MZ4kOb2E9moH9gCUr00Co9V7ncGRJ3wbd/a9tLDKKFdI0BtOcnlpfx0ZBh0+w3WSvsl0TsesTg==" 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] [notifications]
# Web Push Notifications configuration.
# Leave out to disable.
[notifications.push] [notifications.push]
# Whether to enable push notifications # Subject field embedded in the push notification
enabled = true # subject = "mailto:joe@example.com"
#
[notifications.push.vapid] [notifications.push.vapid_keys]
# VAPID keys for push notifications # VAPID keys for push notifications
# Run Versia Server with those values missing to generate new keys # Run Versia Server with those values missing to generate new keys
# Sensitive value
public = "BBanhyj2_xWwbTsWld3T49VcAoKZHrVJTzF1f6Av2JwQY_wUi3CF9vZ0WeEcACRj6EEqQ7N35CkUh5epF7n4P_s" public = "BBanhyj2_xWwbTsWld3T49VcAoKZHrVJTzF1f6Av2JwQY_wUi3CF9vZ0WeEcACRj6EEqQ7N35CkUh5epF7n4P_s"
# Sensitive value
private = "Eujaz7NsF0rKZOVrAFL7mMpFdl96f591ERsRn81unq0" private = "Eujaz7NsF0rKZOVrAFL7mMpFdl96f591ERsRn81unq0"
# Optional
# subject = "mailto:joe@example.com"
[defaults] [defaults]
# Default visibility for new notes # 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" visibility = "public"
# Default language for new notes # Default language for new notes (ISO code)
language = "en" language = "en"
# Default avatar, must be a valid URL or "" # Default avatar, must be a valid URL or left out for a placeholder avatar
# avatar = "" # avatar = ""
# Default header, must be a valid URL or "" # Default header, must be a valid URL or left out for none
# header = "" # 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] [federation]
# This is a list of domain names, such as "mastodon.social" or "pleroma.site" # This is a list of domain names, such as "mastodon.social" or "pleroma.site"
@ -236,57 +353,140 @@ reactions = []
banners = [] banners = []
avatars = [] 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] [instance]
name = "Versia" name = "Versia"
description = "A test instance of Versia Server" description = "A Versia Server instance"
# 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"
[filters] # Primary instance languages. ISO 639-1 codes.
# Regex filters for federated and local data languages = ["en"]
# Drops data matching the filters
# Does not apply retroactively to existing data
# Note contents [instance.contact]
note_content = [ email = "staff@yourinstance.com"
# "(https?://)?(www\\.)?youtube\\.com/watch\\?v=[a-zA-Z0-9_-]+",
# "(https?://)?(www\\.)?youtu\\.be/[a-zA-Z0-9_-]+", [instance.branding]
] # logo = "https://cdn.example.com/logo.png"
emoji = [] # banner = "https://cdn.example.com/banner.png"
# These will drop users matching the filters
username = [] # Used for federation. If left empty or missing, the server will generate one for you.
displayname = [] [instance.keys]
bio = [] # 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 = []
[logging] [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
# Log all filtered objects # Available levels: debug, info, warning, error, fatal
log_filters = true log_level = "debug"
[ratelimits] log_file_path = "logs/versia.log"
# 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
[ratelimits.custom] [logging.types]
# Add in any API route in this style here # Either pass a boolean
# Applies before the global ratelimit changes # requests = true
# "/api/v1/accounts/:id/block" = { duration = 30, max = 60 } # Or a table with the following keys:
# "/api/v1/timelines/public" = { duration = 60, max = 200 } # requests_content = { level = "debug", log_file_path = "logs/requests.log" }
# Available types are: requests, responses, requests_content, filters
# https://sentry.io support
# Uncomment to enable
# [logging.sentry]
# Sentry DSN for error logging
# dsn = "https://example.com"
# debug = false
# sample_rate = 1.0
# traces_sample_rate = 1.0
# Can also be regex
# trace_propagation_targets = []
# max_breadcrumbs = 100
# environment = "production"
[plugins] [plugins]
# Whether to automatically load all plugins in the plugins directory
autoload = true
# Override for autoload
[plugins.overrides]
enabled = []
disabled = []
[plugins.config."@versia/openid"]
# If enabled, Versia will require users to log in with an OpenID provider
forced = false
# Allow registration with OpenID providers
# If signups.registration is false, it will only be possible to register with OpenID
allow_registration = true
[plugins.config."@versia/openid".keys] [plugins.config."@versia/openid".keys]
private = "MC4CAQAwBQYDK2VwBCIEID+H5n9PY3zVKZQcq4jrnE1IiRd2EWWr8ApuHUXmuOzl" # Run Versia Server with those values missing to generate a new key
public = "MCowBQYDK2VwAyEAzenliNkgpXYsh3gXTnAoUWzlCPjIOppmAVx2DBlLsC8=" public = "MCowBQYDK2VwAyEAfyZx8r98gVHtdH5EF1NYrBeChOXkt50mqiwKO2TX0f8="
private = "MC4CAQAwBQYDK2VwBCIEILDi1g7+bwNjBBvL4CRWHZpCFBR2m2OPCot62Wr+TCbq"
# 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.*
# [[plugins.config."@versia/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"

View file

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

View file

@ -7,7 +7,7 @@ import type { Context } from "hono";
import { setCookie } from "hono/cookie"; import { setCookie } from "hono/cookie";
import { SignJWT } from "jose"; import { SignJWT } from "jose";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
const schemas = { const schemas = {
form: z.object({ form: z.object({

View file

@ -3,7 +3,7 @@ import { createRoute, z } from "@hono/zod-openapi";
import { db } from "@versia/kit/db"; import { db } from "@versia/kit/db";
import { Applications, Tokens } from "@versia/kit/tables"; import { Applications, Tokens } from "@versia/kit/tables";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
const schemas = { const schemas = {
query: z.object({ query: z.object({

View file

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

View file

@ -4,7 +4,7 @@ import { User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables"; import { Users } from "@versia/kit/tables";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { Context } from "hono"; import type { Context } from "hono";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
const schemas = { const schemas = {
form: z.object({ form: z.object({

View file

@ -10,8 +10,8 @@ import { Timeline } from "@versia/kit/db";
import { Notes, RolePermissions } from "@versia/kit/tables"; import { Notes, RolePermissions } from "@versia/kit/tables";
import { and, eq, gt, gte, inArray, isNull, lt, or, sql } from "drizzle-orm"; import { and, eq, gt, gte, inArray, isNull, lt, or, sql } from "drizzle-orm";
import { Account as AccountSchema } from "~/classes/schemas/account"; import { Account as AccountSchema } from "~/classes/schemas/account";
import { zBoolean } from "~/classes/schemas/common.ts";
import { Status as StatusSchema } from "~/classes/schemas/status"; import { Status as StatusSchema } from "~/classes/schemas/status";
import { zBoolean } from "~/packages/config-manager/config.type";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",

View file

@ -6,8 +6,8 @@ import { Users } from "@versia/kit/tables";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import ISO6391 from "iso-639-1"; import ISO6391 from "iso-639-1";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/packages/config-manager"; import { zBoolean } from "~/classes/schemas/common";
import { zBoolean } from "~/packages/config-manager/config.type"; import { config } from "~/config.ts";
const schema = z.object({ const schema = z.object({
username: z.string().openapi({ username: z.string().openapi({
@ -157,7 +157,7 @@ export default apiRoute((app) =>
const { username, email, password, agreement, locale } = const { username, email, password, agreement, locale } =
context.req.valid("json"); context.req.valid("json");
if (!config.signups.registration) { if (!config.registration.allow) {
throw new ApiError(422, "Registration is disabled"); throw new ApiError(422, "Registration is disabled");
} }
@ -217,7 +217,11 @@ export default apiRoute((app) =>
} }
// Check if username doesnt match filters // Check if username doesnt match filters
if (config.filters.username.some((filter) => username?.match(filter))) { if (
config.validation.filters.username.some((filter) =>
filter.test(username),
)
) {
errors.details.username.push({ errors.details.username.push({
error: "ERR_INVALID", error: "ERR_INVALID",
description: "contains blocked words", description: "contains blocked words",
@ -225,10 +229,13 @@ export default apiRoute((app) =>
} }
// Check if username is too long // Check if username is too long
if ((username?.length ?? 0) > config.validation.max_username_size) { if (
(username?.length ?? 0) >
config.validation.accounts.max_username_characters
) {
errors.details.username.push({ errors.details.username.push({
error: "ERR_TOO_LONG", error: "ERR_TOO_LONG",
description: `is too long (maximum is ${config.validation.max_username_size} characters)`, description: `is too long (maximum is ${config.validation.accounts.max_username_characters} characters)`,
}); });
} }
@ -241,7 +248,11 @@ export default apiRoute((app) =>
} }
// Check if username is reserved // Check if username is reserved
if (config.validation.username_blacklist.includes(username ?? "")) { if (
config.validation.accounts.disallowed_usernames.some((filter) =>
filter.test(username),
)
) {
errors.details.username.push({ errors.details.username.push({
error: "ERR_RESERVED", error: "ERR_RESERVED",
description: "is reserved", description: "is reserved",
@ -274,9 +285,11 @@ export default apiRoute((app) =>
// Check if email is blocked // Check if email is blocked
if ( if (
config.validation.email_blacklist.includes(email) || config.validation.emails.disallowed_domains.some((f) =>
(config.validation.blacklist_tempmail && f.test(email.split("@")[1]),
tempmailDomains.domains.includes((email ?? "").split("@")[1])) ) ||
(config.validation.emails.disallow_tempmail &&
tempmailDomains.domains.includes(email.split("@")[1]))
) { ) {
errors.details.email.push({ errors.details.email.push({
error: "ERR_BLOCKED", error: "ERR_BLOCKED",

View file

@ -12,7 +12,7 @@ import { and, eq, isNull } from "drizzle-orm";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { Account } from "~/classes/schemas/account"; import { Account } from "~/classes/schemas/account";
import { Account as AccountSchema } from "~/classes/schemas/account"; import { Account as AccountSchema } from "~/classes/schemas/account";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",

View file

@ -3,8 +3,8 @@ import { createRoute, z } from "@hono/zod-openapi";
import { Relationship } from "@versia/kit/db"; import { Relationship } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables"; import { RolePermissions } from "@versia/kit/tables";
import { Account as AccountSchema } from "~/classes/schemas/account"; import { Account as AccountSchema } from "~/classes/schemas/account";
import { zBoolean } from "~/classes/schemas/common.ts";
import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship"; import { Relationship as RelationshipSchema } from "~/classes/schemas/relationship";
import { zBoolean } from "~/packages/config-manager/config.type";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",

View file

@ -6,7 +6,7 @@ import { eq, ilike, not, or, sql } from "drizzle-orm";
import stringComparison from "string-comparison"; import stringComparison from "string-comparison";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { Account as AccountSchema } from "~/classes/schemas/account"; import { Account as AccountSchema } from "~/classes/schemas/account";
import { zBoolean } from "~/packages/config-manager/config.type"; import { zBoolean } from "~/classes/schemas/common.ts";
export const route = createRoute({ export const route = createRoute({
method: "get", method: "get",

View file

@ -1,6 +1,6 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, test } from "bun:test";
import type { Account as APIAccount } from "@versia/client/types"; import type { Account as APIAccount } from "@versia/client/types";
import { config } from "~/packages/config-manager/index.ts"; import { config } from "~/config.ts";
import { fakeRequest, getTestUsers } from "~/tests/utils"; import { fakeRequest, getTestUsers } from "~/tests/utils";
const { tokens, deleteUsers } = await getTestUsers(1); const { tokens, deleteUsers } = await getTestUsers(1);

View file

@ -8,8 +8,8 @@ import { and, eq, isNull } from "drizzle-orm";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { contentToHtml } from "~/classes/functions/status"; import { contentToHtml } from "~/classes/functions/status";
import { Account as AccountSchema } from "~/classes/schemas/account"; import { Account as AccountSchema } from "~/classes/schemas/account";
import { zBoolean } from "~/packages/config-manager/config.type"; import { zBoolean } from "~/classes/schemas/common.ts";
import { config } from "~/packages/config-manager/index.ts"; import { config } from "~/config.ts";
const route = createRoute({ const route = createRoute({
method: "patch", method: "patch",
@ -62,9 +62,9 @@ const route = createRoute({
.refine( .refine(
(v) => (v) =>
v.size <= v.size <=
config.validation config.validation.accounts
.max_avatar_size, .max_avatar_bytes,
`Avatar must be less than ${config.validation.max_avatar_size} bytes`, `Avatar must be less than ${config.validation.accounts.max_avatar_bytes} bytes`,
) )
.openapi({ .openapi({
description: description:
@ -84,9 +84,9 @@ const route = createRoute({
.refine( .refine(
(v) => (v) =>
v.size <= v.size <=
config.validation config.validation.accounts
.max_header_size, .max_header_bytes,
`Header must be less than ${config.validation.max_header_size} bytes`, `Header must be less than ${config.validation.accounts.max_header_bytes} bytes`,
) )
.openapi({ .openapi({
description: description:
@ -144,7 +144,9 @@ const route = createRoute({
.element.shape.value, .element.shape.value,
}), }),
) )
.max(config.validation.max_field_count), .max(
config.validation.accounts.max_field_count,
),
}) })
.partial(), .partial(),
}, },

View file

@ -2,7 +2,7 @@ import { apiRoute, auth } from "@/api";
import { generateChallenge } from "@/challenges"; import { generateChallenge } from "@/challenges";
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
import { ErrorSchema } from "~/types/api"; import { ErrorSchema } from "~/types/api";
const route = createRoute({ const route = createRoute({
@ -45,7 +45,7 @@ const route = createRoute({
export default apiRoute((app) => export default apiRoute((app) =>
app.openapi(route, async (context) => { app.openapi(route, async (context) => {
if (!config.validation.challenges.enabled) { if (!config.validation.challenges) {
throw new ApiError(400, "Challenges are disabled in config"); throw new ApiError(400, "Challenges are disabled in config");
} }

View file

@ -10,7 +10,7 @@ import { createRoute, z } from "@hono/zod-openapi";
import { RolePermissions } from "@versia/kit/tables"; import { RolePermissions } from "@versia/kit/tables";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { CustomEmoji as CustomEmojiSchema } from "~/classes/schemas/emoji"; import { CustomEmoji as CustomEmojiSchema } from "~/classes/schemas/emoji";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
import { ErrorSchema } from "~/types/api"; import { ErrorSchema } from "~/types/api";
const schema = z const schema = z
@ -31,8 +31,8 @@ const schema = z
"Emoji image encoded using multipart/form-data", "Emoji image encoded using multipart/form-data",
}) })
.refine( .refine(
(v) => v.size <= config.validation.max_emoji_size, (v) => v.size <= config.validation.emojis.max_bytes,
`Emoji must be less than ${config.validation.max_emoji_size} bytes`, `Emoji must be less than ${config.validation.emojis.max_bytes} bytes`,
), ),
), ),
category: CustomEmojiSchema.shape.category.optional(), category: CustomEmojiSchema.shape.category.optional(),

View file

@ -6,7 +6,7 @@ import { Emojis, RolePermissions } from "@versia/kit/tables";
import { and, eq, isNull, or } from "drizzle-orm"; import { and, eq, isNull, or } from "drizzle-orm";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { CustomEmoji as CustomEmojiSchema } from "~/classes/schemas/emoji"; import { CustomEmoji as CustomEmojiSchema } from "~/classes/schemas/emoji";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
const schema = z.object({ const schema = z.object({
shortcode: CustomEmojiSchema.shape.shortcode, shortcode: CustomEmojiSchema.shape.shortcode,
@ -25,8 +25,8 @@ const schema = z.object({
"Emoji image encoded using multipart/form-data", "Emoji image encoded using multipart/form-data",
}) })
.refine( .refine(
(v) => v.size <= config.validation.max_emoji_size, (v) => v.size <= config.validation.emojis.max_bytes,
`Emoji must be less than ${config.validation.max_emoji_size} bytes`, `Emoji must be less than ${config.validation.emojis.max_bytes} bytes`,
), ),
), ),
category: CustomEmojiSchema.shape.category.optional(), category: CustomEmojiSchema.shape.category.optional(),

View file

@ -1,6 +1,6 @@
import { apiRoute } from "@/api"; import { apiRoute } from "@/api";
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",

View file

@ -12,7 +12,7 @@ describe("/api/v1/instance/extended_description", () => {
const json = await response.json(); const json = await response.json();
expect(json).toEqual({ expect(json).toEqual({
updated_at: new Date(1970, 0, 0).toISOString(), updated_at: new Date(0).toISOString(),
content: content:
'<p>This is a <a href="https://versia.pub">Versia</a> server with the default extended description.</p>\n', '<p>This is a <a href="https://versia.pub">Versia</a> server with the default extended description.</p>\n',
}); });

View file

@ -1,8 +1,8 @@
import { apiRoute } from "@/api"; import { apiRoute } from "@/api";
import { renderMarkdownInPath } from "@/markdown";
import { createRoute } from "@hono/zod-openapi"; import { createRoute } from "@hono/zod-openapi";
import { markdownParse } from "~/classes/functions/status";
import { ExtendedDescription as ExtendedDescriptionSchema } from "~/classes/schemas/extended-description"; import { ExtendedDescription as ExtendedDescriptionSchema } from "~/classes/schemas/extended-description";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
@ -27,14 +27,17 @@ const route = createRoute({
export default apiRoute((app) => export default apiRoute((app) =>
app.openapi(route, async (context) => { app.openapi(route, async (context) => {
const { content, lastModified } = await renderMarkdownInPath( const content = await markdownParse(
config.instance.extended_description_path ?? "", config.instance.extended_description_path?.content ??
"This is a [Versia](https://versia.pub) server with the default extended description.", "This is a [Versia](https://versia.pub) server with the default extended description.",
); );
return context.json( return context.json(
{ {
updated_at: lastModified.toISOString(), updated_at: new Date(
config.instance.extended_description_path?.file
.lastModified ?? 0,
).toISOString(),
content, content,
}, },
200, 200,

View file

@ -1,13 +1,13 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth } from "@/api";
import { renderMarkdownInPath } from "@/markdown";
import { proxyUrl } from "@/response"; import { proxyUrl } from "@/response";
import { createRoute, type z } from "@hono/zod-openapi"; import { createRoute, type z } from "@hono/zod-openapi";
import { Instance, Note, User } from "@versia/kit/db"; import { Instance, Note, User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables"; import { Users } from "@versia/kit/tables";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import { markdownParse } from "~/classes/functions/status";
import { InstanceV1 as InstanceV1Schema } from "~/classes/schemas/instance-v1"; import { InstanceV1 as InstanceV1Schema } from "~/classes/schemas/instance-v1";
import { config } from "~/config.ts";
import manifest from "~/package.json"; import manifest from "~/package.json";
import { config } from "~/packages/config-manager";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
@ -65,35 +65,38 @@ export default apiRoute((app) =>
} }
| undefined; | undefined;
const { content } = await renderMarkdownInPath( const content = await markdownParse(
config.instance.extended_description_path ?? "", config.instance.extended_description_path?.content ??
"This is a [Versia](https://versia.pub) server with the default extended description.", "This is a [Versia](https://versia.pub) server with the default extended description.",
); );
// TODO: fill in more values
return context.json({ return context.json({
approval_required: false, approval_required: config.registration.require_approval,
configuration: { configuration: {
polls: { polls: {
max_characters_per_option: max_characters_per_option:
config.validation.max_poll_option_size, config.validation.polls.max_option_characters,
max_expiration: config.validation.max_poll_duration, max_expiration:
max_options: config.validation.max_poll_options, config.validation.polls.max_duration_seconds,
min_expiration: config.validation.min_poll_duration, max_options: config.validation.polls.max_options,
min_expiration:
config.validation.polls.min_duration_seconds,
}, },
statuses: { statuses: {
characters_reserved_per_url: 0, characters_reserved_per_url: 0,
max_characters: config.validation.max_note_size, max_characters: config.validation.notes.max_characters,
max_media_attachments: max_media_attachments:
config.validation.max_media_attachments, config.validation.notes.max_attachments,
}, },
media_attachments: { media_attachments: {
supported_mime_types: config.validation.allowed_mime_types, supported_mime_types:
image_size_limit: config.validation.max_media_size, config.validation.media.allowed_mime_types,
image_matrix_limit: config.validation.max_media_size, image_size_limit: config.validation.media.max_bytes,
video_size_limit: config.validation.max_media_size, // TODO: Implement
video_frame_rate_limit: config.validation.max_media_size, image_matrix_limit: 1 ** 10,
video_matrix_limit: config.validation.max_media_size, video_size_limit: 1 ** 10,
video_frame_rate_limit: 60,
video_matrix_limit: 1 ** 10,
}, },
accounts: { accounts: {
max_featured_tags: 100, max_featured_tags: 100,
@ -101,23 +104,22 @@ export default apiRoute((app) =>
}, },
short_description: config.instance.description, short_description: config.instance.description,
description: content, description: content,
// TODO: Add contact email email: config.instance.contact.email,
email: "",
invites_enabled: false, invites_enabled: false,
registrations: config.signups.registration, registrations: config.registration.allow,
// TODO: Implement languages: config.instance.languages,
languages: ["en"], rules: config.instance.rules.map((r, index) => ({
rules: config.signups.rules.map((r, index) => ({
id: String(index), id: String(index),
text: r, text: r.text,
hint: r.hint,
})), })),
stats: { stats: {
domain_count: knownDomainsCount, domain_count: knownDomainsCount,
status_count: statusCount, status_count: statusCount,
user_count: userCount, user_count: userCount,
}, },
thumbnail: config.instance.logo thumbnail: config.instance.branding.logo
? proxyUrl(config.instance.logo).toString() ? proxyUrl(config.instance.branding.logo).toString()
: null, : null,
title: config.instance.name, title: config.instance.name,
uri: config.http.base_url.host, uri: config.http.base_url.host,

View file

@ -10,7 +10,7 @@ describe("/api/v1/instance/privacy_policy", () => {
const json = await response.json(); const json = await response.json();
expect(json).toEqual({ expect(json).toEqual({
updated_at: new Date(1970, 0, 0).toISOString(), updated_at: new Date(0).toISOString(),
// This instance has not provided any privacy policy. // This instance has not provided any privacy policy.
content: content:
"<p>This instance has not provided any privacy policy.</p>\n", "<p>This instance has not provided any privacy policy.</p>\n",

View file

@ -1,8 +1,8 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth } from "@/api";
import { renderMarkdownInPath } from "@/markdown";
import { createRoute } from "@hono/zod-openapi"; import { createRoute } from "@hono/zod-openapi";
import { markdownParse } from "~/classes/functions/status";
import { PrivacyPolicy as PrivacyPolicySchema } from "~/classes/schemas/privacy-policy"; import { PrivacyPolicy as PrivacyPolicySchema } from "~/classes/schemas/privacy-policy";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
@ -32,13 +32,15 @@ const route = createRoute({
export default apiRoute((app) => export default apiRoute((app) =>
app.openapi(route, async (context) => { app.openapi(route, async (context) => {
const { content, lastModified } = await renderMarkdownInPath( const content = await markdownParse(
config.instance.privacy_policy_path ?? "", config.instance.privacy_policy_path?.content ??
"This instance has not provided any privacy policy.", "This instance has not provided any privacy policy.",
); );
return context.json({ return context.json({
updated_at: lastModified.toISOString(), updated_at: new Date(
config.instance.privacy_policy_path?.file.lastModified ?? 0,
).toISOString(),
content, content,
}); });
}), }),

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { config } from "~/packages/config-manager/index.ts"; import { config } from "~/config.ts";
import { fakeRequest } from "~/tests/utils"; import { fakeRequest } from "~/tests/utils";
// /api/v1/instance/rules // /api/v1/instance/rules
@ -11,10 +11,10 @@ describe("/api/v1/instance/rules", () => {
const json = await response.json(); const json = await response.json();
expect(json).toEqual( expect(json).toEqual(
config.signups.rules.map((rule, index) => ({ config.instance.rules.map((r, index) => ({
id: String(index), id: String(index),
text: rule, text: r.text,
hint: "", hint: r.hint,
})), })),
); );
}); });

View file

@ -1,7 +1,7 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth } from "@/api";
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { Rule as RuleSchema } from "~/classes/schemas/rule"; import { Rule as RuleSchema } from "~/classes/schemas/rule";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
@ -32,10 +32,10 @@ const route = createRoute({
export default apiRoute((app) => export default apiRoute((app) =>
app.openapi(route, (context) => { app.openapi(route, (context) => {
return context.json( return context.json(
config.signups.rules.map((rule, index) => ({ config.instance.rules.map((r, index) => ({
id: String(index), id: String(index),
text: rule, text: r.text,
hint: "", hint: r.hint,
})), })),
); );
}), }),

View file

@ -10,7 +10,7 @@ describe("/api/v1/instance/terms_of_service", () => {
const json = await response.json(); const json = await response.json();
expect(json).toEqual({ expect(json).toEqual({
updated_at: new Date(1970, 0, 0).toISOString(), updated_at: new Date(0).toISOString(),
// This instance has not provided any terms of service. // This instance has not provided any terms of service.
content: content:
"<p>This instance has not provided any terms of service.</p>\n", "<p>This instance has not provided any terms of service.</p>\n",

View file

@ -1,8 +1,8 @@
import { apiRoute, auth } from "@/api"; import { apiRoute, auth } from "@/api";
import { renderMarkdownInPath } from "@/markdown";
import { createRoute } from "@hono/zod-openapi"; import { createRoute } from "@hono/zod-openapi";
import { markdownParse } from "~/classes/functions/status";
import { TermsOfService as TermsOfServiceSchema } from "~/classes/schemas/tos"; import { TermsOfService as TermsOfServiceSchema } from "~/classes/schemas/tos";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
@ -33,13 +33,15 @@ const route = createRoute({
export default apiRoute((app) => export default apiRoute((app) =>
app.openapi(route, async (context) => { app.openapi(route, async (context) => {
const { content, lastModified } = await renderMarkdownInPath( const content = await markdownParse(
config.instance.tos_path ?? "", config.instance.tos_path?.content ??
"This instance has not provided any terms of service.", "This instance has not provided any terms of service.",
); );
return context.json({ return context.json({
updated_at: lastModified.toISOString(), updated_at: new Date(
config.instance.tos_path?.file.lastModified ?? 0,
).toISOString(),
content, content,
}); });
}), }),

View file

@ -4,8 +4,8 @@ import { Timeline } from "@versia/kit/db";
import { Notifications, RolePermissions } from "@versia/kit/tables"; import { Notifications, RolePermissions } from "@versia/kit/tables";
import { and, eq, gt, gte, inArray, lt, not, sql } from "drizzle-orm"; import { and, eq, gt, gte, inArray, lt, not, sql } from "drizzle-orm";
import { Account as AccountSchema } from "~/classes/schemas/account"; import { Account as AccountSchema } from "~/classes/schemas/account";
import { zBoolean } from "~/classes/schemas/common.ts";
import { Notification as NotificationSchema } from "~/classes/schemas/notification.ts"; import { Notification as NotificationSchema } from "~/classes/schemas/notification.ts";
import { zBoolean } from "~/packages/config-manager/config.type";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",

View file

@ -1,7 +1,7 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { Role } from "@versia/kit/db"; import { Role } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables"; import { RolePermissions } from "@versia/kit/tables";
import { config } from "~/packages/config-manager/index.ts"; import { config } from "~/config.ts";
import { fakeRequest, getTestUsers } from "~/tests/utils"; import { fakeRequest, getTestUsers } from "~/tests/utils";
const { users, deleteUsers, tokens } = await getTestUsers(1); const { users, deleteUsers, tokens } = await getTestUsers(1);

View file

@ -11,13 +11,13 @@ import { Media } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables"; import { RolePermissions } from "@versia/kit/tables";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { Attachment as AttachmentSchema } from "~/classes/schemas/attachment"; import { Attachment as AttachmentSchema } from "~/classes/schemas/attachment";
import { zBoolean } from "~/classes/schemas/common.ts";
import { PollOption } from "~/classes/schemas/poll"; import { PollOption } from "~/classes/schemas/poll";
import { import {
Status as StatusSchema, Status as StatusSchema,
StatusSource as StatusSourceSchema, StatusSource as StatusSourceSchema,
} from "~/classes/schemas/status"; } from "~/classes/schemas/status";
import { zBoolean } from "~/packages/config-manager/config.type"; import { config } from "~/config.ts";
import { config } from "~/packages/config-manager/index.ts";
const schema = z const schema = z
.object({ .object({
@ -35,7 +35,7 @@ const schema = z
}), }),
media_ids: z media_ids: z
.array(AttachmentSchema.shape.id) .array(AttachmentSchema.shape.id)
.max(config.validation.max_media_attachments) .max(config.validation.notes.max_attachments)
.default([]) .default([])
.openapi({ .openapi({
description: description:
@ -51,7 +51,7 @@ const schema = z
language: StatusSchema.shape.language.optional(), language: StatusSchema.shape.language.optional(),
"poll[options]": z "poll[options]": z
.array(PollOption.shape.title) .array(PollOption.shape.title)
.max(config.validation.max_poll_options) .max(config.validation.polls.max_options)
.optional() .optional()
.openapi({ .openapi({
description: description:
@ -60,8 +60,8 @@ const schema = z
"poll[expires_in]": z.coerce "poll[expires_in]": z.coerce
.number() .number()
.int() .int()
.min(config.validation.min_poll_duration) .min(config.validation.polls.min_duration_seconds)
.max(config.validation.max_poll_duration) .max(config.validation.polls.max_duration_seconds)
.optional() .optional()
.openapi({ .openapi({
description: description:

View file

@ -3,7 +3,7 @@ import type { Status as ApiStatus } from "@versia/client/types";
import { Media, db } from "@versia/kit/db"; import { Media, db } from "@versia/kit/db";
import { Emojis } from "@versia/kit/tables"; import { Emojis } from "@versia/kit/tables";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { config } from "~/packages/config-manager/index.ts"; import { config } from "~/config.ts";
import { fakeRequest, getTestUsers } from "~/tests/utils"; import { fakeRequest, getTestUsers } from "~/tests/utils";
const { users, tokens, deleteUsers } = await getTestUsers(5); const { users, tokens, deleteUsers } = await getTestUsers(5);
@ -61,7 +61,7 @@ describe("/api/v1/statuses", () => {
Authorization: `Bearer ${tokens[0].data.accessToken}`, Authorization: `Bearer ${tokens[0].data.accessToken}`,
}, },
body: new URLSearchParams({ body: new URLSearchParams({
status: "a".repeat(config.validation.max_note_size + 1), status: "a".repeat(config.validation.notes.max_characters + 1),
local_only: "true", local_only: "true",
}), }),
}); });

View file

@ -4,13 +4,13 @@ import { Media, Note } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables"; import { RolePermissions } from "@versia/kit/tables";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { Attachment as AttachmentSchema } from "~/classes/schemas/attachment"; import { Attachment as AttachmentSchema } from "~/classes/schemas/attachment";
import { zBoolean } from "~/classes/schemas/common.ts";
import { PollOption } from "~/classes/schemas/poll"; import { PollOption } from "~/classes/schemas/poll";
import { import {
Status as StatusSchema, Status as StatusSchema,
StatusSource as StatusSourceSchema, StatusSource as StatusSourceSchema,
} from "~/classes/schemas/status"; } from "~/classes/schemas/status";
import { zBoolean } from "~/packages/config-manager/config.type"; import { config } from "~/config.ts";
import { config } from "~/packages/config-manager/index.ts";
const schema = z const schema = z
.object({ .object({
@ -28,7 +28,7 @@ const schema = z
}), }),
media_ids: z media_ids: z
.array(AttachmentSchema.shape.id) .array(AttachmentSchema.shape.id)
.max(config.validation.max_media_attachments) .max(config.validation.notes.max_attachments)
.default([]) .default([])
.openapi({ .openapi({
description: description:
@ -44,7 +44,7 @@ const schema = z
language: StatusSchema.shape.language.optional(), language: StatusSchema.shape.language.optional(),
"poll[options]": z "poll[options]": z
.array(PollOption.shape.title) .array(PollOption.shape.title)
.max(config.validation.max_poll_options) .max(config.validation.polls.max_options)
.optional() .optional()
.openapi({ .openapi({
description: description:
@ -53,8 +53,8 @@ const schema = z
"poll[expires_in]": z.coerce "poll[expires_in]": z.coerce
.number() .number()
.int() .int()
.min(config.validation.min_poll_duration) .min(config.validation.polls.min_duration_seconds)
.max(config.validation.max_poll_duration) .max(config.validation.polls.max_duration_seconds)
.optional() .optional()
.openapi({ .openapi({
description: description:

View file

@ -1,6 +1,6 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, test } from "bun:test";
import type { Status as ApiStatus } from "@versia/client/types"; import type { Status as ApiStatus } from "@versia/client/types";
import { config } from "~/packages/config-manager/index.ts"; import { config } from "~/config.ts";
import { fakeRequest, getTestStatuses, getTestUsers } from "~/tests/utils"; import { fakeRequest, getTestStatuses, getTestUsers } from "~/tests/utils";
const { users, tokens, deleteUsers } = await getTestUsers(5); const { users, tokens, deleteUsers } = await getTestUsers(5);

View file

@ -1,6 +1,6 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, test } from "bun:test";
import type { Status as ApiStatus } from "@versia/client/types"; import type { Status as ApiStatus } from "@versia/client/types";
import { config } from "~/packages/config-manager/index.ts"; import { config } from "~/config.ts";
import { fakeRequest, getTestStatuses, getTestUsers } from "~/tests/utils"; import { fakeRequest, getTestStatuses, getTestUsers } from "~/tests/utils";
const { users, tokens, deleteUsers } = await getTestUsers(5); const { users, tokens, deleteUsers } = await getTestUsers(5);

View file

@ -3,8 +3,8 @@ import { createRoute, z } from "@hono/zod-openapi";
import { Timeline } from "@versia/kit/db"; import { Timeline } from "@versia/kit/db";
import { Notes, RolePermissions } from "@versia/kit/tables"; import { Notes, RolePermissions } from "@versia/kit/tables";
import { and, eq, gt, gte, inArray, lt, or, sql } from "drizzle-orm"; import { and, eq, gt, gte, inArray, lt, or, sql } from "drizzle-orm";
import { zBoolean } from "~/classes/schemas/common.ts";
import { Status as StatusSchema } from "~/classes/schemas/status"; import { Status as StatusSchema } from "~/classes/schemas/status";
import { zBoolean } from "~/packages/config-manager/config.type";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",

View file

@ -4,11 +4,11 @@ import { db } from "@versia/kit/db";
import { FilterKeywords, Filters, RolePermissions } from "@versia/kit/tables"; import { FilterKeywords, Filters, RolePermissions } from "@versia/kit/tables";
import { type SQL, and, eq, inArray } from "drizzle-orm"; import { type SQL, and, eq, inArray } from "drizzle-orm";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { zBoolean } from "~/classes/schemas/common.ts";
import { import {
FilterKeyword as FilterKeywordSchema, FilterKeyword as FilterKeywordSchema,
Filter as FilterSchema, Filter as FilterSchema,
} from "~/classes/schemas/filters"; } from "~/classes/schemas/filters";
import { zBoolean } from "~/packages/config-manager/config.type";
import { ErrorSchema } from "~/types/api"; import { ErrorSchema } from "~/types/api";
const routeGet = createRoute({ const routeGet = createRoute({

View file

@ -5,8 +5,8 @@ import { User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables"; import { Users } from "@versia/kit/tables";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import { Instance as InstanceSchema } from "~/classes/schemas/instance"; import { Instance as InstanceSchema } from "~/classes/schemas/instance";
import { config } from "~/config.ts";
import pkg from "~/package.json"; import pkg from "~/package.json";
import { config } from "~/packages/config-manager";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
@ -69,92 +69,99 @@ export default apiRoute((app) =>
mastodon: 1, mastodon: 1,
}, },
thumbnail: { thumbnail: {
url: config.instance.logo url: config.instance.branding.logo
? proxyUrl(config.instance.logo).toString() ? proxyUrl(config.instance.branding.logo).toString()
: pkg.icon, : pkg.icon,
}, },
banner: { banner: {
url: config.instance.banner url: config.instance.branding.banner
? proxyUrl(config.instance.banner).toString() ? proxyUrl(config.instance.branding.banner).toString()
: null, : null,
}, },
icon: [], icon: [],
languages: ["en"], languages: config.instance.languages,
configuration: { configuration: {
urls: { urls: {
// TODO: Implement Streaming API // TODO: Implement Streaming API
streaming: "", streaming: "",
}, },
vapid: { vapid: {
// TODO: Fill in vapid values public_key:
public_key: "", config.notifications.push?.vapid_keys.public ?? "",
}, },
accounts: { accounts: {
max_featured_tags: 100, max_featured_tags: 100,
max_displayname_characters: max_displayname_characters:
config.validation.max_displayname_size, config.validation.accounts.max_displayname_characters,
avatar_limit: config.validation.max_avatar_size, avatar_limit: config.validation.accounts.max_avatar_bytes,
header_limit: config.validation.max_header_size, header_limit: config.validation.accounts.max_header_bytes,
max_username_characters: max_username_characters:
config.validation.max_username_size, config.validation.accounts.max_username_characters,
max_note_characters: config.validation.max_bio_size, max_note_characters:
max_pinned_statuses: 100, config.validation.accounts.max_bio_characters,
max_pinned_statuses:
config.validation.accounts.max_pinned_notes,
fields: { fields: {
max_fields: config.validation.max_field_count, max_fields: config.validation.accounts.max_field_count,
max_name_characters: max_name_characters:
config.validation.max_field_name_size, config.validation.accounts
.max_field_name_characters,
max_value_characters: max_value_characters:
config.validation.max_field_value_size, config.validation.accounts
.max_field_value_characters,
}, },
}, },
statuses: { statuses: {
max_characters: config.validation.max_note_size, max_characters: config.validation.notes.max_characters,
max_media_attachments: max_media_attachments:
config.validation.max_media_attachments, config.validation.notes.max_attachments,
characters_reserved_per_url: 0, // TODO: Implement
characters_reserved_per_url: 13,
}, },
media_attachments: { media_attachments: {
supported_mime_types: config.validation.allowed_mime_types, supported_mime_types:
image_size_limit: config.validation.max_media_size, config.validation.media.allowed_mime_types,
image_matrix_limit: config.validation.max_media_size, image_size_limit: config.validation.media.max_bytes,
video_size_limit: config.validation.max_media_size, image_matrix_limit: 1 ** 10,
video_frame_rate_limit: config.validation.max_media_size, video_size_limit: 1 ** 10,
video_matrix_limit: config.validation.max_media_size, video_frame_rate_limit: 60,
video_matrix_limit: 1 ** 10,
description_limit: description_limit:
config.validation.max_media_description_size, config.validation.media.max_description_characters,
}, },
emojis: { emojis: {
emoji_size_limit: config.validation.max_emoji_size, emoji_size_limit: config.validation.emojis.max_bytes,
max_shortcode_characters: max_shortcode_characters:
config.validation.max_emoji_shortcode_size, config.validation.emojis.max_shortcode_characters,
max_description_characters: max_description_characters:
config.validation.max_emoji_description_size, config.validation.emojis.max_description_characters,
}, },
polls: { polls: {
max_characters_per_option: max_characters_per_option:
config.validation.max_poll_option_size, config.validation.polls.max_option_characters,
max_expiration: config.validation.max_poll_duration, max_expiration:
max_options: config.validation.max_poll_options, config.validation.polls.max_duration_seconds,
min_expiration: config.validation.min_poll_duration, max_options: config.validation.polls.max_options,
min_expiration:
config.validation.polls.min_duration_seconds,
}, },
translation: { translation: {
enabled: false, enabled: false,
}, },
}, },
registrations: { registrations: {
enabled: config.signups.registration, enabled: config.registration.allow,
approval_required: false, approval_required: config.registration.require_approval,
message: null, message: config.registration.message ?? null,
}, },
contact: { contact: {
// TODO: Add contact email email: config.instance.contact.email,
email: "",
account: (contactAccount as User)?.toApi(), account: (contactAccount as User)?.toApi(),
}, },
rules: config.signups.rules.map((rule, index) => ({ rules: config.instance.rules.map((r, index) => ({
id: String(index), id: String(index),
text: rule, text: r.text,
hint: "", hint: r.hint,
})), })),
sso: { sso: {
forced: oidcConfig?.forced ?? false, forced: oidcConfig?.forced ?? false,

View file

@ -12,10 +12,10 @@ import { and, eq, inArray, isNull, sql } from "drizzle-orm";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { Account as AccountSchema } from "~/classes/schemas/account"; import { Account as AccountSchema } from "~/classes/schemas/account";
import { Id } from "~/classes/schemas/common"; import { Id } from "~/classes/schemas/common";
import { zBoolean } from "~/classes/schemas/common.ts";
import { Search as SearchSchema } from "~/classes/schemas/search"; import { Search as SearchSchema } from "~/classes/schemas/search";
import { searchManager } from "~/classes/search/search-manager"; import { searchManager } from "~/classes/search/search-manager";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
import { zBoolean } from "~/packages/config-manager/config.type";
import { ErrorSchema } from "~/types/api"; import { ErrorSchema } from "~/types/api";
const route = createRoute({ const route = createRoute({
@ -133,7 +133,7 @@ export default apiRoute((app) =>
); );
} }
if (!config.sonic.enabled) { if (!config.search.enabled) {
throw new ApiError(501, "Search is not enabled on this server"); throw new ApiError(501, "Search is not enabled on this server");
} }

View file

@ -3,7 +3,7 @@ import { createRoute, z } from "@hono/zod-openapi";
import { proxy } from "hono/proxy"; import { proxy } from "hono/proxy";
import type { ContentfulStatusCode, StatusCode } from "hono/utils/http-status"; import type { ContentfulStatusCode, StatusCode } from "hono/utils/http-status";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
import { ErrorSchema } from "~/types/api"; import { ErrorSchema } from "~/types/api";
const schemas = { const schemas = {
@ -56,7 +56,7 @@ export default apiRoute((app) =>
const media = await proxy(id, { const media = await proxy(id, {
// @ts-expect-error Proxy is a Bun-specific feature // @ts-expect-error Proxy is a Bun-specific feature
proxy: config.http.proxy.address, proxy: config.http.proxy_address,
}); });
// Check if file extension ends in svg or svg // Check if file extension ends in svg or svg

View file

@ -8,7 +8,7 @@ import { Like, Note, User } from "@versia/kit/db";
import { Likes, Notes } from "@versia/kit/tables"; import { Likes, Notes } from "@versia/kit/tables";
import { and, eq, inArray, sql } from "drizzle-orm"; import { and, eq, inArray, sql } from "drizzle-orm";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
import { ErrorSchema, type KnownEntity } from "~/types/api"; import { ErrorSchema, type KnownEntity } from "~/types/api";
const route = createRoute({ const route = createRoute({

View file

@ -8,7 +8,7 @@ import { Note, User, db } from "@versia/kit/db";
import { Notes } from "@versia/kit/tables"; import { Notes } from "@versia/kit/tables";
import { and, eq, inArray } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
import { ErrorSchema } from "~/types/api"; import { ErrorSchema } from "~/types/api";
const schemas = { const schemas = {

View file

@ -1,6 +1,6 @@
import { apiRoute } from "@/api"; import { apiRoute } from "@/api";
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",

View file

@ -1,8 +1,8 @@
import { apiRoute } from "@/api"; import { apiRoute } from "@/api";
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { Note, User } from "@versia/kit/db"; import { Note, User } from "@versia/kit/db";
import { config } from "~/config.ts";
import manifest from "~/package.json"; import manifest from "~/package.json";
import { config } from "~/packages/config-manager";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
@ -65,7 +65,7 @@ export default apiRoute((app) =>
}, },
localPosts: noteCount, localPosts: noteCount,
}, },
openRegistrations: config.signups.registration, openRegistrations: config.registration.allow,
metadata: { metadata: {
nodeName: config.instance.name, nodeName: config.instance.name,
nodeDescription: config.instance.description, nodeDescription: config.instance.description,

View file

@ -1,6 +1,6 @@
import { apiRoute } from "@/api"; import { apiRoute } from "@/api";
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",

View file

@ -1,6 +1,6 @@
import { apiRoute } from "@/api"; import { apiRoute } from "@/api";
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",

View file

@ -5,8 +5,8 @@ import { InstanceMetadata as InstanceMetadataSchema } from "@versia/federation/s
import { User } from "@versia/kit/db"; import { User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables"; import { Users } from "@versia/kit/tables";
import { asc } from "drizzle-orm"; import { asc } from "drizzle-orm";
import { config } from "~/config.ts";
import pkg from "~/package.json"; import pkg from "~/package.json";
import { config } from "~/packages/config-manager";
const route = createRoute({ const route = createRoute({
method: "get", method: "get",
@ -29,6 +29,10 @@ export default apiRoute((app) =>
// Get date of first user creation // Get date of first user creation
const firstUser = await User.fromSql(undefined, asc(Users.createdAt)); const firstUser = await User.fromSql(undefined, asc(Users.createdAt));
const publicKey = Buffer.from(
await crypto.subtle.exportKey("spki", config.instance.keys.public),
).toString("base64");
return context.json( return context.json(
{ {
type: "InstanceMetadata" as const, type: "InstanceMetadata" as const,
@ -43,18 +47,18 @@ export default apiRoute((app) =>
name: config.instance.name, name: config.instance.name,
description: config.instance.description, description: config.instance.description,
public_key: { public_key: {
key: config.instance.keys.public, key: publicKey,
algorithm: "ed25519" as const, algorithm: "ed25519" as const,
}, },
software: { software: {
name: "Versia Server", name: "Versia Server",
version: pkg.version, version: pkg.version,
}, },
banner: config.instance.banner banner: config.instance.branding.banner
? urlToContentFormat(config.instance.banner) ? urlToContentFormat(config.instance.branding.banner)
: undefined, : undefined,
logo: config.instance.logo logo: config.instance.branding.logo
? urlToContentFormat(config.instance.logo) ? urlToContentFormat(config.instance.branding.logo)
: undefined, : undefined,
shared_inbox: new URL( shared_inbox: new URL(
"/inbox", "/inbox",

View file

@ -12,7 +12,7 @@ import { User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables"; import { Users } from "@versia/kit/tables";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
import { ErrorSchema } from "~/types/api"; import { ErrorSchema } from "~/types/api";
const schemas = { const schemas = {
@ -90,7 +90,7 @@ export default apiRoute((app) =>
let activityPubUrl = ""; let activityPubUrl = "";
if (config.federation.bridge.enabled) { if (config.federation.bridge) {
const manager = await User.getFederationRequester(); const manager = await User.getFederationRequester();
try { try {
@ -98,7 +98,7 @@ export default apiRoute((app) =>
user.data.username, user.data.username,
config.http.base_url.host, config.http.base_url.host,
"application/activity+json", "application/activity+json",
config.federation.bridge.url?.toString(), config.federation.bridge.url.origin,
); );
} catch (e) { } catch (e) {
const error = e as ResponseError; const error = e as ResponseError;
@ -136,7 +136,7 @@ export default apiRoute((app) =>
type: type:
user.avatar?.getPreferredMimeType() ?? user.avatar?.getPreferredMimeType() ??
"image/svg+xml", "image/svg+xml",
href: user.getAvatarUrl(config), href: user.getAvatarUrl(),
}, },
].filter(Boolean) as { ].filter(Boolean) as {
rel: string; rel: string;

16
app.ts
View file

@ -12,12 +12,11 @@ import { cors } from "hono/cors";
import { createMiddleware } from "hono/factory"; import { createMiddleware } from "hono/factory";
import { prettyJSON } from "hono/pretty-json"; import { prettyJSON } from "hono/pretty-json";
import { secureHeaders } from "hono/secure-headers"; import { secureHeaders } from "hono/secure-headers";
import { config } from "~/config.ts";
import pkg from "~/package.json" with { type: "application/json" }; import pkg from "~/package.json" with { type: "application/json" };
import { config } from "~/packages/config-manager/index.ts";
import { ApiError } from "./classes/errors/api-error.ts"; import { ApiError } from "./classes/errors/api-error.ts";
import { PluginLoader } from "./classes/plugin/loader.ts"; import { PluginLoader } from "./classes/plugin/loader.ts";
import { agentBans } from "./middlewares/agent-bans.ts"; import { agentBans } from "./middlewares/agent-bans.ts";
import { bait } from "./middlewares/bait.ts";
import { boundaryCheck } from "./middlewares/boundary-check.ts"; import { boundaryCheck } from "./middlewares/boundary-check.ts";
import { ipBans } from "./middlewares/ip-bans.ts"; import { ipBans } from "./middlewares/ip-bans.ts";
import { logger } from "./middlewares/logger.ts"; import { logger } from "./middlewares/logger.ts";
@ -33,21 +32,8 @@ export const appFactory = async (): Promise<OpenAPIHono<HonoEnv>> => {
defaultHook: handleZodError, defaultHook: handleZodError,
}); });
/* const { printMetrics, registerMetrics } = prometheus({
collectDefaultMetrics: true,
metricOptions: {
requestsTotal: {
customLabels: {
content_type: (c) =>
c.res.headers.get("content-type") ?? "unknown",
},
},
},
}); */
app.use(ipBans); app.use(ipBans);
app.use(agentBans); app.use(agentBans);
app.use(bait);
app.use(logger); app.use(logger);
app.use(boundaryCheck); app.use(boundaryCheck);
app.use( app.use(

740
classes/config/schema.ts Normal file
View file

@ -0,0 +1,740 @@
import { z } from "@hono/zod-openapi";
import {
ADMIN_ROLES,
DEFAULT_ROLES,
RolePermissions,
} from "@versia/kit/tables";
import { type BunFile, file } from "bun";
import { types as mimeTypes } from "mime-types";
import { generateVAPIDKeys } from "web-push";
import { ZodError } from "zod";
import { fromZodError } from "zod-validation-error";
import { iso631 } from "../schemas/common.ts";
export enum MediaBackendType {
Local = "local",
S3 = "s3",
}
const urlPath = z
.string()
.trim()
.min(1)
// Remove trailing slashes, but keep the root slash
.transform((arg) => (arg === "/" ? arg : arg.replace(/\/$/, "")));
const url = z
.string()
.trim()
.min(1)
.refine((arg) => URL.canParse(arg), "Invalid url")
.transform((arg) => new URL(arg));
const unixPort = z
.number()
.int()
.min(1)
.max(2 ** 16 - 1);
const fileFromPathString = (text: string): BunFile => file(text.slice(5));
// Not using .ip() because we allow CIDR ranges and wildcards and such
const ip = z
.string()
.describe("An IPv6/v4 address or CIDR range. Wildcards are also allowed");
const regex = z
.string()
.transform((arg) => new RegExp(arg))
.describe("JavaScript regular expression");
export const sensitiveString = z
.string()
.refine(
(text) =>
text.startsWith("PATH:") ? fileFromPathString(text).exists() : true,
(text) => ({
message: `Path ${fileFromPathString(text).name} does not exist, is a directory or is not accessible`,
}),
)
.transform((text) =>
text.startsWith("PATH:") ? fileFromPathString(text).text() : text,
)
.describe("You can use PATH:/path/to/file to load this value from a file");
export const filePathString = z
.string()
.transform((s) => file(s))
.refine(
(file) => file.exists(),
(file) => ({
message: `Path ${file.name} does not exist, is a directory or is not accessible`,
}),
)
.transform(async (file) => ({
content: await file.text(),
file,
}))
.describe("This value must be a file path");
export const keyPair = z
.strictObject({
public: sensitiveString,
private: sensitiveString,
})
.optional()
.transform(async (k, ctx) => {
if (!k) {
const keys = await crypto.subtle.generateKey("Ed25519", true, [
"sign",
"verify",
]);
const privateKey = Buffer.from(
await crypto.subtle.exportKey("pkcs8", keys.privateKey),
).toString("base64");
const publicKey = Buffer.from(
await crypto.subtle.exportKey("spki", keys.publicKey),
).toString("base64");
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Public and private keys are not set. Here are generated keys for you to copy.\n\nPublic: ${publicKey}\nPrivate: ${privateKey}`,
});
return z.NEVER;
}
let publicKey: CryptoKey;
let privateKey: CryptoKey;
try {
publicKey = await crypto.subtle.importKey(
"spki",
Buffer.from(k.public, "base64"),
"Ed25519",
true,
["verify"],
);
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Public key is invalid",
});
return z.NEVER;
}
try {
privateKey = await crypto.subtle.importKey(
"pkcs8",
Buffer.from(k.private, "base64"),
"Ed25519",
true,
["sign"],
);
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Private key is invalid",
});
return z.NEVER;
}
return {
public: publicKey,
private: privateKey,
};
});
export const vapidKeyPair = z
.strictObject({
public: sensitiveString,
private: sensitiveString,
})
.optional()
.transform((k, ctx) => {
if (!k) {
const keys = generateVAPIDKeys();
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `VAPID keys are not set. Here are generated keys for you to copy.\n\nPublic: ${keys.publicKey}\nPrivate: ${keys.privateKey}`,
});
return z.NEVER;
}
return k;
});
export const hmacKey = sensitiveString.transform(async (text, ctx) => {
if (!text) {
const key = await crypto.subtle.generateKey(
{
name: "HMAC",
hash: "SHA-256",
},
true,
["sign"],
);
const exported = await crypto.subtle.exportKey("raw", key);
const base64 = Buffer.from(exported).toString("base64");
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `HMAC key is not set. Here is a generated key for you to copy: ${base64}`,
});
return z.NEVER;
}
try {
await crypto.subtle.importKey(
"raw",
Buffer.from(text, "base64"),
{
name: "HMAC",
hash: "SHA-256",
},
true,
["sign"],
);
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "HMAC key is invalid",
});
return z.NEVER;
}
return text;
});
try {
console.info();
} catch (e) {
if (e instanceof ZodError) {
throw fromZodError(e);
}
throw e;
}
export const ConfigSchema = z
.strictObject({
postgres: z
.strictObject({
host: z.string().min(1).default("localhost"),
port: unixPort.default(5432),
username: z.string().min(1),
password: sensitiveString.default(""),
database: z.string().min(1).default("versia"),
replicas: z
.array(
z.strictObject({
host: z.string().min(1),
port: unixPort.default(5432),
username: z.string().min(1),
password: sensitiveString.default(""),
database: z.string().min(1).default("versia"),
}),
)
.describe("Additional read-only replicas")
.default([]),
})
.describe("PostgreSQL database configuration"),
redis: z
.strictObject({
queue: z
.strictObject({
host: z.string().min(1).default("localhost"),
port: unixPort.default(6379),
password: sensitiveString.default(""),
database: z.number().int().default(0),
})
.describe("A Redis database used for managing queues."),
cache: z
.strictObject({
host: z.string().min(1).default("localhost"),
port: unixPort.default(6379),
password: sensitiveString.default(""),
database: z.number().int().default(1),
})
.optional()
.describe(
"A Redis database used for caching SQL queries. Optional.",
),
})
.describe("Redis configuration. Used for queues and caching."),
search: z
.strictObject({
enabled: z
.boolean()
.default(false)
.describe("Enable indexing and searching?"),
sonic: z
.strictObject({
host: z.string().min(1).default("localhost"),
port: unixPort.default(7700),
password: sensitiveString,
})
.describe("Sonic database configuration")
.optional(),
})
.refine(
(o) => !o.enabled || o.sonic,
"When search is enabled, Sonic configuration must be set",
)
.describe("Search and indexing configuration"),
registration: z.strictObject({
allow: z
.boolean()
.default(true)
.describe("Can users sign up freely?"),
require_approval: z.boolean().default(false),
message: z
.string()
.optional()
.describe(
"Message to show to users when registration is disabled",
),
}),
http: z.strictObject({
base_url: url.describe(
"URL that the instance will be accessible at",
),
bind: z.string().min(1).default("0.0.0.0"),
bind_port: unixPort.default(8080),
banned_ips: z.array(ip).default([]),
banned_user_agents: z.array(regex).default([]),
proxy_address: url
.optional()
.describe("URL to an eventual HTTP proxy")
.refine(async (url) => {
if (!url) {
return true;
}
// Test the proxy
const response = await fetch(
"https://api.ipify.org?format=json",
{
// @ts-expect-error Proxy is a Bun-specific feature
proxy: url.origin,
},
);
return response.ok;
}, "The HTTP proxy address is not reachable"),
tls: z
.strictObject({
key: filePathString,
cert: filePathString,
passphrase: sensitiveString.optional(),
ca: filePathString.optional(),
})
.describe(
"TLS configuration. You should probably be using a reverse proxy instead of this",
)
.optional(),
}),
frontend: z.strictObject({
enabled: z.boolean().default(true),
url: url.default("http://localhost:3000"),
routes: z.strictObject({
home: urlPath.default("/"),
login: urlPath.default("/oauth/authorize"),
consent: urlPath.default("/oauth/consent"),
register: urlPath.default("/register"),
password_reset: urlPath.default("/oauth/reset"),
}),
settings: z.record(z.string(), z.any()).default({}),
}),
email: z
.strictObject({
send_emails: z.boolean().default(false),
smtp: z
.strictObject({
server: z.string().min(1),
port: unixPort.default(465),
username: z.string().min(1),
password: sensitiveString.optional(),
tls: z.boolean().default(true),
})
.optional(),
})
.refine(
(o) => o.send_emails || !o.smtp,
"When send_emails is enabled, SMTP configuration must be set",
),
media: z.strictObject({
backend: z
.nativeEnum(MediaBackendType)
.default(MediaBackendType.Local),
uploads_path: z.string().min(1).default("uploads"),
conversion: z.strictObject({
convert_images: z.boolean().default(false),
convert_to: z.string().default("image/webp"),
convert_vectors: z.boolean().default(false),
}),
}),
s3: z
.strictObject({
endpoint: url,
access_key: sensitiveString,
secret_access_key: sensitiveString,
region: z.string().optional(),
bucket_name: z.string().optional(),
public_url: url.describe(
"Public URL that uploaded media will be accessible at",
),
})
.optional(),
validation: z.strictObject({
accounts: z.strictObject({
max_displayname_characters: z
.number()
.int()
.nonnegative()
.default(50),
max_username_characters: z
.number()
.int()
.nonnegative()
.default(30),
max_bio_characters: z
.number()
.int()
.nonnegative()
.default(5000),
max_avatar_bytes: z
.number()
.int()
.nonnegative()
.default(5_000_000),
max_header_bytes: z
.number()
.int()
.nonnegative()
.default(5_000_000),
disallowed_usernames: z
.array(regex)
.default([
"well-known",
"about",
"activities",
"api",
"auth",
"dev",
"inbox",
"internal",
"main",
"media",
"nodeinfo",
"notice",
"oauth",
"objects",
"proxy",
"push",
"registration",
"relay",
"settings",
"status",
"tag",
"users",
"web",
"search",
"mfa",
]),
max_field_count: z.number().int().default(10),
max_field_name_characters: z.number().int().default(1000),
max_field_value_characters: z.number().int().default(1000),
max_pinned_notes: z.number().int().default(20),
}),
notes: z.strictObject({
max_characters: z.number().int().nonnegative().default(5000),
allowed_url_schemes: z
.array(z.string())
.default([
"http",
"https",
"ftp",
"dat",
"dweb",
"gopher",
"hyper",
"ipfs",
"ipns",
"irc",
"xmpp",
"ircs",
"magnet",
"mailto",
"mumble",
"ssb",
"gemini",
]),
max_attachments: z.number().int().default(16),
}),
media: z.strictObject({
max_bytes: z.number().int().nonnegative().default(40_000_000),
max_description_characters: z
.number()
.int()
.nonnegative()
.default(1000),
allowed_mime_types: z
.array(z.string())
.default(Object.values(mimeTypes)),
}),
emojis: z.strictObject({
max_bytes: z.number().int().nonnegative().default(1_000_000),
max_shortcode_characters: z
.number()
.int()
.nonnegative()
.default(100),
max_description_characters: z
.number()
.int()
.nonnegative()
.default(1_000),
}),
polls: z.strictObject({
max_options: z.number().int().nonnegative().default(20),
max_option_characters: z
.number()
.int()
.nonnegative()
.default(500),
min_duration_seconds: z
.number()
.int()
.nonnegative()
.default(60),
max_duration_seconds: z
.number()
.int()
.nonnegative()
.default(100 * 24 * 60 * 60),
}),
emails: z.strictObject({
disallow_tempmail: z
.boolean()
.default(false)
.describe("Blocks over 10,000 common tempmail domains"),
disallowed_domains: z.array(regex).default([]),
}),
challenges: z
.strictObject({
difficulty: z.number().int().positive().default(50000),
expiration: z.number().int().positive().default(300),
key: hmacKey,
})
.optional()
.describe(
"CAPTCHA challenge configuration. Challenges are disabled if not provided.",
),
filters: z
.strictObject({
note_content: z.array(regex).default([]),
emoji_shortcode: z.array(regex).default([]),
username: z.array(regex).default([]),
displayname: z.array(regex).default([]),
bio: z.array(regex).default([]),
})
.describe(
"Block content that matches these regular expressions",
),
}),
notifications: z.strictObject({
push: z
.strictObject({
vapid_keys: vapidKeyPair,
subject: z
.string()
.optional()
.describe(
"Subject field embedded in the push notification. Example: 'mailto:contact@example.com'",
),
})
.describe(
"Web Push Notifications configuration. Leave out to disable.",
)
.optional(),
}),
defaults: z.strictObject({
visibility: z
.enum(["public", "unlisted", "private", "direct"])
.default("public"),
language: z.string().default("en"),
avatar: url.optional(),
header: url.optional(),
placeholder_style: z
.string()
.default("thumbs")
.describe("A style name from https://www.dicebear.com/styles"),
}),
federation: z.strictObject({
blocked: z.array(z.string()).default([]),
followers_only: z.array(z.string()).default([]),
discard: z.strictObject({
reports: z.array(z.string()).default([]),
deletes: z.array(z.string()).default([]),
updates: z.array(z.string()).default([]),
media: z.array(z.string()).default([]),
follows: z.array(z.string()).default([]),
likes: z.array(z.string()).default([]),
reactions: z.array(z.string()).default([]),
banners: z.array(z.string()).default([]),
avatars: z.array(z.string()).default([]),
}),
bridge: z
.strictObject({
software: z.enum(["versia-ap"]).or(z.string()),
allowed_ips: z.array(ip).default([]),
token: sensitiveString,
url,
})
.optional(),
}),
queues: z.record(
z.enum(["delivery", "inbox", "fetch", "push", "media"]),
z.strictObject({
remove_after_complete_seconds: z
.number()
.int()
.nonnegative()
// 1 year
.default(60 * 60 * 24 * 365),
remove_after_failure_seconds: z
.number()
.int()
.nonnegative()
// 1 year
.default(60 * 60 * 24 * 365),
}),
),
instance: z.strictObject({
name: z.string().min(1).default("Versia Server"),
description: z.string().min(1).default("A Versia instance"),
extended_description_path: filePathString.optional(),
tos_path: filePathString.optional(),
privacy_policy_path: filePathString.optional(),
branding: z.strictObject({
logo: url.optional(),
banner: url.optional(),
}),
languages: z
.array(iso631)
.describe("Primary instance languages. ISO 639-1 codes."),
contact: z.strictObject({
email: z
.string()
.email()
.describe("Email to contact the instance administration"),
}),
rules: z
.array(
z.strictObject({
text: z
.string()
.min(1)
.max(255)
.describe("Short description of the rule"),
hint: z
.string()
.min(1)
.max(4096)
.optional()
.describe(
"Longer version of the rule with additional information",
),
}),
)
.default([]),
keys: keyPair,
}),
permissions: z.strictObject({
anonymous: z
.array(z.nativeEnum(RolePermissions))
.default(DEFAULT_ROLES),
default: z
.array(z.nativeEnum(RolePermissions))
.default(DEFAULT_ROLES),
admin: z.array(z.nativeEnum(RolePermissions)).default(ADMIN_ROLES),
}),
logging: z.strictObject({
types: z.record(
z.enum([
"requests",
"responses",
"requests_content",
"filters",
]),
z
.boolean()
.default(false)
.or(
z.strictObject({
level: z
.enum([
"debug",
"info",
"warning",
"error",
"fatal",
])
.default("info"),
log_file_path: z.string().optional(),
}),
),
),
log_level: z
.enum(["debug", "info", "warning", "error", "fatal"])
.default("info"),
sentry: z
.strictObject({
dsn: url,
debug: z.boolean().default(false),
sample_rate: z.number().min(0).max(1.0).default(1.0),
traces_sample_rate: z.number().min(0).max(1.0).default(1.0),
trace_propagation_targets: z.array(z.string()).default([]),
max_breadcrumbs: z.number().default(100),
environment: z.string().optional(),
})
.optional(),
log_file_path: z.string().default("logs/versia.log"),
}),
debug: z
.strictObject({
federation: z.boolean().default(false),
})
.optional(),
plugins: z.strictObject({
autoload: z.boolean().default(true),
overrides: z
.strictObject({
enabled: z.array(z.string()).default([]),
disabled: z.array(z.string()).default([]),
})
.refine(
// Only one of enabled or disabled can be set
(arg) =>
arg.enabled.length === 0 || arg.disabled.length === 0,
"Only one of enabled or disabled can be set",
),
config: z.record(z.string(), z.any()).optional(),
}),
})
.refine(
// If media backend is S3, s3 config must be set
(arg) => arg.media.backend === MediaBackendType.Local || !!arg.s3,
"When media backend is S3, S3 configuration must be set",
);

View file

@ -1,6 +1,6 @@
import { zodToJsonSchema } from "zod-to-json-schema"; import { zodToJsonSchema } from "zod-to-json-schema";
import { configValidator } from "./config.type"; import { ConfigSchema } from "./schema.ts";
const jsonSchema = zodToJsonSchema(configValidator); const jsonSchema = zodToJsonSchema(ConfigSchema, {});
console.write(`${JSON.stringify(jsonSchema, null, 4)}\n`); console.write(`${JSON.stringify(jsonSchema, null, 4)}\n`);

View file

@ -12,7 +12,7 @@ import {
eq, eq,
inArray, inArray,
} from "drizzle-orm"; } from "drizzle-orm";
import { config } from "~/packages/config-manager/index.ts"; import { config } from "~/config.ts";
import { ApiError } from "../errors/api-error.ts"; import { ApiError } from "../errors/api-error.ts";
import { BaseInterface } from "./base.ts"; import { BaseInterface } from "./base.ts";
import { User } from "./user.ts"; import { User } from "./user.ts";
@ -147,7 +147,7 @@ export class Instance extends BaseInterface<typeof Instances> {
const { ok, raw, data } = await requester const { ok, raw, data } = await requester
.get(wellKnownUrl, { .get(wellKnownUrl, {
// @ts-expect-error Bun extension // @ts-expect-error Bun extension
proxy: config.http.proxy.address, proxy: config.http.proxy_address,
}) })
.catch((e) => ({ .catch((e) => ({
...(e as ResponseError).response, ...(e as ResponseError).response,
@ -204,7 +204,7 @@ export class Instance extends BaseInterface<typeof Instances> {
links: { rel: string; href: string }[]; links: { rel: string; href: string }[];
}>(wellKnownUrl, { }>(wellKnownUrl, {
// @ts-expect-error Bun extension // @ts-expect-error Bun extension
proxy: config.http.proxy.address, proxy: config.http.proxy_address,
}) })
.catch((e) => ({ .catch((e) => ({
...( ...(
@ -256,7 +256,7 @@ export class Instance extends BaseInterface<typeof Instances> {
software: { version: string }; software: { version: string };
}>(metadataUrl.href, { }>(metadataUrl.href, {
// @ts-expect-error Bun extension // @ts-expect-error Bun extension
proxy: config.http.proxy.address, proxy: config.http.proxy_address,
}) })
.catch((e) => ({ .catch((e) => ({
...( ...(

View file

@ -17,7 +17,7 @@ import {
eq, eq,
inArray, inArray,
} from "drizzle-orm"; } from "drizzle-orm";
import { config } from "~/packages/config-manager/index.ts"; import { config } from "~/config.ts";
import { BaseInterface } from "./base.ts"; import { BaseInterface } from "./base.ts";
import { Note } from "./note.ts"; import { Note } from "./note.ts";
import { User } from "./user.ts"; import { User } from "./user.ts";

View file

@ -15,9 +15,9 @@ import {
inArray, inArray,
} from "drizzle-orm"; } from "drizzle-orm";
import sharp from "sharp"; import sharp from "sharp";
import { MediaBackendType } from "~/classes/config/schema.ts";
import type { Attachment as AttachmentSchema } from "~/classes/schemas/attachment.ts"; import type { Attachment as AttachmentSchema } from "~/classes/schemas/attachment.ts";
import { MediaBackendType } from "~/packages/config-manager/config.type"; import { config } from "~/config.ts";
import { config } from "~/packages/config-manager/index.ts";
import { ApiError } from "../errors/api-error.ts"; import { ApiError } from "../errors/api-error.ts";
import { getMediaHash } from "../media/media-hasher.ts"; import { getMediaHash } from "../media/media-hasher.ts";
import { MediaJobType, mediaQueue } from "../queues/media.ts"; import { MediaJobType, mediaQueue } from "../queues/media.ts";
@ -135,11 +135,7 @@ export class Media extends BaseInterface<typeof Medias> {
switch (config.media.backend) { switch (config.media.backend) {
case MediaBackendType.Local: { case MediaBackendType.Local: {
const path = join( const path = join(config.media.uploads_path, hash, fileName);
config.media.local_uploads_folder,
hash,
fileName,
);
await write(path, file); await write(path, file);
@ -154,7 +150,7 @@ export class Media extends BaseInterface<typeof Medias> {
} }
const client = new S3Client({ const client = new S3Client({
endpoint: config.s3.endpoint, endpoint: config.s3.endpoint.origin,
region: config.s3.region, region: config.s3.region,
bucket: config.s3.bucket_name, bucket: config.s3.bucket_name,
accessKeyId: config.s3.access_key, accessKeyId: config.s3.access_key,
@ -260,21 +256,21 @@ export class Media extends BaseInterface<typeof Medias> {
} }
private static checkFile(file: File): void { private static checkFile(file: File): void {
if (file.size > config.validation.max_media_size) { if (file.size > config.validation.media.max_bytes) {
throw new ApiError( throw new ApiError(
413, 413,
`File too large, max size is ${config.validation.max_media_size} bytes`, `File too large, max size is ${config.validation.media.max_bytes} bytes`,
); );
} }
if ( if (
config.validation.enforce_mime_types && config.validation.media.allowed_mime_types.length > 0 &&
!config.validation.allowed_mime_types.includes(file.type) !config.validation.media.allowed_mime_types.includes(file.type)
) { ) {
throw new ApiError( throw new ApiError(
415, 415,
`File type ${file.type} is not allowed`, `File type ${file.type} is not allowed`,
`Allowed types: ${config.validation.allowed_mime_types.join(", ")}`, `Allowed types: ${config.validation.media.allowed_mime_types.join(", ")}`,
); );
} }
} }

View file

@ -38,7 +38,7 @@ import {
parseTextMentions, parseTextMentions,
} from "~/classes/functions/status"; } from "~/classes/functions/status";
import type { Status as StatusSchema } from "~/classes/schemas/status.ts"; import type { Status as StatusSchema } from "~/classes/schemas/status.ts";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
import { DeliveryJobType, deliveryQueue } from "../queues/delivery.ts"; import { DeliveryJobType, deliveryQueue } from "../queues/delivery.ts";
import type { Status } from "../schemas/status.ts"; import type { Status } from "../schemas/status.ts";
import { Application } from "./application.ts"; import { Application } from "./application.ts";
@ -594,7 +594,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
const { data } = await requester.get(uri, { const { data } = await requester.get(uri, {
// @ts-expect-error Bun extension // @ts-expect-error Bun extension
proxy: config.http.proxy.address, proxy: config.http.proxy_address,
}); });
const note = await new EntityValidator().Note(data); const note = await new EntityValidator().Note(data);

View file

@ -9,7 +9,7 @@ import {
eq, eq,
inArray, inArray,
} from "drizzle-orm"; } from "drizzle-orm";
import { config } from "~/packages/config-manager/index.ts"; import { config } from "~/config.ts";
import { BaseInterface } from "./base.ts"; import { BaseInterface } from "./base.ts";
type ReactionType = InferSelectModel<typeof Reactions> & { type ReactionType = InferSelectModel<typeof Reactions> & {

View file

@ -14,7 +14,7 @@ import {
eq, eq,
inArray, inArray,
} from "drizzle-orm"; } from "drizzle-orm";
import { config } from "~/packages/config-manager/index.ts"; import { config } from "~/config.ts";
import { BaseInterface } from "./base.ts"; import { BaseInterface } from "./base.ts";
type RoleType = InferSelectModel<typeof Roles>; type RoleType = InferSelectModel<typeof Roles>;

View file

@ -1,6 +1,6 @@
import { Notes, Notifications, Users } from "@versia/kit/tables"; import { Notes, Notifications, Users } from "@versia/kit/tables";
import { type SQL, gt } from "drizzle-orm"; import { type SQL, gt } from "drizzle-orm";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
import { Note } from "./note.ts"; import { Note } from "./note.ts";
import { Notification } from "./notification.ts"; import { Notification } from "./notification.ts";
import { User } from "./user.ts"; import { User } from "./user.ts";

View file

@ -47,7 +47,7 @@ import {
import { htmlToText } from "html-to-text"; import { htmlToText } from "html-to-text";
import { findManyUsers } from "~/classes/functions/user"; import { findManyUsers } from "~/classes/functions/user";
import { searchManager } from "~/classes/search/search-manager"; import { searchManager } from "~/classes/search/search-manager";
import { type Config, config } from "~/packages/config-manager"; import { config } from "~/config.ts";
import type { KnownEntity } from "~/types/api.ts"; import type { KnownEntity } from "~/types/api.ts";
import { DeliveryJobType, deliveryQueue } from "../queues/delivery.ts"; import { DeliveryJobType, deliveryQueue } from "../queues/delivery.ts";
import { PushJobType, pushQueue } from "../queues/push.ts"; import { PushJobType, pushQueue } from "../queues/push.ts";
@ -522,7 +522,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
}); });
// Also do push notifications // Also do push notifications
if (config.notifications.push.enabled) { if (config.notifications.push) {
await this.notifyPush(notification.id, type, relatedUser, note); await this.notifyPush(notification.id, type, relatedUser, note);
} }
} }
@ -603,7 +603,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
} }
if (instance.data.protocol === "activitypub") { if (instance.data.protocol === "activitypub") {
if (!config.federation.bridge.enabled) { if (!config.federation.bridge) {
throw new Error("ActivityPub bridge is not enabled"); throw new Error("ActivityPub bridge is not enabled");
} }
@ -627,7 +627,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
const requester = await User.getFederationRequester(); const requester = await User.getFederationRequester();
const output = await requester.get<Partial<VersiaUser>>(uri, { const output = await requester.get<Partial<VersiaUser>>(uri, {
// @ts-expect-error Bun extension // @ts-expect-error Bun extension
proxy: config.http.proxy.address, proxy: config.http.proxy_address,
}); });
const { data: json } = output; const { data: json } = output;
@ -815,10 +815,9 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
/** /**
* Get the user's avatar in raw URL format * Get the user's avatar in raw URL format
* @param config The config to use
* @returns The raw URL for the user's avatar * @returns The raw URL for the user's avatar
*/ */
public getAvatarUrl(config: Config): URL { public getAvatarUrl(): URL {
if (!this.avatar) { if (!this.avatar) {
return ( return (
config.defaults.avatar || config.defaults.avatar ||
@ -912,10 +911,9 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
/** /**
* Get the user's header in raw URL format * Get the user's header in raw URL format
* @param config The config to use
* @returns The raw URL for the user's header * @returns The raw URL for the user's header
*/ */
public getHeaderUrl(config: Config): URL | null { public getHeaderUrl(): URL | null {
if (!this.header) { if (!this.header) {
return config.defaults.header ?? null; return config.defaults.header ?? null;
} }
@ -996,7 +994,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
JSON.stringify(entity), JSON.stringify(entity),
); );
if (config.debug.federation) { if (config.debug?.federation) {
const logger = getLogger("federation"); const logger = getLogger("federation");
// Log public key // Log public key
@ -1014,8 +1012,8 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
* *
* @returns The requester * @returns The requester
*/ */
public static async getFederationRequester(): Promise<FederationRequester> { public static getFederationRequester(): FederationRequester {
const signatureConstructor = await SignatureConstructor.fromStringKey( const signatureConstructor = new SignatureConstructor(
config.instance.keys.private, config.instance.keys.private,
config.http.base_url, config.http.base_url,
); );
@ -1087,7 +1085,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
try { try {
await new FederationRequester().post(inbox, entity, { await new FederationRequester().post(inbox, entity, {
// @ts-expect-error Bun extension // @ts-expect-error Bun extension
proxy: config.http.proxy.address, proxy: config.http.proxy_address,
headers: { headers: {
...headers.toJSON(), ...headers.toJSON(),
"Content-Type": "application/json; charset=utf-8", "Content-Type": "application/json; charset=utf-8",
@ -1117,9 +1115,9 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
url: url:
user.uri || user.uri ||
new URL(`/@${user.username}`, config.http.base_url).toString(), new URL(`/@${user.username}`, config.http.base_url).toString(),
avatar: proxyUrl(this.getAvatarUrl(config)).toString(), avatar: proxyUrl(this.getAvatarUrl()).toString(),
header: this.getHeaderUrl(config) header: this.getHeaderUrl()
? proxyUrl(this.getHeaderUrl(config) as URL).toString() ? proxyUrl(this.getHeaderUrl() as URL).toString()
: "", : "",
locked: user.isLocked, locked: user.isLocked,
created_at: new Date(user.createdAt).toISOString(), created_at: new Date(user.createdAt).toISOString(),
@ -1135,9 +1133,9 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
bot: user.isBot, bot: user.isBot,
source: isOwnAccount ? user.source : undefined, source: isOwnAccount ? user.source : undefined,
// TODO: Add static avatar and header // TODO: Add static avatar and header
avatar_static: proxyUrl(this.getAvatarUrl(config)).toString(), avatar_static: proxyUrl(this.getAvatarUrl()).toString(),
header_static: this.getHeaderUrl(config) header_static: this.getHeaderUrl()
? proxyUrl(this.getHeaderUrl(config) as URL).toString() ? proxyUrl(this.getHeaderUrl() as URL).toString()
: "", : "",
acct: this.getAcct(), acct: this.getAcct(),
// TODO: Add these fields // TODO: Add these fields

View file

@ -18,7 +18,7 @@ import {
import MarkdownIt from "markdown-it"; import MarkdownIt from "markdown-it";
import markdownItContainer from "markdown-it-container"; import markdownItContainer from "markdown-it-container";
import markdownItTocDoneRight from "markdown-it-toc-done-right"; import markdownItTocDoneRight from "markdown-it-toc-done-right";
import { config } from "~/packages/config-manager/index.ts"; import { config } from "~/config.ts";
import { import {
transformOutputToUserWithRelations, transformOutputToUserWithRelations,
userExtrasTemplate, userExtrasTemplate,

View file

@ -9,8 +9,10 @@ import {
User, User,
} from "@versia/kit/db"; } from "@versia/kit/db";
import type { SocketAddress } from "bun"; import type { SocketAddress } from "bun";
import type { z } from "zod";
import { ValidationError } from "zod-validation-error"; import { ValidationError } from "zod-validation-error";
import { config } from "~/packages/config-manager/index.ts"; import { config } from "~/config.ts";
import type { ConfigSchema } from "../config/schema.ts";
import { InboxProcessor } from "./processor.ts"; import { InboxProcessor } from "./processor.ts";
// Mock dependencies // Mock dependencies
@ -58,7 +60,7 @@ mock.module("@versia/federation", () => ({
RequestParserHandler: jest.fn(), RequestParserHandler: jest.fn(),
})); }));
mock.module("~/packages/config-manager/index.ts", () => ({ mock.module("~/config.ts", () => ({
config: { config: {
debug: { debug: {
federation: false, federation: false,
@ -172,9 +174,13 @@ describe("InboxProcessor", () => {
}); });
test("returns false for valid bridge request", () => { test("returns false for valid bridge request", () => {
config.federation.bridge.enabled = true; config.federation.bridge = {
config.federation.bridge.token = "valid-token"; token: "valid-token",
config.federation.bridge.allowed_ips = ["127.0.0.1"]; allowed_ips: ["127.0.0.1"],
url: new URL("https://test.com"),
software: "versia-ap",
};
mockHeaders.authorization = "Bearer valid-token"; mockHeaders.authorization = "Bearer valid-token";
// biome-ignore lint/complexity/useLiteralKeys: Private method // biome-ignore lint/complexity/useLiteralKeys: Private method
@ -183,7 +189,9 @@ describe("InboxProcessor", () => {
}); });
test("returns error response for invalid token", () => { test("returns error response for invalid token", () => {
config.federation.bridge.enabled = true; config.federation.bridge = {} as z.infer<
typeof ConfigSchema
>["federation"]["bridge"];
mockHeaders.authorization = "Bearer invalid-token"; mockHeaders.authorization = "Bearer invalid-token";
// biome-ignore lint/complexity/useLiteralKeys: Private method // biome-ignore lint/complexity/useLiteralKeys: Private method

View file

@ -23,7 +23,7 @@ import { eq } from "drizzle-orm";
import type { StatusCode } from "hono/utils/http-status"; import type { StatusCode } from "hono/utils/http-status";
import { matches } from "ip-matching"; import { matches } from "ip-matching";
import { type ValidationError, isValidationError } from "zod-validation-error"; import { type ValidationError, isValidationError } from "zod-validation-error";
import { config } from "~/packages/config-manager/index.ts"; import { config } from "~/config.ts";
type ResponseBody = { type ResponseBody = {
message?: string; message?: string;
@ -98,7 +98,7 @@ export class InboxProcessor {
throw new Error("Sender is not defined"); throw new Error("Sender is not defined");
} }
if (config.debug.federation) { if (config.debug?.federation) {
this.logger.debug`Sender public key: ${chalk.gray( this.logger.debug`Sender public key: ${chalk.gray(
this.sender.key, this.sender.key,
)}`; )}`;
@ -134,7 +134,7 @@ export class InboxProcessor {
* @returns {boolean | ResponseBody} - Whether to skip signature checks. May include a response body if there are errors. * @returns {boolean | ResponseBody} - Whether to skip signature checks. May include a response body if there are errors.
*/ */
private shouldCheckSignature(): boolean | ResponseBody { private shouldCheckSignature(): boolean | ResponseBody {
if (config.federation.bridge.enabled) { if (config.federation.bridge) {
const token = this.headers.authorization?.split("Bearer ")[1]; const token = this.headers.authorization?.split("Bearer ")[1];
if (token) { if (token) {
@ -158,6 +158,14 @@ export class InboxProcessor {
* @returns * @returns
*/ */
private isRequestFromBridge(token: string): boolean | ResponseBody { private isRequestFromBridge(token: string): boolean | ResponseBody {
if (!config.federation.bridge) {
return {
message:
"Bridge is not configured. Please remove the Authorization header.",
code: 500,
};
}
if (token !== config.federation.bridge.token) { if (token !== config.federation.bridge.token) {
return { return {
message: message:

View file

@ -1,10 +1,10 @@
import { beforeEach, describe, expect, it, mock } from "bun:test"; import { beforeEach, describe, expect, it, mock } from "bun:test";
import sharp from "sharp"; import sharp from "sharp";
import type { Config } from "~/packages/config-manager/config.type"; import type { config } from "~/config.ts";
import { convertImage } from "./image-conversion.ts"; import { convertImage } from "./image-conversion.ts";
describe("ImageConversionPreprocessor", () => { describe("ImageConversionPreprocessor", () => {
let mockConfig: Config; let mockConfig: typeof config;
beforeEach(() => { beforeEach(() => {
mockConfig = { mockConfig = {
@ -15,9 +15,9 @@ describe("ImageConversionPreprocessor", () => {
convert_vector: false, convert_vector: false,
}, },
}, },
} as Config; } as unknown as typeof config;
mock.module("~/packages/config-manager/index.ts", () => ({ mock.module("~/config.ts", () => ({
config: mockConfig, config: mockConfig,
})); }));
}); });
@ -59,7 +59,7 @@ describe("ImageConversionPreprocessor", () => {
}); });
it("should convert SVG when convert_vector is true", async () => { it("should convert SVG when convert_vector is true", async () => {
mockConfig.media.conversion.convert_vector = true; mockConfig.media.conversion.convert_vectors = true;
const svgContent = const svgContent =
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100" fill="red"/></svg>'; '<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100" fill="red"/></svg>';

View file

@ -4,7 +4,7 @@
*/ */
import sharp from "sharp"; import sharp from "sharp";
import { config } from "~/packages/config-manager/index.ts"; import { config } from "~/config.ts";
/** /**
* Supported input media formats. * Supported input media formats.
@ -39,7 +39,7 @@ const supportedOutputFormats = [
const isConvertible = (file: File): boolean => { const isConvertible = (file: File): boolean => {
if ( if (
file.type === "image/svg+xml" && file.type === "image/svg+xml" &&
!config.media.conversion.convert_vector !config.media.conversion.convert_vectors
) { ) {
return false; return false;
} }

View file

@ -5,7 +5,7 @@ import chalk from "chalk";
import { parseJSON5, parseJSONC } from "confbox"; import { parseJSON5, parseJSONC } from "confbox";
import type { ZodTypeAny } from "zod"; import type { ZodTypeAny } from "zod";
import { type ValidationError, fromZodError } from "zod-validation-error"; import { type ValidationError, fromZodError } from "zod-validation-error";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
import { Plugin } from "~/packages/plugin-kit/plugin"; import { Plugin } from "~/packages/plugin-kit/plugin";
import { type Manifest, manifestSchema } from "~/packages/plugin-kit/schema"; import { type Manifest, manifestSchema } from "~/packages/plugin-kit/schema";
import type { HonoEnv } from "~/types/api"; import type { HonoEnv } from "~/types/api";
@ -230,10 +230,13 @@ export class PluginLoader {
config.plugins?.config?.[data.manifest.name], config.plugins?.config?.[data.manifest.name],
); );
} catch (e) { } catch (e) {
logger.fatal`Plugin configuration is invalid: ${chalk.redBright(e as ValidationError)}`; logger.fatal`Error encountered while loading plugin ${chalk.blueBright(data.manifest.name)} ${chalk.blueBright(data.manifest.version)} configuration.`;
logger.fatal`This is due to invalid, missing or incomplete configuration.`;
logger.fatal`Put your configuration at ${chalk.blueBright( logger.fatal`Put your configuration at ${chalk.blueBright(
"plugins.config.<plugin-name>", "plugins.config.<plugin-name>",
)}`; )}`;
logger.fatal`Here is the error message, please fix the configuration file accordingly:`;
logger.fatal`${(e as ValidationError).message}`;
await Bun.sleep(Number.POSITIVE_INFINITY); await Bun.sleep(Number.POSITIVE_INFINITY);
} }

View file

@ -1,8 +1,8 @@
import { userAddressValidator } from "@/api.ts"; import { userAddressValidator } from "@/api.ts";
import { z } from "@hono/zod-openapi"; import { z } from "@hono/zod-openapi";
import type { Account as ApiAccount } from "@versia/client/types"; import type { Account as ApiAccount } from "@versia/client/types";
import { config } from "~/packages/config-manager"; import { zBoolean } from "~/classes/schemas/common.ts";
import { zBoolean } from "~/packages/config-manager/config.type"; import { config } from "~/config.ts";
import { iso631 } from "./common.ts"; import { iso631 } from "./common.ts";
import { CustomEmoji } from "./emoji.ts"; import { CustomEmoji } from "./emoji.ts";
import { Role } from "./versia.ts"; import { Role } from "./versia.ts";
@ -12,7 +12,7 @@ export const Field = z.object({
.string() .string()
.trim() .trim()
.min(1) .min(1)
.max(config.validation.max_field_name_size) .max(config.validation.accounts.max_field_name_characters)
.openapi({ .openapi({
description: "The key of a given fields key-value pair.", description: "The key of a given fields key-value pair.",
example: "Freak level", example: "Freak level",
@ -24,7 +24,7 @@ export const Field = z.object({
.string() .string()
.trim() .trim()
.min(1) .min(1)
.max(config.validation.max_field_value_size) .max(config.validation.accounts.max_field_value_characters)
.openapi({ .openapi({
description: "The value associated with the name key.", description: "The value associated with the name key.",
example: "<p>High</p>", example: "<p>High</p>",
@ -87,9 +87,12 @@ export const Source = z
.string() .string()
.trim() .trim()
.min(0) .min(0)
.max(config.validation.max_bio_size) .max(config.validation.accounts.max_bio_characters)
.refine( .refine(
(s) => !config.filters.bio.some((filter) => s.match(filter)), (s) =>
!config.validation.filters.bio.some((filter) =>
filter.test(s),
),
"Bio contains blocked words", "Bio contains blocked words",
) )
.openapi({ .openapi({
@ -99,7 +102,10 @@ export const Source = z
url: "https://docs.joinmastodon.org/entities/Account/#source-note", url: "https://docs.joinmastodon.org/entities/Account/#source-note",
}, },
}), }),
fields: z.array(Field).max(config.validation.max_field_count).openapi({ fields: z
.array(Field)
.max(config.validation.accounts.max_field_count)
.openapi({
description: "Metadata about the account.", description: "Metadata about the account.",
}), }),
}) })
@ -126,15 +132,25 @@ export const Account = z.object({
.string() .string()
.min(3) .min(3)
.trim() .trim()
.max(config.validation.max_username_size) .max(config.validation.accounts.max_username_characters)
.regex( .regex(
/^[a-z0-9_-]+$/, /^[a-z0-9_-]+$/,
"Username can only contain letters, numbers, underscores and hyphens", "Username can only contain letters, numbers, underscores and hyphens",
) )
.refine( .refine(
(s) => !config.filters.username.some((filter) => s.match(filter)), (s) =>
!config.validation.filters.username.some((filter) =>
filter.test(s),
),
"Username contains blocked words", "Username contains blocked words",
) )
.refine(
(s) =>
!config.validation.accounts.disallowed_usernames.some((u) =>
u.test(s),
),
"Username is disallowed",
)
.openapi({ .openapi({
description: "The username of the account, not including domain.", description: "The username of the account, not including domain.",
example: "lexi", example: "lexi",
@ -169,10 +185,12 @@ export const Account = z.object({
.string() .string()
.min(3) .min(3)
.trim() .trim()
.max(config.validation.max_displayname_size) .max(config.validation.accounts.max_displayname_characters)
.refine( .refine(
(s) => (s) =>
!config.filters.displayname.some((filter) => s.match(filter)), !config.validation.filters.displayname.some((filter) =>
filter.test(s),
),
"Display name contains blocked words", "Display name contains blocked words",
) )
.openapi({ .openapi({
@ -185,10 +203,11 @@ export const Account = z.object({
note: z note: z
.string() .string()
.min(0) .min(0)
.max(config.validation.max_bio_size) .max(config.validation.accounts.max_bio_characters)
.trim() .trim()
.refine( .refine(
(s) => !config.filters.bio.some((filter) => s.match(filter)), (s) =>
!config.validation.filters.bio.some((filter) => filter.test(s)),
"Bio contains blocked words", "Bio contains blocked words",
) )
.openapi({ .openapi({
@ -255,7 +274,7 @@ export const Account = z.object({
}), }),
fields: z fields: z
.array(Field) .array(Field)
.max(config.validation.max_field_count) .max(config.validation.accounts.max_field_count)
.openapi({ .openapi({
description: description:
"Additional metadata attached to a profile as name-value pairs.", "Additional metadata attached to a profile as name-value pairs.",

View file

@ -1,5 +1,5 @@
import { z } from "@hono/zod-openapi"; import { z } from "@hono/zod-openapi";
import { config } from "~/packages/config-manager/index.ts"; import { config } from "~/config.ts";
import { Id } from "./common.ts"; import { Id } from "./common.ts";
export const Attachment = z export const Attachment = z
@ -54,7 +54,7 @@ export const Attachment = z
description: z description: z
.string() .string()
.trim() .trim()
.max(config.validation.max_media_description_size) .max(config.validation.media.max_description_characters)
.nullable() .nullable()
.openapi({ .openapi({
description: description:

View file

@ -4,3 +4,8 @@ import ISO6391 from "iso-639-1";
export const Id = z.string().uuid(); export const Id = z.string().uuid();
export const iso631 = z.enum(ISO6391.getAllCodes() as [string, ...string[]]); export const iso631 = z.enum(ISO6391.getAllCodes() as [string, ...string[]]);
export const zBoolean = z
.string()
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
.or(z.boolean());

View file

@ -1,7 +1,7 @@
import { emojiValidator } from "@/api.ts"; import { emojiValidator } from "@/api.ts";
import { z } from "@hono/zod-openapi"; import { z } from "@hono/zod-openapi";
import { zBoolean } from "~/packages/config-manager/config.type"; import { zBoolean } from "~/classes/schemas/common.ts";
import { config } from "~/packages/config-manager/index.ts"; import { config } from "~/config.ts";
import { Id } from "./common.ts"; import { Id } from "./common.ts";
export const CustomEmoji = z export const CustomEmoji = z
@ -15,7 +15,7 @@ export const CustomEmoji = z
.string() .string()
.trim() .trim()
.min(1) .min(1)
.max(config.validation.max_emoji_shortcode_size) .max(config.validation.emojis.max_shortcode_characters)
.regex( .regex(
emojiValidator, emojiValidator,
"Shortcode must only contain letters (any case), numbers, dashes or underscores.", "Shortcode must only contain letters (any case), numbers, dashes or underscores.",
@ -77,7 +77,7 @@ export const CustomEmoji = z
/* Versia Server API extension */ /* Versia Server API extension */
description: z description: z
.string() .string()
.max(config.validation.max_emoji_description_size) .max(config.validation.emojis.max_description_characters)
.nullable() .nullable()
.openapi({ .openapi({
description: description:

View file

@ -1,6 +1,5 @@
import { z } from "@hono/zod-openapi"; import { z } from "@hono/zod-openapi";
import { zBoolean } from "~/packages/config-manager/config.type.ts"; import { Id, zBoolean } from "./common.ts";
import { Id } from "./common.ts";
export const FilterStatus = z export const FilterStatus = z
.object({ .object({

View file

@ -1,5 +1,5 @@
import { z } from "@hono/zod-openapi"; import { z } from "@hono/zod-openapi";
import { config } from "~/packages/config-manager/index.ts"; import { config } from "~/config.ts";
import { Id } from "./common.ts"; import { Id } from "./common.ts";
import { CustomEmoji } from "./emoji.ts"; import { CustomEmoji } from "./emoji.ts";
@ -9,7 +9,7 @@ export const PollOption = z
.string() .string()
.trim() .trim()
.min(1) .min(1)
.max(config.validation.max_poll_option_size) .max(config.validation.polls.max_option_characters)
.openapi({ .openapi({
description: "The text value of the poll option.", description: "The text value of the poll option.",
example: "yes", example: "yes",

View file

@ -1,11 +1,10 @@
import { z } from "@hono/zod-openapi"; import { z } from "@hono/zod-openapi";
import type { Status as ApiNote } from "@versia/client/types"; import type { Status as ApiNote } from "@versia/client/types";
import { zBoolean } from "~/packages/config-manager/config.type.ts"; import { config } from "~/config.ts";
import { config } from "~/packages/config-manager/index.ts";
import { Account } from "./account.ts"; import { Account } from "./account.ts";
import { Attachment } from "./attachment.ts"; import { Attachment } from "./attachment.ts";
import { PreviewCard } from "./card.ts"; import { PreviewCard } from "./card.ts";
import { Id, iso631 } from "./common.ts"; import { Id, iso631, zBoolean } from "./common.ts";
import { CustomEmoji } from "./emoji.ts"; import { CustomEmoji } from "./emoji.ts";
import { FilterResult } from "./filters.ts"; import { FilterResult } from "./filters.ts";
import { Poll } from "./poll.ts"; import { Poll } from "./poll.ts";
@ -58,12 +57,12 @@ export const StatusSource = z
}), }),
text: z text: z
.string() .string()
.max(config.validation.max_note_size) .max(config.validation.notes.max_characters)
.trim() .trim()
.refine( .refine(
(s) => (s) =>
!config.filters.note_content.some((filter) => !config.validation.filters.note_content.some((filter) =>
s.match(filter), filter.test(s),
), ),
"Status contains blocked words", "Status contains blocked words",
) )

View file

@ -1,6 +1,6 @@
import { z } from "@hono/zod-openapi"; import { z } from "@hono/zod-openapi";
import { RolePermission } from "@versia/client/types"; import { RolePermission } from "@versia/client/types";
import { config } from "~/packages/config-manager/index.ts"; import { config } from "~/config.ts";
import { Id } from "./common.ts"; import { Id } from "./common.ts";
/* Versia Server API extension */ /* Versia Server API extension */
@ -56,7 +56,7 @@ export const NoteReaction = z
name: z name: z
.string() .string()
.min(1) .min(1)
.max(config.validation.max_emoji_shortcode_size) .max(config.validation.emojis.max_shortcode_characters)
.trim() .trim()
.openapi({ .openapi({
description: "Custom Emoji shortcode or Unicode emoji.", description: "Custom Emoji shortcode or Unicode emoji.",

View file

@ -10,7 +10,7 @@ import {
Ingest as SonicChannelIngest, Ingest as SonicChannelIngest,
Search as SonicChannelSearch, Search as SonicChannelSearch,
} from "sonic-channel"; } from "sonic-channel";
import { type Config, config } from "~/packages/config-manager"; import { config } from "~/config.ts";
/** /**
* Enum for Sonic index types * Enum for Sonic index types
@ -32,17 +32,21 @@ export class SonicSearchManager {
/** /**
* @param config Configuration for Sonic * @param config Configuration for Sonic
*/ */
public constructor(private config: Config) { public constructor() {
if (!config.search.sonic) {
throw new Error("Sonic configuration is missing");
}
this.searchChannel = new SonicChannelSearch({ this.searchChannel = new SonicChannelSearch({
host: config.sonic.host, host: config.search.sonic.host,
port: config.sonic.port, port: config.search.sonic.port,
auth: config.sonic.password, auth: config.search.sonic.password,
}); });
this.ingestChannel = new SonicChannelIngest({ this.ingestChannel = new SonicChannelIngest({
host: config.sonic.host, host: config.search.sonic.host,
port: config.sonic.port, port: config.search.sonic.port,
auth: config.sonic.password, auth: config.search.sonic.password,
}); });
} }
@ -50,7 +54,7 @@ export class SonicSearchManager {
* Connect to Sonic * Connect to Sonic
*/ */
public async connect(silent = false): Promise<void> { public async connect(silent = false): Promise<void> {
if (!this.config.sonic.enabled) { if (!config.search.enabled) {
!silent && this.logger.info`Sonic search is disabled`; !silent && this.logger.info`Sonic search is disabled`;
return; return;
} }
@ -127,7 +131,7 @@ export class SonicSearchManager {
* @param user User to add * @param user User to add
*/ */
public async addUser(user: User): Promise<void> { public async addUser(user: User): Promise<void> {
if (!this.config.sonic.enabled) { if (!config.search.enabled) {
return; return;
} }
@ -310,4 +314,4 @@ export class SonicSearchManager {
} }
} }
export const searchManager = new SonicSearchManager(config); export const searchManager = new SonicSearchManager();

View file

@ -1,7 +1,7 @@
import { User } from "@versia/kit/db"; import { User } from "@versia/kit/db";
import { Worker } from "bullmq"; import { Worker } from "bullmq";
import chalk from "chalk"; import chalk from "chalk";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
import { connection } from "~/utils/redis.ts"; import { connection } from "~/utils/redis.ts";
import { import {
type DeliveryJobData, type DeliveryJobData,
@ -52,10 +52,10 @@ export const getDeliveryWorker = (): Worker<
{ {
connection, connection,
removeOnComplete: { removeOnComplete: {
age: config.queues.delivery.remove_on_complete, age: config.queues.delivery?.remove_after_complete_seconds,
}, },
removeOnFail: { removeOnFail: {
age: config.queues.delivery.remove_on_failure, age: config.queues.delivery?.remove_after_failure_seconds,
}, },
}, },
); );

View file

@ -2,7 +2,7 @@ import { Instance } from "@versia/kit/db";
import { Instances } from "@versia/kit/tables"; import { Instances } from "@versia/kit/tables";
import { Worker } from "bullmq"; import { Worker } from "bullmq";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
import { connection } from "~/utils/redis.ts"; import { connection } from "~/utils/redis.ts";
import { import {
type FetchJobData, type FetchJobData,
@ -52,10 +52,10 @@ export const getFetchWorker = (): Worker<FetchJobData, void, FetchJobType> =>
{ {
connection, connection,
removeOnComplete: { removeOnComplete: {
age: config.queues.fetch.remove_on_complete, age: config.queues.fetch?.remove_after_complete_seconds,
}, },
removeOnFail: { removeOnFail: {
age: config.queues.fetch.remove_on_failure, age: config.queues.fetch?.remove_after_failure_seconds,
}, },
}, },
); );

View file

@ -1,7 +1,7 @@
import { getLogger } from "@logtape/logtape"; import { getLogger } from "@logtape/logtape";
import { Instance, User } from "@versia/kit/db"; import { Instance, User } from "@versia/kit/db";
import { Worker } from "bullmq"; import { Worker } from "bullmq";
import { config } from "~/packages/config-manager/index.ts"; import { config } from "~/config.ts";
import { connection } from "~/utils/redis.ts"; import { connection } from "~/utils/redis.ts";
import { InboxProcessor } from "../inbox/processor.ts"; import { InboxProcessor } from "../inbox/processor.ts";
import { import {
@ -168,10 +168,10 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
{ {
connection, connection,
removeOnComplete: { removeOnComplete: {
age: config.queues.inbox.remove_on_complete, age: config.queues.inbox?.remove_after_complete_seconds,
}, },
removeOnFail: { removeOnFail: {
age: config.queues.inbox.remove_on_failure, age: config.queues.inbox?.remove_after_failure_seconds,
}, },
}, },
); );

View file

@ -1,6 +1,6 @@
import { Media } from "@versia/kit/db"; import { Media } from "@versia/kit/db";
import { Worker } from "bullmq"; import { Worker } from "bullmq";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
import { connection } from "~/utils/redis.ts"; import { connection } from "~/utils/redis.ts";
import { calculateBlurhash } from "../media/preprocessors/blurhash.ts"; import { calculateBlurhash } from "../media/preprocessors/blurhash.ts";
import { convertImage } from "../media/preprocessors/image-conversion.ts"; import { convertImage } from "../media/preprocessors/image-conversion.ts";
@ -100,10 +100,10 @@ export const getMediaWorker = (): Worker<MediaJobData, void, MediaJobType> =>
{ {
connection, connection,
removeOnComplete: { removeOnComplete: {
age: config.queues.media.remove_on_complete, age: config.queues.media?.remove_after_complete_seconds,
}, },
removeOnFail: { removeOnFail: {
age: config.queues.media.remove_on_failure, age: config.queues.media?.remove_after_failure_seconds,
}, },
}, },
); );

View file

@ -2,7 +2,7 @@ import { htmlToText } from "@/content_types.ts";
import { Note, PushSubscription, Token, User } from "@versia/kit/db"; import { Note, PushSubscription, Token, User } from "@versia/kit/db";
import { Worker } from "bullmq"; import { Worker } from "bullmq";
import { sendNotification } from "web-push"; import { sendNotification } from "web-push";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
import { connection } from "~/utils/redis.ts"; import { connection } from "~/utils/redis.ts";
import { import {
type PushJobData, type PushJobData,
@ -18,6 +18,11 @@ export const getPushWorker = (): Worker<PushJobData, void, PushJobType> =>
data: { psId, relatedUserId, type, noteId, notificationId }, data: { psId, relatedUserId, type, noteId, notificationId },
} = job; } = job;
if (!config.notifications.push) {
await job.log("Push notifications are disabled");
return;
}
await job.log( await job.log(
`Sending push notification for note [${notificationId}]`, `Sending push notification for note [${notificationId}]`,
); );
@ -105,17 +110,18 @@ export const getPushWorker = (): Worker<PushJobData, void, PushJobType> =>
preferred_locale: "en-US", preferred_locale: "en-US",
notification_id: notificationId, notification_id: notificationId,
notification_type: type, notification_type: type,
icon: relatedUser.getAvatarUrl(config), icon: relatedUser.getAvatarUrl(),
title, title,
body: truncate(body, 140), body: truncate(body, 140),
}), }),
{ {
vapidDetails: { vapidDetails: {
subject: subject:
config.notifications.push.vapid.subject || config.notifications.push.subject ||
config.http.base_url.origin, config.http.base_url.origin,
privateKey: config.notifications.push.vapid.private, privateKey:
publicKey: config.notifications.push.vapid.public, config.notifications.push.vapid_keys.private,
publicKey: config.notifications.push.vapid_keys.public,
}, },
contentEncoding: "aesgcm", contentEncoding: "aesgcm",
}, },
@ -128,10 +134,10 @@ export const getPushWorker = (): Worker<PushJobData, void, PushJobType> =>
{ {
connection, connection,
removeOnComplete: { removeOnComplete: {
age: config.queues.push.remove_on_complete, age: config.queues.push?.remove_after_complete_seconds,
}, },
removeOnFail: { removeOnFail: {
age: config.queues.push.remove_on_failure, age: config.queues.push?.remove_after_failure_seconds,
}, },
}, },
); );

View file

@ -5,7 +5,7 @@ import chalk from "chalk";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import ora from "ora"; import ora from "ora";
import { BaseCommand } from "~/cli/base"; import { BaseCommand } from "~/cli/base";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
export default class EmojiAdd extends BaseCommand<typeof EmojiAdd> { export default class EmojiAdd extends BaseCommand<typeof EmojiAdd> {
public static override args = { public static override args = {
@ -62,7 +62,7 @@ export default class EmojiAdd extends BaseCommand<typeof EmojiAdd> {
"Accept-Encoding": "identity", "Accept-Encoding": "identity",
}, },
// @ts-expect-error Proxy is a Bun-specific feature // @ts-expect-error Proxy is a Bun-specific feature
proxy: config.http.proxy.address, proxy: config.http.proxy_address,
}); });
if (!response.ok) { if (!response.ok) {

View file

@ -7,7 +7,7 @@ import { lookup } from "mime-types";
import ora from "ora"; import ora from "ora";
import { unzip } from "unzipit"; import { unzip } from "unzipit";
import { BaseCommand } from "~/cli/base"; import { BaseCommand } from "~/cli/base";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
type MetaType = { type MetaType = {
emojis: { emojis: {
@ -69,7 +69,7 @@ export default class EmojiImport extends BaseCommand<typeof EmojiImport> {
"Accept-Encoding": "identity", "Accept-Encoding": "identity",
}, },
// @ts-expect-error Proxy is a Bun-specific feature // @ts-expect-error Proxy is a Bun-specific feature
proxy: config.http.proxy.address, proxy: config.http.proxy_address,
}); });
if (!response.ok) { if (!response.ok) {

View file

@ -2,7 +2,7 @@ import { Args, Flags } from "@oclif/core";
import ora from "ora"; import ora from "ora";
import { SonicIndexType, searchManager } from "~/classes/search/search-manager"; import { SonicIndexType, searchManager } from "~/classes/search/search-manager";
import { BaseCommand } from "~/cli/base"; import { BaseCommand } from "~/cli/base";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
export default class IndexRebuild extends BaseCommand<typeof IndexRebuild> { export default class IndexRebuild extends BaseCommand<typeof IndexRebuild> {
public static override args = { public static override args = {
@ -28,8 +28,8 @@ export default class IndexRebuild extends BaseCommand<typeof IndexRebuild> {
public async run(): Promise<void> { public async run(): Promise<void> {
const { flags, args } = await this.parse(IndexRebuild); const { flags, args } = await this.parse(IndexRebuild);
if (!config.sonic.enabled) { if (!config.search.enabled) {
this.error("Sonic search is disabled"); this.error("Search is disabled");
this.exit(1); this.exit(1);
} }

View file

@ -4,7 +4,7 @@ import chalk from "chalk";
import { renderUnicodeCompact } from "uqr"; import { renderUnicodeCompact } from "uqr";
import { UserFinderCommand } from "~/cli/classes"; import { UserFinderCommand } from "~/cli/classes";
import { formatArray } from "~/cli/utils/format"; import { formatArray } from "~/cli/utils/format";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
export default class UserReset extends UserFinderCommand<typeof UserReset> { export default class UserReset extends UserFinderCommand<typeof UserReset> {
public static override description = "Resets users' passwords"; public static override description = "Resets users' passwords";

50
config.ts Normal file
View file

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

View file

@ -1,16 +1,20 @@
# You can change the URL to the commit/tag you are using # 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 #:schema https://raw.githubusercontent.com/versia-pub/server/main/config/config.schema.json
[database] # 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)
# Main PostgreSQL database connection
[postgres]
# PostgreSQL database configuration
host = "localhost" host = "localhost"
port = 5432 port = 5432
username = "versia" username = "versia"
# Sensitive value
password = "mycoolpassword" password = "mycoolpassword"
database = "versia" database = "versia"
# Add any eventual read-only database replicas here # Additional read-only replicas
# [[database.replicas]] # [[postgres.replicas]]
# host = "other-host" # host = "other-host"
# port = 5432 # port = 5432
# username = "versia" # username = "versia"
@ -18,45 +22,48 @@ database = "versia"
# database = "replica1" # database = "replica1"
[redis.queue] [redis.queue]
# Redis instance for storing the federation queue # A Redis database used for managing queues.
# Required for federation # Required for federation
host = "localhost" host = "localhost"
port = 6379 port = 6379
password = "" # Sensitive value
# password = "test"
database = 0 database = 0
[redis.cache] # A Redis database used for caching SQL queries.
# Redis instance to be used as a timeline cache
# Optional, can be the same as the queue instance # Optional, can be the same as the queue instance
host = "localhost" # [redis.cache]
port = 6380 # host = "localhost"
password = "" # port = 6380
database = 1 # database = 1
# password = ""
# Search and indexing configuration
[search]
# Enable indexing and searching?
enabled = false enabled = false
[sonic] # Optional if search is disabled
# If Sonic is not configured, search will not be enabled # [search.sonic]
host = "localhost" # host = "localhost"
port = 7700 # port = 7700
password = "" # Sensitive value
enabled = true # password = "test"
[signups] [registration]
# Whether to enable registrations or not # Can users sign up freely?
registration = true allow = true
rules = [ # NOT IMPLEMENTED
"Do not harass others", require_approval = false
"Be nice to people", # Message to show to users when registration is disabled
"Don't spam", # message = "ran out of spoons to moderate registrations, sorry"
"Don't post illegal content",
]
[http] [http]
# The full URL Versia Server will be reachable by (paths are not supported) # URL that the instance will be accessible at
base_url = "https://versia.localhost:9900" base_url = "https://example.com"
# Address to bind to (0.0.0.0 is suggested for proxies) # Address to bind to (0.0.0.0 is suggested for proxies)
bind = "versia.localhost" bind = "0.0.0.0"
bind_port = 9900 bind_port = 8080
# Bans IPv4 or IPv6 IPs (wildcards, networks and ranges are supported) # Bans IPv4 or IPv6 IPs (wildcards, networks and ranges are supported)
banned_ips = [] banned_ips = []
@ -66,29 +73,17 @@ banned_user_agents = [
# "wget\/1.20.3", # "wget\/1.20.3",
] ]
[http.proxy] # URL to an eventual HTTP proxy
# For HTTP proxies (e.g. Tor proxies)
# Will be used for all outgoing requests # Will be used for all outgoing requests
enabled = false # proxy_address = "http://localhost:8118"
address = "http://localhost:8118"
[http.tls] # TLS configuration. You should probably be using a reverse proxy instead of this
# If these values are set, Versia Server will use these files for TLS # [http.tls]
enabled = false # key = "/path/to/key.pem"
key = "" # cert = "/path/to/cert.pem"
cert = "" # Sensitive value
passphrase = "" # passphrase = "awawa"
ca = "" # ca = "/path/to/ca.pem"
[http.bait]
# Enable the bait feature (sends fake data to those who are flagged)
enabled = false
# Path to file of bait data (if not provided, Versia Server will send the entire Bee Movie script)
send_file = ""
# IPs to send bait data to (wildcards, networks and ranges are supported)
bait_ips = ["127.0.0.1", "::1"]
# User agents to send bait data to (regex format)
bait_user_agents = ["curl", "wget"]
[frontend] [frontend]
# Enable custom frontends (warning: not enabling this will make Versia Server only accessible via the Mastodon API) # Enable custom frontends (warning: not enabling this will make Versia Server only accessible via the Mastodon API)
@ -112,27 +107,29 @@ url = "http://localhost:3000"
# This can be used to set up custom themes, etc on supported frontends. # This can be used to set up custom themes, etc on supported frontends.
# theme = "dark" # theme = "dark"
[smtp] # NOT IMPLEMENTED
[email]
# Enable email sending
send_emails = false
# If send_emails is true, the following settings are required
# [email.smtp]
# SMTP server to use for sending emails # SMTP server to use for sending emails
server = "smtp.example.com" # server = "smtp.example.com"
port = 465 # port = 465
username = "test@example.com" # username = "test@example.com"
password = "password123" # Sensitive value
tls = true # password = "password123"
# Disable all email functions (this will allow people to sign up without verifying # tls = true
# their email)
enabled = false
[media] [media]
# Can be "s3" or "local", where "local" uploads the file to the local filesystem # Can be "s3" or "local", where "local" uploads the file to the local filesystem
# Changing this value will not retroactively apply to existing data # Changing this value will not retroactively apply to existing data
# Don't forget to fill in the s3 config :3 # Don't forget to fill in the s3 config :3
backend = "s3" backend = "s3"
# 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 # If media backend is "local", this is the folder where the files will be stored
# Can be any path # Can be any path
local_uploads_folder = "uploads" uploads_path = "uploads"
[media.conversion] [media.conversion]
# Whether to automatically convert images to another format on upload # Whether to automatically convert images to another format on upload
@ -141,41 +138,30 @@ convert_images = true
# JXL support will likely not work # JXL support will likely not work
convert_to = "image/webp" convert_to = "image/webp"
# Also convert SVG images? # Also convert SVG images?
convert_vector = false convert_vectors = false
# [s3] # [s3]
# Can be left commented if you don't use the S3 media backend # Can be left commented if you don't use the S3 media backend
# endpoint = "" # endpoint = "https://s3.example.com"
# Sensitive value
# access_key = "XXXXX" # access_key = "XXXXX"
# Sensitive value
# secret_access_key = "XXX" # secret_access_key = "XXX"
# region = "" # region = "us-east-1"
# bucket_name = "versia" # bucket_name = "versia"
# public_url = "https://cdn.example.com" # public_url = "https://cdn.example.com"
[validation] [validation]
# Checks user data # Checks user data
# Does not retroactively apply to previously entered data # Does not retroactively apply to previously entered data
max_displayname_size = 50 # Character length [validation.accounts]
max_bio_size = 5000 max_displayname_characters = 50
max_note_size = 5000 max_username_characters = 30
max_avatar_size = 5_000_000 # Bytes max_bio_characters = 5000
max_header_size = 5_000_000 max_avatar_bytes = 5_000_000
max_media_size = 40_000_000 max_header_bytes = 5_000_000
max_media_attachments = 10 # Regex is allowed here
max_media_description_size = 1000 disallowed_usernames = [
max_emoji_size = 1000000
max_emoji_shortcode_size = 100
max_emoji_description_size = 1000
max_poll_options = 20
max_poll_option_size = 500
min_poll_duration = 60 # Seconds
max_poll_duration = 1893456000
max_username_size = 30
max_field_count = 10
max_field_name_size = 1000
max_field_value_size = 1000
# Forbidden usernames, defaults are from Akkoma
username_blacklist = [
"well-known", "well-known",
"about", "about",
"activities", "activities",
@ -202,12 +188,14 @@ username_blacklist = [
"search", "search",
"mfa", "mfa",
] ]
# Whether to blacklist known temporary email providers max_field_count = 10
blacklist_tempmail = false max_field_name_characters = 1000
# Additional email providers to blacklist (list of domains) max_field_value_characters = 1000
email_blacklist = [] max_pinned_notes = 20
# Valid URL schemes, otherwise the URL is parsed as text
url_scheme_whitelist = [ [validation.notes]
max_characters = 5000
allowed_url_schemes = [
"http", "http",
"https", "https",
"ftp", "ftp",
@ -226,39 +214,71 @@ url_scheme_whitelist = [
"ssb", "ssb",
"gemini", "gemini",
] ]
# Only allow those MIME types of data to be uploaded max_attachments = 16
# This can easily be spoofed, but if it is spoofed it will appear broken
# to normal clients until despoofed
enforce_mime_types = false
# Defaults to all valid MIME types
# allowed_mime_types = []
[validation.challenges] [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 # "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 # This means that they do not require any user interaction, instead
# they require the user's computer to do a small amount of work # they require the user's computer to do a small amount of work
enabled = false
# The difficulty of the challenge, higher is will take more time to solve # The difficulty of the challenge, higher is will take more time to solve
difficulty = 50000 # difficulty = 50000
# Challenge expiration time in seconds # Challenge expiration time in seconds
expiration = 300 # 5 minutes # expiration = 300 # 5 minutes
# Leave this empty to generate a new key # Leave this empty to generate a new key
key = "" # Sensitive value
# key = ""
# 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] [notifications]
[notifications.push] # Web Push Notifications configuration.
# Whether to enable push notifications # Leave out to disable.
enabled = true # [notifications.push]
# Subject field embedded in the push notification
[notifications.push.vapid] # subject = "mailto:joe@example.com"
#
# [notifications.push.vapid_keys]
# VAPID keys for push notifications # VAPID keys for push notifications
# Run Versia Server with those values missing to generate new keys # Run Versia Server with those values missing to generate new keys
public = "" # Sensitive value
private = "" # public = ""
# Optional # Sensitive value
# subject = "mailto:joe@example.com" # private = ""
[defaults] [defaults]
# Default visibility for new notes # Default visibility for new notes
@ -278,37 +298,37 @@ placeholder_style = "thumbs"
# Controls the delivery queue (for outbound federation) # Controls the delivery queue (for outbound federation)
[queues.delivery] [queues.delivery]
# Time in seconds to remove completed jobs # Time in seconds to remove completed jobs
remove_on_complete = 31536000 remove_after_complete_seconds = 31536000
# Time in seconds to remove failed jobs # Time in seconds to remove failed jobs
remove_on_failure = 31536000 remove_after_failure_seconds = 31536000
# Controls the inbox processing queue (for inbound federation) # Controls the inbox processing queue (for inbound federation)
[queues.inbox] [queues.inbox]
# Time in seconds to remove completed jobs # Time in seconds to remove completed jobs
remove_on_complete = 31536000 remove_after_complete_seconds = 31536000
# Time in seconds to remove failed jobs # Time in seconds to remove failed jobs
remove_on_failure = 31536000 remove_after_failure_seconds = 31536000
# Controls the fetch queue (for remote data refreshes) # Controls the fetch queue (for remote data refreshes)
[queues.fetch] [queues.fetch]
# Time in seconds to remove completed jobs # Time in seconds to remove completed jobs
remove_on_complete = 31536000 remove_after_complete_seconds = 31536000
# Time in seconds to remove failed jobs # Time in seconds to remove failed jobs
remove_on_failure = 31536000 remove_after_failure_seconds = 31536000
# Controls the push queue (for push notification delivery) # Controls the push queue (for push notification delivery)
[queues.push] [queues.push]
# Time in seconds to remove completed jobs # Time in seconds to remove completed jobs
remove_on_complete = 31536000 remove_after_complete_seconds = 31536000
# Time in seconds to remove failed jobs # Time in seconds to remove failed jobs
remove_on_failure = 31536000 remove_after_failure_seconds = 31536000
# Controls the media queue (for media processing) # Controls the media queue (for media processing)
[queues.media] [queues.media]
# Time in seconds to remove completed jobs # Time in seconds to remove completed jobs
remove_on_complete = 31536000 remove_after_complete_seconds = 31536000
# Time in seconds to remove failed jobs # Time in seconds to remove failed jobs
remove_on_failure = 31536000 remove_after_failure_seconds = 31536000
[federation] [federation]
# This is a list of domain names, such as "mastodon.social" or "pleroma.site" # This is a list of domain names, such as "mastodon.social" or "pleroma.site"
@ -335,39 +355,58 @@ avatars = []
# For bridge software, such as versia-pub/activitypub # For bridge software, such as versia-pub/activitypub
# Bridges must be hosted separately from the main Versia Server process # Bridges must be hosted separately from the main Versia Server process
[federation.bridge] # [federation.bridge]
enabled = false
# Only versia-ap exists for now # Only versia-ap exists for now
software = "versia-ap" # software = "versia-ap"
# If this is empty, any bridge with the correct token # If this is empty, any bridge with the correct token
# will be able to send data to your instance # will be able to send data to your instance
allowed_ips = ["192.168.1.0/24"] # v4, v6, ranges and wildcards are supported
# allowed_ips = ["192.168.1.0/24"]
# Token for the bridge software # Token for the bridge software
# Bridge must have the same token! # Bridge must have the same token!
token = "mycooltoken" # Sensitive value
url = "https://ap.versia.social" # token = "mycooltoken"
# url = "https://ap.versia.social"
[instance] [instance]
name = "Versia" name = "Versia"
description = "A Versia Server instance" description = "A Versia Server instance"
# Path to a file containing a longer description of your instance
# This will be parsed as Markdown # Paths to instance long description, terms of service, and privacy policy
# These will be parsed as Markdown
#
# extended_description_path = "config/extended_description.md" # extended_description_path = "config/extended_description.md"
# Path to a file containing the terms of service of your instance
# This will be parsed as Markdown
# tos_path = "config/tos.md" # tos_path = "config/tos.md"
# Path to a file containing the privacy policy of your instance
# This will be parsed as Markdown
# privacy_policy_path = "config/privacy_policy.md" # privacy_policy_path = "config/privacy_policy.md"
# URL to your instance logo
# logo = "" # Primary instance languages. ISO 639-1 codes.
# URL to your instance banner languages = ["en"]
# banner = ""
[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. # Used for federation. If left empty or missing, the server will generate one for you.
[instance.keys] # [instance.keys]
public = "" # Sensitive value
private = "" # public = ""
# Sensitive value
# private = ""
[[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] [permissions]
# Control default permissions for users # Control default permissions for users
@ -385,67 +424,34 @@ private = ""
# Defaults to being able to manage all instance data, content, and users # Defaults to being able to manage all instance data, content, and users
# admin = [] # admin = []
[filters]
# Regex filters for federated and local data
# Drops data matching the filters
# Does not apply retroactively to existing data
# 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] [logging]
# Log all requests (warning: this is a lot of data)
log_requests = false
# Log request and their contents (warning: this is a lot of data)
log_requests_verbose = false
# Available levels: debug, info, warning, error, fatal # Available levels: debug, info, warning, error, fatal
log_level = "debug" log_level = "debug"
# For GDPR compliance, you can disable logging of IPs
log_ip = false
# Log all filtered objects log_file_path = "logs/versia.log"
log_filters = true
[logging.sentry] [logging.types]
# Whether to enable https://sentry.io error logging # Either pass a boolean
enabled = false # requests = true
# Or a table with the following keys:
# requests_content = { level = "debug", log_file_path = "logs/requests.log" }
# Available types are: requests, responses, requests_content, filters
# https://sentry.io support
# Uncomment to enable
# [logging.sentry]
# Sentry DSN for error logging # Sentry DSN for error logging
dsn = "" # dsn = "https://example.com"
debug = false # debug = false
sample_rate = 1.0 # sample_rate = 1.0
traces_sample_rate = 1.0 # traces_sample_rate = 1.0
# Can also be regex # Can also be regex
trace_propagation_targets = [] # trace_propagation_targets = []
max_breadcrumbs = 100 # max_breadcrumbs = 100
# environment = "production" # environment = "production"
[logging.storage]
# Path to logfile for requests
requests = "logs/requests.log"
[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
[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 }
[plugins] [plugins]
# Whether to automatically load all plugins in the plugins directory # Whether to automatically load all plugins in the plugins directory
autoload = true autoload = true
@ -481,5 +487,6 @@ allow_registration = true
# This MUST match the provider's issuer URI, including the trailing slash (or lack thereof) # This MUST match the provider's issuer URI, including the trailing slash (or lack thereof)
# url = "https://id.cpluspatch.com/application/o/versia-testing/" # url = "https://id.cpluspatch.com/application/o/versia-testing/"
# client_id = "XXXX" # client_id = "XXXX"
# Sensitive value
# client_secret = "XXXXX" # client_secret = "XXXXX"
# icon = "https://cpluspatch.com/images/icons/logo.svg" # icon = "https://cpluspatch.com/images/icons/logo.svg"

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
import type { Config } from "drizzle-kit"; import type { Config } from "drizzle-kit";
import { config } from "./packages/config-manager/index.ts"; import { config } from "~/config.ts";
/** /**
* Drizzle can't properly resolve imports with top-level await, so uncomment * Drizzle can't properly resolve imports with top-level await, so uncomment
@ -15,11 +15,11 @@ export default {
user: "lysand", user: "lysand",
password: "lysand", password: "lysand",
database: "lysand", */ database: "lysand", */
host: config.database.host, host: config.postgres.host,
port: Number(config.database.port), port: config.postgres.port,
user: config.database.username, user: config.postgres.username,
password: config.database.password, password: config.postgres.password,
database: config.database.database, database: config.postgres.database,
}, },
// Print all statements // Print all statements
verbose: true, verbose: true,

View file

@ -4,28 +4,27 @@ import { type NodePgDatabase, drizzle } from "drizzle-orm/node-postgres";
import { withReplicas } from "drizzle-orm/pg-core"; import { withReplicas } from "drizzle-orm/pg-core";
import { migrate } from "drizzle-orm/postgres-js/migrator"; import { migrate } from "drizzle-orm/postgres-js/migrator";
import { Pool } from "pg"; import { Pool } from "pg";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
import * as schema from "./schema.ts"; import * as schema from "./schema.ts";
const primaryDb = new Pool({ const primaryDb = new Pool({
host: config.database.host, host: config.postgres.host,
port: Number(config.database.port), port: config.postgres.port,
user: config.database.username, user: config.postgres.username,
password: config.database.password, password: config.postgres.password,
database: config.database.database, database: config.postgres.database,
}); });
const replicas = const replicas = config.postgres.replicas.map(
config.database.replicas?.map(
(replica) => (replica) =>
new Pool({ new Pool({
host: replica.host, host: replica.host,
port: Number(replica.port), port: replica.port,
user: replica.username, user: replica.username,
password: replica.password, password: replica.password,
database: replica.database, database: replica.database,
}), }),
) ?? []; );
export const db = export const db =
(replicas.length ?? 0) > 0 (replicas.length ?? 0) > 0

View file

@ -2,7 +2,7 @@ import cluster from "node:cluster";
import { sentry } from "@/sentry"; import { sentry } from "@/sentry";
import { createServer } from "@/server"; import { createServer } from "@/server";
import { appFactory } from "~/app"; import { appFactory } from "~/app";
import { config } from "~/packages/config-manager/index.ts"; import { config } from "~/config.ts";
process.on("SIGINT", () => { process.on("SIGINT", () => {
process.exit(); process.exit();

View file

@ -1,10 +1,9 @@
import { checkConfig } from "@/init";
import { configureLoggers } from "@/loggers"; import { configureLoggers } from "@/loggers";
import { getLogger } from "@logtape/logtape"; import { getLogger } from "@logtape/logtape";
import { Note } from "@versia/kit/db"; import { Note } from "@versia/kit/db";
import IORedis from "ioredis"; import IORedis from "ioredis";
import { config } from "~/config.ts";
import { setupDatabase } from "~/drizzle/db"; import { setupDatabase } from "~/drizzle/db";
import { config } from "~/packages/config-manager/index.ts";
import { searchManager } from "../../classes/search/search-manager.ts"; import { searchManager } from "../../classes/search/search-manager.ts";
const timeAtStart = performance.now(); const timeAtStart = performance.now();
@ -26,15 +25,13 @@ serverLogger.info`Starting Versia Server...`;
await setupDatabase(); await setupDatabase();
if (config.sonic.enabled) { if (config.search.enabled) {
await searchManager.connect(); await searchManager.connect();
} }
// Check if database is reachable // Check if database is reachable
const postCount = await Note.getCount(); const postCount = await Note.getCount();
await checkConfig(config);
serverLogger.info`Versia Server started at ${config.http.bind}:${config.http.bind_port} in ${(performance.now() - timeAtStart).toFixed(0)}ms`; serverLogger.info`Versia Server started at ${config.http.bind}:${config.http.bind_port} in ${(performance.now() - timeAtStart).toFixed(0)}ms`;
serverLogger.info`Database is online, now serving ${postCount} posts`; serverLogger.info`Database is online, now serving ${postCount} posts`;

View file

@ -1,11 +1,10 @@
import { checkConfig } from "@/init";
import { configureLoggers } from "@/loggers"; import { configureLoggers } from "@/loggers";
import { getLogger } from "@logtape/logtape"; import { getLogger } from "@logtape/logtape";
import { Note } from "@versia/kit/db"; import { Note } from "@versia/kit/db";
import chalk from "chalk"; import chalk from "chalk";
import IORedis from "ioredis"; import IORedis from "ioredis";
import { config } from "~/config.ts";
import { setupDatabase } from "~/drizzle/db"; import { setupDatabase } from "~/drizzle/db";
import { config } from "~/packages/config-manager/index.ts";
import { searchManager } from "../../classes/search/search-manager.ts"; import { searchManager } from "../../classes/search/search-manager.ts";
const timeAtStart = performance.now(); const timeAtStart = performance.now();
@ -28,15 +27,13 @@ serverLogger.info`Starting Versia Server Worker...`;
await setupDatabase(); await setupDatabase();
if (config.sonic.enabled) { if (config.search.enabled) {
await searchManager.connect(); await searchManager.connect();
} }
// Check if database is reachable // Check if database is reachable
const postCount = await Note.getCount(); const postCount = await Note.getCount();
await checkConfig(config);
serverLogger.info`Versia Server Worker started at ${config.http.bind}:${config.http.bind_port} in ${(performance.now() - timeAtStart).toFixed(0)}ms`; serverLogger.info`Versia Server Worker started at ${config.http.bind}:${config.http.bind_port} in ${(performance.now() - timeAtStart).toFixed(0)}ms`;
serverLogger.info`Database is online, containing ${postCount} posts`; serverLogger.info`Database is online, containing ${postCount} posts`;

View file

@ -1,6 +1,6 @@
import { createMiddleware } from "hono/factory"; import { createMiddleware } from "hono/factory";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
export const agentBans = createMiddleware(async (context, next) => { export const agentBans = createMiddleware(async (context, next) => {
// Check for banned user agents (regex) // Check for banned user agents (regex)

View file

@ -1,51 +0,0 @@
import { getLogger } from "@logtape/logtape";
import type { BunFile, SocketAddress } from "bun";
import { createMiddleware } from "hono/factory";
import { matches } from "ip-matching";
import { config } from "~/packages/config-manager";
const baitFile = async (): Promise<BunFile | undefined> => {
const file = Bun.file(config.http.bait.send_file || "./beemovie.txt");
if (await file.exists()) {
return file;
}
const logger = getLogger("server");
logger.error`Bait file not found: ${config.http.bait.send_file}`;
};
export const bait = createMiddleware(async (context, next) => {
const requestIp = context.env?.ip as SocketAddress | undefined | null;
if (!config.http.bait.enabled) {
return await next();
}
const file = await baitFile();
if (!file) {
return await next();
}
// Check for bait IPs
if (requestIp?.address) {
for (const ip of config.http.bait.bait_ips) {
if (matches(ip, requestIp.address)) {
return context.body(file.stream());
}
}
}
// Check for bait user agents (regex)
const ua = context.req.header("user-agent") ?? "";
for (const agent of config.http.bait.bait_user_agents) {
if (new RegExp(agent).test(ua)) {
return context.body(file.stream());
}
}
await next();
});

View file

@ -4,7 +4,7 @@ import type { SocketAddress } from "bun";
import { createMiddleware } from "hono/factory"; import { createMiddleware } from "hono/factory";
import { matches } from "ip-matching"; import { matches } from "ip-matching";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
export const ipBans = createMiddleware(async (context, next) => { export const ipBans = createMiddleware(async (context, next) => {
// Check for banned IPs // Check for banned IPs

View file

@ -1,10 +1,10 @@
import { getLogger } from "@logtape/logtape"; import { getLogger } from "@logtape/logtape";
import chalk from "chalk"; import chalk from "chalk";
import { createMiddleware } from "hono/factory"; import { createMiddleware } from "hono/factory";
import { config } from "~/packages/config-manager"; import { config } from "~/config.ts";
export const logger = createMiddleware(async (context, next) => { export const logger = createMiddleware(async (context, next) => {
if (config.logging.log_requests) { if (config.logging.types.requests) {
const serverLogger = getLogger("server"); const serverLogger = getLogger("server");
const body = await context.req.raw.clone().text(); const body = await context.req.raw.clone().text();
@ -25,7 +25,7 @@ export const logger = createMiddleware(async (context, next) => {
const bodyLog = `${chalk.bold("Body")}: ${chalk.gray(body)}`; const bodyLog = `${chalk.bold("Body")}: ${chalk.gray(body)}`;
if (config.logging.log_requests_verbose) { if (config.logging.types.requests_content) {
serverLogger.debug`${urlAndMethod}\n${hash}\n${headers}\n${bodyLog}`; serverLogger.debug`${urlAndMethod}\n${hash}\n${headers}\n${bodyLog}`;
} else { } else {
serverLogger.debug`${urlAndMethod}`; serverLogger.debug`${urlAndMethod}`;

View file

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

View file

@ -38,7 +38,7 @@
"wc": "find server database *.ts docs packages types utils drizzle tests -type f -print0 | wc -m --files0-from=-", "wc": "find server database *.ts docs packages types utils drizzle tests -type f -print0 | wc -m --files0-from=-",
"cli": "bun run cli/index.ts", "cli": "bun run cli/index.ts",
"prune": "ts-prune | grep -v server/ | grep -v dist/ | grep -v '(used in module)'", "prune": "ts-prune | grep -v server/ | grep -v dist/ | grep -v '(used in module)'",
"schema:generate": "bun run packages/config-manager/json-schema.ts > config/config.schema.json && bun run packages/plugin-kit/json-schema.ts > packages/plugin-kit/manifest.schema.json", "schema:generate": "bun run classes/config/to-json-schema.ts > config/config.schema.json && bun run packages/plugin-kit/json-schema.ts > packages/plugin-kit/manifest.schema.json",
"check": "bunx tsc -p .", "check": "bunx tsc -p .",
"test": "find . -name \"*.test.ts\" -not -path \"./node_modules/*\" | xargs -I {} sh -c 'bun test {} || exit 255'", "test": "find . -name \"*.test.ts\" -not -path \"./node_modules/*\" | xargs -I {} sh -c 'bun test {} || exit 255'",
"docs:dev": "vitepress dev docs", "docs:dev": "vitepress dev docs",

View file

@ -1,863 +0,0 @@
import { z } from "@hono/zod-openapi";
import {
ADMIN_ROLES,
DEFAULT_ROLES,
RolePermissions,
} from "@versia/kit/tables";
import { types as mimeTypes } from "mime-types";
export enum MediaBackendType {
Local = "local",
S3 = "s3",
}
const zUrlPath = z
.string()
.trim()
.min(1)
// Remove trailing slashes, but keep the root slash
.transform((arg) => (arg === "/" ? arg : arg.replace(/\/$/, "")));
const zUrl = z
.string()
.trim()
.min(1)
.refine((arg) => URL.canParse(arg), "Invalid url")
.transform((arg) => arg.replace(/\/$/, ""))
.transform((arg) => new URL(arg));
export const zBoolean = z
.string()
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
.or(z.boolean());
export const configValidator = z
.object({
database: z
.object({
host: z.string().min(1).default("localhost"),
port: z
.number()
.int()
.min(1)
.max(2 ** 16 - 1)
.default(5432),
username: z.string().min(1),
password: z.string().default(""),
database: z.string().min(1).default("versia"),
replicas: z
.array(
z
.object({
host: z.string().min(1),
port: z
.number()
.int()
.min(1)
.max(2 ** 16 - 1)
.default(5432),
username: z.string().min(1),
password: z.string().default(""),
database: z.string().min(1).default("versia"),
})
.strict(),
)
.optional(),
})
.strict(),
redis: z
.object({
queue: z
.object({
host: z.string().min(1).default("localhost"),
port: z
.number()
.int()
.min(1)
.max(2 ** 16 - 1)
.default(6379),
password: z.string().default(""),
database: z.number().int().default(0),
})
.strict()
.default({
host: "localhost",
port: 6379,
password: "",
database: 0,
}),
cache: z
.object({
host: z.string().min(1).default("localhost"),
port: z
.number()
.int()
.min(1)
.max(2 ** 16 - 1)
.default(6379),
password: z.string().default(""),
database: z.number().int().default(1),
enabled: z.boolean().default(false),
})
.strict()
.default({
host: "localhost",
port: 6379,
password: "",
database: 1,
enabled: false,
}),
})
.strict(),
sonic: z
.object({
host: z.string().min(1).default("localhost"),
port: z
.number()
.int()
.min(1)
.max(2 ** 16 - 1)
.default(7700),
password: z.string(),
enabled: z.boolean().default(false),
})
.strict(),
signups: z
.object({
registration: z.boolean().default(true),
rules: z.array(z.string()).default([]),
})
.strict(),
http: z
.object({
base_url: zUrl.default("http://versia.social"),
bind: z.string().min(1).default("0.0.0.0"),
bind_port: z
.number()
.int()
.min(1)
.max(2 ** 16 - 1)
.default(8080),
// Not using .ip() because we allow CIDR ranges and wildcards and such
banned_ips: z.array(z.string()).default([]),
banned_user_agents: z.array(z.string()).default([]),
proxy: z
.object({
enabled: z.boolean().default(false),
address: zUrl.or(z.literal("")),
})
.strict()
.default({
enabled: false,
address: "",
})
.refine(
(arg) => !arg.enabled || !!arg.address,
"When proxy is enabled, address must be set",
)
.transform((arg) => ({
...arg,
address: arg.enabled ? arg.address : undefined,
})),
tls: z
.object({
enabled: z.boolean().default(false),
key: z.string(),
cert: z.string(),
passphrase: z.string().optional(),
ca: z.string().optional(),
})
.strict()
.default({
enabled: false,
key: "",
cert: "",
passphrase: "",
ca: "",
}),
bait: z
.object({
enabled: z.boolean().default(false),
send_file: z.string().optional(),
bait_ips: z.array(z.string()).default([]),
bait_user_agents: z.array(z.string()).default([]),
})
.strict()
.default({
enabled: false,
send_file: "",
bait_ips: [],
bait_user_agents: [],
}),
})
.strict(),
frontend: z
.object({
enabled: z.boolean().default(true),
url: zUrl.default("http://localhost:3000"),
routes: z
.object({
home: zUrlPath.default("/"),
login: zUrlPath.default("/oauth/authorize"),
consent: zUrlPath.default("/oauth/consent"),
register: zUrlPath.default("/register"),
password_reset: zUrlPath.default("/oauth/reset"),
})
.strict()
.default({
home: "/",
login: "/oauth/authorize",
consent: "/oauth/consent",
register: "/register",
password_reset: "/oauth/reset",
}),
settings: z.record(z.string(), z.any()).default({}),
})
.strict()
.default({
enabled: true,
url: "http://localhost:3000",
settings: {},
}),
smtp: z
.object({
server: z.string().min(1),
port: z
.number()
.int()
.min(1)
.max(2 ** 16 - 1)
.default(465),
username: z.string().min(1),
password: z.string().min(1).optional(),
tls: z.boolean().default(true),
enabled: z.boolean().default(false),
})
.strict()
.default({
server: "",
port: 465,
username: "",
password: "",
tls: true,
enabled: false,
}),
media: z
.object({
backend: z
.nativeEnum(MediaBackendType)
.default(MediaBackendType.Local),
deduplicate_media: z.boolean().default(true),
local_uploads_folder: z.string().min(1).default("uploads"),
conversion: z
.object({
convert_images: z.boolean().default(false),
convert_to: z.string().default("image/webp"),
convert_vector: z.boolean().default(false),
})
.strict()
.default({
convert_images: false,
convert_to: "image/webp",
convert_vector: false,
}),
})
.strict()
.default({
backend: MediaBackendType.Local,
deduplicate_media: true,
local_uploads_folder: "uploads",
conversion: {
convert_images: false,
convert_to: "image/webp",
},
}),
s3: z
.object({
endpoint: z.string(),
access_key: z.string(),
secret_access_key: z.string(),
region: z.string().optional(),
bucket_name: z.string().default("versia"),
public_url: zUrl,
})
.strict()
.optional(),
validation: z
.object({
max_displayname_size: z.number().int().default(50),
max_bio_size: z.number().int().default(5000),
max_note_size: z.number().int().default(5000),
max_avatar_size: z.number().int().default(5000000),
max_header_size: z.number().int().default(5000000),
max_media_size: z.number().int().default(40000000),
max_media_attachments: z.number().int().default(10),
max_media_description_size: z.number().int().default(1000),
max_emoji_size: z.number().int().default(1000000),
max_emoji_shortcode_size: z.number().int().default(100),
max_emoji_description_size: z.number().int().default(1000),
max_poll_options: z.number().int().default(20),
max_poll_option_size: z.number().int().default(500),
min_poll_duration: z.number().int().default(60),
max_poll_duration: z.number().int().default(1893456000),
max_username_size: z.number().int().default(30),
max_field_count: z.number().int().default(10),
max_field_name_size: z.number().int().default(1000),
max_field_value_size: z.number().int().default(1000),
username_blacklist: z
.array(z.string())
.default([
"well-known",
"about",
"activities",
"api",
"auth",
"dev",
"inbox",
"internal",
"main",
"media",
"nodeinfo",
"notice",
"oauth",
"objects",
"proxy",
"push",
"registration",
"relay",
"settings",
"status",
"tag",
"users",
"web",
"search",
"mfa",
]),
blacklist_tempmail: z.boolean().default(false),
email_blacklist: z.array(z.string()).default([]),
url_scheme_whitelist: z
.array(z.string())
.default([
"http",
"https",
"ftp",
"dat",
"dweb",
"gopher",
"hyper",
"ipfs",
"ipns",
"irc",
"xmpp",
"ircs",
"magnet",
"mailto",
"mumble",
"ssb",
"gemini",
]),
enforce_mime_types: z.boolean().default(false),
allowed_mime_types: z
.array(z.string())
.default(Object.values(mimeTypes)),
challenges: z
.object({
enabled: z.boolean().default(true),
difficulty: z.number().int().positive().default(50000),
expiration: z.number().int().positive().default(300),
key: z.string().default(""),
})
.strict()
.default({
enabled: true,
difficulty: 50000,
expiration: 300,
key: "",
}),
})
.strict()
.default({
max_displayname_size: 50,
max_bio_size: 5000,
max_note_size: 5000,
max_avatar_size: 5000000,
max_header_size: 5000000,
max_media_size: 40000000,
max_media_attachments: 10,
max_media_description_size: 1000,
max_emoji_size: 1000000,
max_emoji_shortcode_size: 100,
max_emoji_description_size: 1000,
max_poll_options: 20,
max_poll_option_size: 500,
min_poll_duration: 60,
max_poll_duration: 1893456000,
max_username_size: 30,
max_field_count: 10,
max_field_name_size: 1000,
max_field_value_size: 1000,
username_blacklist: [
"well-known",
"about",
"activities",
"api",
"auth",
"dev",
"inbox",
"internal",
"main",
"media",
"nodeinfo",
"notice",
"oauth",
"objects",
"proxy",
"push",
"registration",
"relay",
"settings",
"status",
"tag",
"users",
"web",
"search",
"mfa",
],
blacklist_tempmail: false,
email_blacklist: [],
url_scheme_whitelist: [
"http",
"https",
"ftp",
"dat",
"dweb",
"gopher",
"hyper",
"ipfs",
"ipns",
"irc",
"xmpp",
"ircs",
"magnet",
"mailto",
"mumble",
"ssb",
"gemini",
],
enforce_mime_types: false,
allowed_mime_types: Object.values(mimeTypes),
challenges: {
enabled: true,
difficulty: 50000,
expiration: 300,
key: "",
},
}),
notifications: z
.object({
push: z
.object({
enabled: z.boolean().default(true),
vapid: z
.object({
public: z.string(),
private: z.string(),
subject: z.string().optional(),
})
.strict()
.default({
public: "",
private: "",
}),
})
.strict()
.default({
enabled: true,
vapid: {
public: "",
private: "",
},
}),
})
.strict(),
defaults: z
.object({
visibility: z.string().default("public"),
language: z.string().default("en"),
avatar: zUrl.optional(),
header: zUrl.optional(),
placeholder_style: z.string().default("thumbs"),
})
.strict()
.default({
visibility: "public",
language: "en",
avatar: undefined,
header: undefined,
placeholder_style: "thumbs",
}),
federation: z
.object({
blocked: z.array(zUrl).default([]),
followers_only: z.array(zUrl).default([]),
discard: z
.object({
reports: z.array(zUrl).default([]),
deletes: z.array(zUrl).default([]),
updates: z.array(zUrl).default([]),
media: z.array(zUrl).default([]),
follows: z.array(zUrl).default([]),
likes: z.array(zUrl).default([]),
reactions: z.array(zUrl).default([]),
banners: z.array(zUrl).default([]),
avatars: z.array(zUrl).default([]),
})
.strict(),
bridge: z
.object({
enabled: z.boolean().default(false),
software: z.enum(["versia-ap"]).or(z.string()),
allowed_ips: z.array(z.string().trim()).default([]),
token: z.string().default(""),
url: zUrl.optional(),
})
.strict()
.default({
enabled: false,
software: "versia-ap",
allowed_ips: [],
token: "",
})
.refine(
(arg) => (arg.enabled ? arg.url : true),
"When bridge is enabled, url must be set",
),
})
.strict()
.default({
blocked: [],
followers_only: [],
discard: {
reports: [],
deletes: [],
updates: [],
media: [],
follows: [],
likes: [],
reactions: [],
banners: [],
avatars: [],
},
bridge: {
enabled: false,
software: "versia-ap",
allowed_ips: [],
token: "",
},
}),
queues: z
.object({
delivery: z
.object({
remove_on_complete: z
.number()
.int()
// 1 year
.default(60 * 60 * 24 * 365),
remove_on_failure: z
.number()
.int()
// 1 year
.default(60 * 60 * 24 * 365),
})
.strict()
.default({
remove_on_complete: 60 * 60 * 24 * 365,
remove_on_failure: 60 * 60 * 24 * 365,
}),
inbox: z
.object({
remove_on_complete: z
.number()
.int()
// 1 year
.default(60 * 60 * 24 * 365),
remove_on_failure: z
.number()
.int()
// 1 year
.default(60 * 60 * 24 * 365),
})
.strict()
.default({
remove_on_complete: 60 * 60 * 24 * 365,
remove_on_failure: 60 * 60 * 24 * 365,
}),
fetch: z
.object({
remove_on_complete: z
.number()
.int()
// 1 year
.default(60 * 60 * 24 * 365),
remove_on_failure: z
.number()
.int()
// 1 year
.default(60 * 60 * 24 * 365),
})
.strict()
.default({
remove_on_complete: 60 * 60 * 24 * 365,
remove_on_failure: 60 * 60 * 24 * 365,
}),
push: z
.object({
remove_on_complete: z
.number()
.int()
// 1 year
.default(60 * 60 * 24 * 365),
remove_on_failure: z
.number()
.int()
// 1 year
.default(60 * 60 * 24 * 365),
})
.strict()
.default({
remove_on_complete: 60 * 60 * 24 * 365,
remove_on_failure: 60 * 60 * 24 * 365,
}),
media: z
.object({
remove_on_complete: z
.number()
.int()
// 1 year
.default(60 * 60 * 24 * 365),
remove_on_failure: z
.number()
.int()
// 1 year
.default(60 * 60 * 24 * 365),
})
.strict()
.default({
remove_on_complete: 60 * 60 * 24 * 365,
remove_on_failure: 60 * 60 * 24 * 365,
}),
})
.strict()
.default({
delivery: {
remove_on_complete: 60 * 60 * 24 * 365,
remove_on_failure: 60 * 60 * 24 * 365,
},
inbox: {
remove_on_complete: 60 * 60 * 24 * 365,
remove_on_failure: 60 * 60 * 24 * 365,
},
fetch: {
remove_on_complete: 60 * 60 * 24 * 365,
remove_on_failure: 60 * 60 * 24 * 365,
},
push: {
remove_on_complete: 60 * 60 * 24 * 365,
remove_on_failure: 60 * 60 * 24 * 365,
},
media: {
remove_on_complete: 60 * 60 * 24 * 365,
remove_on_failure: 60 * 60 * 24 * 365,
},
}),
instance: z
.object({
name: z.string().min(1).default("Versia"),
description: z.string().min(1).default("A Versia instance"),
extended_description_path: z.string().optional(),
tos_path: z.string().optional(),
privacy_policy_path: z.string().optional(),
logo: zUrl.optional(),
banner: zUrl.optional(),
keys: z
.object({
public: z.string().min(3).default("").or(z.literal("")),
private: z
.string()
.min(3)
.default("")
.or(z.literal("")),
})
.strict()
.default({
public: "",
private: "",
}),
})
.strict()
.default({
name: "Versia",
description: "A Versia instance",
extended_description_path: undefined,
tos_path: undefined,
privacy_policy_path: undefined,
logo: undefined,
banner: undefined,
keys: {
public: "",
private: "",
},
}),
permissions: z
.object({
anonymous: z
.array(z.nativeEnum(RolePermissions))
.default(DEFAULT_ROLES),
default: z
.array(z.nativeEnum(RolePermissions))
.default(DEFAULT_ROLES),
admin: z
.array(z.nativeEnum(RolePermissions))
.default(ADMIN_ROLES),
})
.strict()
.default({
anonymous: DEFAULT_ROLES,
default: DEFAULT_ROLES,
admin: ADMIN_ROLES,
}),
filters: z
.object({
note_content: z.array(z.string()).default([]),
emoji: z.array(z.string()).default([]),
username: z.array(z.string()).default([]),
displayname: z.array(z.string()).default([]),
bio: z.array(z.string()).default([]),
})
.strict(),
logging: z
.object({
log_requests: z.boolean().default(false),
log_responses: z.boolean().default(false),
log_requests_verbose: z.boolean().default(false),
log_level: z
.enum(["debug", "info", "warning", "error", "fatal"])
.default("info"),
log_ip: z.boolean().default(false),
log_filters: z.boolean().default(true),
sentry: z
.object({
enabled: z.boolean().default(false),
dsn: z.string().url().or(z.literal("")).optional(),
debug: z.boolean().default(false),
sample_rate: z.number().min(0).max(1.0).default(1.0),
traces_sample_rate: z
.number()
.min(0)
.max(1.0)
.default(1.0),
trace_propagation_targets: z
.array(z.string())
.default([]),
max_breadcrumbs: z.number().default(100),
environment: z.string().optional(),
})
.strict()
.default({
enabled: false,
debug: false,
sample_rate: 1.0,
traces_sample_rate: 1.0,
max_breadcrumbs: 100,
})
.refine(
(arg) => (arg.enabled ? !!arg.dsn : true),
"When sentry is enabled, DSN must be set",
),
storage: z
.object({
requests: z.string().default("logs/requests.log"),
})
.strict()
.default({
requests: "logs/requests.log",
}),
})
.strict()
.default({
log_requests: false,
log_responses: false,
log_requests_verbose: false,
log_level: "info",
log_ip: false,
log_filters: true,
sentry: {
enabled: false,
debug: false,
sample_rate: 1.0,
traces_sample_rate: 1.0,
max_breadcrumbs: 100,
},
storage: {
requests: "logs/requests.log",
},
}),
ratelimits: z
.object({
duration_coeff: z.number().default(1),
max_coeff: z.number().default(1),
custom: z
.record(
z.string(),
z
.object({
duration: z.number().default(30),
max: z.number().default(60),
})
.strict(),
)
.default({}),
})
.strict(),
debug: z
.object({
federation: z.boolean().default(false),
})
.strict()
.default({
federation: false,
}),
plugins: z
.object({
autoload: z.boolean().default(true),
overrides: z
.object({
enabled: z.array(z.string()).default([]),
disabled: z.array(z.string()).default([]),
})
.strict()
.default({
enabled: [],
disabled: [],
})
.refine(
// Only one of enabled or disabled can be set
(arg) =>
arg.enabled.length === 0 ||
arg.disabled.length === 0,
"Only one of enabled or disabled can be set",
),
config: z.record(z.string(), z.any()).optional(),
})
.strict()
.optional(),
})
.strict()
.refine(
// If media backend is S3, s3 config must be set
(arg) => arg.media.backend === MediaBackendType.Local || !!arg.s3,
"S3 config must be set when using S3 media backend",
);
export type Config = z.infer<typeof configValidator>;

View file

@ -1,32 +0,0 @@
/**
* @file index.ts
* @summary ConfigManager system to retrieve and modify system configuration
* @description Can read from a hand-written file, config.toml, or from a machine-saved file, config.internal.toml
* Fuses both and provides a way to retrieve individual values
*/
import { loadConfig, watchConfig } from "c12";
import { fromZodError } from "zod-validation-error";
import { type Config, configValidator } from "./config.type";
export type { Config } from "./config.type";
const { config } = await watchConfig({
configFile: "./config/config.toml",
overrides:
(
await loadConfig<Config>({
configFile: "./config/config.internal.toml",
})
).config ?? undefined,
});
const parsed = await configValidator.safeParseAsync(config);
if (!parsed.success) {
console.error("Invalid config file:");
throw fromZodError(parsed.error).message;
}
const exportedConfig = parsed.data;
export { exportedConfig as config };

View file

@ -37,7 +37,7 @@
"format": "uri" "format": "uri"
} }
}, },
"required": ["name"], "required": ["name", "email", "url"],
"additionalProperties": false "additionalProperties": false
} }
}, },
@ -75,10 +75,18 @@
"format": "uri" "format": "uri"
} }
}, },
"required": ["type", "url"],
"additionalProperties": false "additionalProperties": false
} }
}, },
"required": ["name", "version", "description"], "required": [
"$schema",
"name",
"version",
"description",
"authors",
"repository"
],
"additionalProperties": false, "additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#" "$schema": "http://json-schema.org/draft-07/schema#"
} }

View file

@ -51,7 +51,7 @@ export class Plugin<ConfigSchema extends z.ZodTypeAny> {
try { try {
this.store = await this.configSchema.parseAsync(config); this.store = await this.configSchema.parseAsync(config);
} catch (error) { } catch (error) {
throw fromZodError(error as ZodError).message; throw fromZodError(error as ZodError);
} }
} }

View file

@ -1,10 +1,10 @@
import { z } from "@hono/zod-openapi"; import { z } from "@hono/zod-openapi";
import { Hooks, Plugin } from "@versia/kit"; import { Hooks, Plugin } from "@versia/kit";
import { User } from "@versia/kit/db"; import { User } from "@versia/kit/db";
import chalk from "chalk";
import { getCookie } from "hono/cookie"; import { getCookie } from "hono/cookie";
import { jwtVerify } from "jose"; import { jwtVerify } from "jose";
import { JOSEError, JWTExpired } from "jose/errors"; import { JOSEError, JWTExpired } from "jose/errors";
import { keyPair, sensitiveString } from "~/classes/config/schema.ts";
import { ApiError } from "~/classes/errors/api-error.ts"; import { ApiError } from "~/classes/errors/api-error.ts";
import { RolePermissions } from "~/drizzle/schema.ts"; import { RolePermissions } from "~/drizzle/schema.ts";
import authorizeRoute from "./routes/authorize.ts"; import authorizeRoute from "./routes/authorize.ts";
@ -26,64 +26,12 @@ const configSchema = z.object({
id: z.string().min(1), id: z.string().min(1),
url: z.string().min(1), url: z.string().min(1),
client_id: z.string().min(1), client_id: z.string().min(1),
client_secret: z.string().min(1), client_secret: sensitiveString,
icon: z.string().min(1).optional(), icon: z.string().min(1).optional(),
}), }),
) )
.default([]), .default([]),
keys: z keys: keyPair,
.object({
public: z
.string()
.min(1)
.transform(async (v) => {
try {
return await crypto.subtle.importKey(
"spki",
Buffer.from(v, "base64"),
"Ed25519",
true,
["verify"],
);
} catch {
throw new Error(
"Public key at oidc.keys.public is invalid",
);
}
}),
private: z
.string()
.min(1)
.transform(async (v) => {
try {
return await crypto.subtle.importKey(
"pkcs8",
Buffer.from(v, "base64"),
"Ed25519",
true,
["sign"],
);
} catch {
throw new Error(
"Private key at oidc.keys.private is invalid",
);
}
}),
})
.optional()
.transform(async (v, ctx) => {
if (!(v?.private && v?.public)) {
const { public_key, private_key } = await User.generateKeys();
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Keys are missing, please add the following to your config:\n\nkeys.public: ${chalk.gray(public_key)}\nkeys.private: ${chalk.gray(private_key)}
`,
});
}
return v as Exclude<typeof v, undefined>;
}),
}); });
const plugin = new Plugin(configSchema); const plugin = new Plugin(configSchema);

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