refactor(config): ♻️ Redo config structure from scratch, simplify validation code, improve checks, add support for loading sensitive data from paths

This commit is contained in:
Jesse Wierzbinski 2025-02-15 02:47:29 +01:00
parent d4afd84019
commit 54fd81f076
No known key found for this signature in database
118 changed files with 3892 additions and 5291 deletions

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

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

View file

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

View file

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

View file

@ -15,9 +15,9 @@ import {
inArray,
} from "drizzle-orm";
import sharp from "sharp";
import { MediaBackendType } from "~/classes/config/schema.ts";
import type { Attachment as AttachmentSchema } from "~/classes/schemas/attachment.ts";
import { MediaBackendType } from "~/packages/config-manager/config.type";
import { config } from "~/packages/config-manager/index.ts";
import { config } from "~/config.ts";
import { ApiError } from "../errors/api-error.ts";
import { getMediaHash } from "../media/media-hasher.ts";
import { MediaJobType, mediaQueue } from "../queues/media.ts";
@ -135,11 +135,7 @@ export class Media extends BaseInterface<typeof Medias> {
switch (config.media.backend) {
case MediaBackendType.Local: {
const path = join(
config.media.local_uploads_folder,
hash,
fileName,
);
const path = join(config.media.uploads_path, hash, fileName);
await write(path, file);
@ -154,7 +150,7 @@ export class Media extends BaseInterface<typeof Medias> {
}
const client = new S3Client({
endpoint: config.s3.endpoint,
endpoint: config.s3.endpoint.origin,
region: config.s3.region,
bucket: config.s3.bucket_name,
accessKeyId: config.s3.access_key,
@ -260,21 +256,21 @@ export class Media extends BaseInterface<typeof Medias> {
}
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(
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 (
config.validation.enforce_mime_types &&
!config.validation.allowed_mime_types.includes(file.type)
config.validation.media.allowed_mime_types.length > 0 &&
!config.validation.media.allowed_mime_types.includes(file.type)
) {
throw new ApiError(
415,
`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,
} from "~/classes/functions/status";
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 type { Status } from "../schemas/status.ts";
import { Application } from "./application.ts";
@ -594,7 +594,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
const { data } = await requester.get(uri, {
// @ts-expect-error Bun extension
proxy: config.http.proxy.address,
proxy: config.http.proxy_address,
});
const note = await new EntityValidator().Note(data);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -9,8 +9,10 @@ import {
User,
} from "@versia/kit/db";
import type { SocketAddress } from "bun";
import type { z } from "zod";
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";
// Mock dependencies
@ -58,7 +60,7 @@ mock.module("@versia/federation", () => ({
RequestParserHandler: jest.fn(),
}));
mock.module("~/packages/config-manager/index.ts", () => ({
mock.module("~/config.ts", () => ({
config: {
debug: {
federation: false,
@ -172,9 +174,13 @@ describe("InboxProcessor", () => {
});
test("returns false for valid bridge request", () => {
config.federation.bridge.enabled = true;
config.federation.bridge.token = "valid-token";
config.federation.bridge.allowed_ips = ["127.0.0.1"];
config.federation.bridge = {
token: "valid-token",
allowed_ips: ["127.0.0.1"],
url: new URL("https://test.com"),
software: "versia-ap",
};
mockHeaders.authorization = "Bearer valid-token";
// biome-ignore lint/complexity/useLiteralKeys: Private method
@ -183,7 +189,9 @@ describe("InboxProcessor", () => {
});
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";
// 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 { matches } from "ip-matching";
import { type ValidationError, isValidationError } from "zod-validation-error";
import { config } from "~/packages/config-manager/index.ts";
import { config } from "~/config.ts";
type ResponseBody = {
message?: string;
@ -98,7 +98,7 @@ export class InboxProcessor {
throw new Error("Sender is not defined");
}
if (config.debug.federation) {
if (config.debug?.federation) {
this.logger.debug`Sender public key: ${chalk.gray(
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.
*/
private shouldCheckSignature(): boolean | ResponseBody {
if (config.federation.bridge.enabled) {
if (config.federation.bridge) {
const token = this.headers.authorization?.split("Bearer ")[1];
if (token) {
@ -158,6 +158,14 @@ export class InboxProcessor {
* @returns
*/
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) {
return {
message:

View file

@ -1,10 +1,10 @@
import { beforeEach, describe, expect, it, mock } from "bun:test";
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";
describe("ImageConversionPreprocessor", () => {
let mockConfig: Config;
let mockConfig: typeof config;
beforeEach(() => {
mockConfig = {
@ -15,9 +15,9 @@ describe("ImageConversionPreprocessor", () => {
convert_vector: false,
},
},
} as Config;
} as unknown as typeof config;
mock.module("~/packages/config-manager/index.ts", () => ({
mock.module("~/config.ts", () => ({
config: mockConfig,
}));
});
@ -59,7 +59,7 @@ describe("ImageConversionPreprocessor", () => {
});
it("should convert SVG when convert_vector is true", async () => {
mockConfig.media.conversion.convert_vector = true;
mockConfig.media.conversion.convert_vectors = true;
const svgContent =
'<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 { config } from "~/packages/config-manager/index.ts";
import { config } from "~/config.ts";
/**
* Supported input media formats.
@ -39,7 +39,7 @@ const supportedOutputFormats = [
const isConvertible = (file: File): boolean => {
if (
file.type === "image/svg+xml" &&
!config.media.conversion.convert_vector
!config.media.conversion.convert_vectors
) {
return false;
}

View file

@ -5,7 +5,7 @@ import chalk from "chalk";
import { parseJSON5, parseJSONC } from "confbox";
import type { ZodTypeAny } from "zod";
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 { type Manifest, manifestSchema } from "~/packages/plugin-kit/schema";
import type { HonoEnv } from "~/types/api";
@ -230,10 +230,13 @@ export class PluginLoader {
config.plugins?.config?.[data.manifest.name],
);
} 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(
"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);
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
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 { CustomEmoji } from "./emoji.ts";
@ -9,7 +9,7 @@ export const PollOption = z
.string()
.trim()
.min(1)
.max(config.validation.max_poll_option_size)
.max(config.validation.polls.max_option_characters)
.openapi({
description: "The text value of the poll option.",
example: "yes",

View file

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

View file

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

View file

@ -10,7 +10,7 @@ import {
Ingest as SonicChannelIngest,
Search as SonicChannelSearch,
} from "sonic-channel";
import { type Config, config } from "~/packages/config-manager";
import { config } from "~/config.ts";
/**
* Enum for Sonic index types
@ -32,17 +32,21 @@ export class SonicSearchManager {
/**
* @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({
host: config.sonic.host,
port: config.sonic.port,
auth: config.sonic.password,
host: config.search.sonic.host,
port: config.search.sonic.port,
auth: config.search.sonic.password,
});
this.ingestChannel = new SonicChannelIngest({
host: config.sonic.host,
port: config.sonic.port,
auth: config.sonic.password,
host: config.search.sonic.host,
port: config.search.sonic.port,
auth: config.search.sonic.password,
});
}
@ -50,7 +54,7 @@ export class SonicSearchManager {
* Connect to Sonic
*/
public async connect(silent = false): Promise<void> {
if (!this.config.sonic.enabled) {
if (!config.search.enabled) {
!silent && this.logger.info`Sonic search is disabled`;
return;
}
@ -127,7 +131,7 @@ export class SonicSearchManager {
* @param user User to add
*/
public async addUser(user: User): Promise<void> {
if (!this.config.sonic.enabled) {
if (!config.search.enabled) {
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 { Worker } from "bullmq";
import chalk from "chalk";
import { config } from "~/packages/config-manager";
import { config } from "~/config.ts";
import { connection } from "~/utils/redis.ts";
import {
type DeliveryJobData,
@ -52,10 +52,10 @@ export const getDeliveryWorker = (): Worker<
{
connection,
removeOnComplete: {
age: config.queues.delivery.remove_on_complete,
age: config.queues.delivery?.remove_after_complete_seconds,
},
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 { Worker } from "bullmq";
import { eq } from "drizzle-orm";
import { config } from "~/packages/config-manager";
import { config } from "~/config.ts";
import { connection } from "~/utils/redis.ts";
import {
type FetchJobData,
@ -52,10 +52,10 @@ export const getFetchWorker = (): Worker<FetchJobData, void, FetchJobType> =>
{
connection,
removeOnComplete: {
age: config.queues.fetch.remove_on_complete,
age: config.queues.fetch?.remove_after_complete_seconds,
},
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 { Instance, User } from "@versia/kit/db";
import { Worker } from "bullmq";
import { config } from "~/packages/config-manager/index.ts";
import { config } from "~/config.ts";
import { connection } from "~/utils/redis.ts";
import { InboxProcessor } from "../inbox/processor.ts";
import {
@ -168,10 +168,10 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
{
connection,
removeOnComplete: {
age: config.queues.inbox.remove_on_complete,
age: config.queues.inbox?.remove_after_complete_seconds,
},
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 { Worker } from "bullmq";
import { config } from "~/packages/config-manager";
import { config } from "~/config.ts";
import { connection } from "~/utils/redis.ts";
import { calculateBlurhash } from "../media/preprocessors/blurhash.ts";
import { convertImage } from "../media/preprocessors/image-conversion.ts";
@ -100,10 +100,10 @@ export const getMediaWorker = (): Worker<MediaJobData, void, MediaJobType> =>
{
connection,
removeOnComplete: {
age: config.queues.media.remove_on_complete,
age: config.queues.media?.remove_after_complete_seconds,
},
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 { Worker } from "bullmq";
import { sendNotification } from "web-push";
import { config } from "~/packages/config-manager";
import { config } from "~/config.ts";
import { connection } from "~/utils/redis.ts";
import {
type PushJobData,
@ -18,6 +18,11 @@ export const getPushWorker = (): Worker<PushJobData, void, PushJobType> =>
data: { psId, relatedUserId, type, noteId, notificationId },
} = job;
if (!config.notifications.push) {
await job.log("Push notifications are disabled");
return;
}
await job.log(
`Sending push notification for note [${notificationId}]`,
);
@ -105,17 +110,18 @@ export const getPushWorker = (): Worker<PushJobData, void, PushJobType> =>
preferred_locale: "en-US",
notification_id: notificationId,
notification_type: type,
icon: relatedUser.getAvatarUrl(config),
icon: relatedUser.getAvatarUrl(),
title,
body: truncate(body, 140),
}),
{
vapidDetails: {
subject:
config.notifications.push.vapid.subject ||
config.notifications.push.subject ||
config.http.base_url.origin,
privateKey: config.notifications.push.vapid.private,
publicKey: config.notifications.push.vapid.public,
privateKey:
config.notifications.push.vapid_keys.private,
publicKey: config.notifications.push.vapid_keys.public,
},
contentEncoding: "aesgcm",
},
@ -128,10 +134,10 @@ export const getPushWorker = (): Worker<PushJobData, void, PushJobType> =>
{
connection,
removeOnComplete: {
age: config.queues.push.remove_on_complete,
age: config.queues.push?.remove_after_complete_seconds,
},
removeOnFail: {
age: config.queues.push.remove_on_failure,
age: config.queues.push?.remove_after_failure_seconds,
},
},
);