mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
refactor(config): 🔥 Replace config validation with Zod
This commit is contained in:
parent
093337dd4f
commit
fb31375b74
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
|
@ -8,6 +8,7 @@
|
||||||
"build",
|
"build",
|
||||||
"api",
|
"api",
|
||||||
"cli",
|
"cli",
|
||||||
"federation"
|
"federation",
|
||||||
|
"config"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,9 @@
|
||||||
# Lysand Config
|
|
||||||
# All of these values can be changed via the CLI (they will be saved in a file named config.internal.toml
|
|
||||||
# in the same directory as this one)
|
|
||||||
# Changing this file does not require a restart, but might take a few seconds to apply
|
|
||||||
# This file will be merged with the CLI configuration, taking precedence over it
|
|
||||||
|
|
||||||
[database]
|
[database]
|
||||||
# Main PostgreSQL database connection
|
# Main PostgreSQL database connection
|
||||||
host = "localhost"
|
host = "localhost"
|
||||||
port = 5432
|
port = 5432
|
||||||
username = "lysand"
|
username = "lysand"
|
||||||
password = "lysand"
|
password = "mycoolpassword"
|
||||||
database = "lysand"
|
database = "lysand"
|
||||||
|
|
||||||
[redis.queue]
|
[redis.queue]
|
||||||
|
|
@ -19,12 +13,13 @@ host = "localhost"
|
||||||
port = 6379
|
port = 6379
|
||||||
password = ""
|
password = ""
|
||||||
database = 0
|
database = 0
|
||||||
|
enabled = true
|
||||||
|
|
||||||
[redis.cache]
|
[redis.cache]
|
||||||
# Redis instance to be used as a timeline cache
|
# 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"
|
host = "localhost"
|
||||||
port = 6379
|
port = 40004
|
||||||
password = ""
|
password = ""
|
||||||
database = 1
|
database = 1
|
||||||
enabled = false
|
enabled = false
|
||||||
|
|
@ -32,13 +27,13 @@ enabled = false
|
||||||
[meilisearch]
|
[meilisearch]
|
||||||
# If Meilisearch is not configured, search will not be enabled
|
# If Meilisearch is not configured, search will not be enabled
|
||||||
host = "localhost"
|
host = "localhost"
|
||||||
port = 7700
|
port = 40007
|
||||||
api_key = "______________________________"
|
api_key = ""
|
||||||
enabled = false
|
enabled = true
|
||||||
|
|
||||||
[signups]
|
[signups]
|
||||||
# URL of your Terms of Service
|
# URL of your Terms of Service
|
||||||
tos_url = "https://my-site.com/tos"
|
tos_url = "https://social.lysand.org/tos"
|
||||||
# Whether to enable registrations or not
|
# Whether to enable registrations or not
|
||||||
registration = true
|
registration = true
|
||||||
rules = [
|
rules = [
|
||||||
|
|
@ -56,23 +51,20 @@ jwt_key = ""
|
||||||
# This is an example configuration
|
# This is an example configuration
|
||||||
# The provider MUST support OpenID Connect with .well-known discovery
|
# The provider MUST support OpenID Connect with .well-known discovery
|
||||||
# Most notably, GitHub does not support this
|
# Most notably, GitHub does not support this
|
||||||
# Set the allowed redirect URIs to (regex) <base_url>/oauth/callback/<name>?.* to allow Lysand to use it
|
|
||||||
# The last ?.* is important, as it allows for query parameters to be passed
|
|
||||||
[[oidc.providers]]
|
[[oidc.providers]]
|
||||||
# Test with custom Authentik instance
|
# name = "CPlusPatch ID"
|
||||||
name = "CPlusPatch ID"
|
# id = "cpluspatch-id"
|
||||||
id = "cpluspatch-id"
|
# url = "https://id.cpluspatch.com/application/o/lysand-testing/"
|
||||||
url = "https://id.cpluspatch.com/application/o/lysand-testing/"
|
# client_id = "XXXX"
|
||||||
client_id = "______________________________"
|
# client_secret = "XXXXX"
|
||||||
client_secret = "__________________________________"
|
# icon = "https://cpluspatch.com/images/icons/logo.svg"
|
||||||
icon = "https://cpluspatch.com/images/icons/logo.svg"
|
|
||||||
|
|
||||||
[http]
|
[http]
|
||||||
# The full URL Lysand will be reachable by (paths are not supported)
|
# The full URL Lysand will be reachable by (paths are not supported)
|
||||||
base_url = "https://lysand.social"
|
base_url = "https://lysand.localhost:9900"
|
||||||
# Address to bind to
|
# Address to bind to (0.0.0.0 is suggested for proxies)
|
||||||
bind = "0.0.0.0"
|
bind = "lysand.localhost"
|
||||||
bind_port = "8080"
|
bind_port = 9900
|
||||||
|
|
||||||
# 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 = []
|
||||||
|
|
@ -85,8 +77,8 @@ banned_user_agents = [
|
||||||
[http.tls]
|
[http.tls]
|
||||||
# If these values are set, Lysand will use these files for TLS
|
# If these values are set, Lysand will use these files for TLS
|
||||||
enabled = false
|
enabled = false
|
||||||
key = "config/privatekey.pem"
|
key = ""
|
||||||
cert = "config/certificate.pem"
|
cert = ""
|
||||||
passphrase = ""
|
passphrase = ""
|
||||||
ca = ""
|
ca = ""
|
||||||
|
|
||||||
|
|
@ -107,20 +99,25 @@ enabled = true
|
||||||
# The URL to reach the frontend at (should be on a local network)
|
# The URL to reach the frontend at (should be on a local network)
|
||||||
url = "http://localhost:3000"
|
url = "http://localhost:3000"
|
||||||
|
|
||||||
|
[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"
|
||||||
|
|
||||||
[frontend.glitch]
|
[frontend.glitch]
|
||||||
# Enable the Glitch frontend integration
|
# Enable the Glitch frontend integration
|
||||||
enabled = false
|
enabled = false
|
||||||
# Glitch assets folder
|
# Glitch assets folder
|
||||||
assets = "glitch"
|
assets = "glitch"
|
||||||
# Server the assets were ripped from (and any eventual CDNs)
|
# Server the assets were ripped from (and any eventual CDNs)
|
||||||
server = ["https://glitch.social", "https://static.glitch.social"]
|
server = ["https://tech.lgbt"]
|
||||||
|
|
||||||
[smtp]
|
[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 = "____________"
|
password = "password123"
|
||||||
tls = true
|
tls = true
|
||||||
# Disable all email functions (this will allow people to sign up without verifying
|
# Disable all email functions (this will allow people to sign up without verifying
|
||||||
# their email)
|
# their email)
|
||||||
|
|
@ -131,7 +128,7 @@ enabled = false
|
||||||
# If you need to change this value after setting up your instance, you must move all the files
|
# If you need to change this value after setting up your instance, you must move all the files
|
||||||
# from one backend to the other manually (the CLI will have an option to do this later)
|
# from one backend to the other manually (the CLI will have an option to do this later)
|
||||||
# TODO: Add CLI command to move files
|
# TODO: Add CLI command to move files
|
||||||
backend = "local"
|
backend = "s3"
|
||||||
# Whether to check the hash of media when uploading to avoid duplication
|
# Whether to check the hash of media when uploading to avoid duplication
|
||||||
deduplicate_media = true
|
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
|
||||||
|
|
@ -140,29 +137,19 @@ local_uploads_folder = "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
|
||||||
convert_images = false
|
convert_images = true
|
||||||
# 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"
|
||||||
|
|
||||||
[s3]
|
[s3]
|
||||||
# Can be left blank if you don't use the S3 media backend
|
# Can be left blank if you don't use the S3 media backend
|
||||||
endpoint = "myhostname.banana.com"
|
# endpoint = ""
|
||||||
access_key = "_____________"
|
# access_key = "XXXXX"
|
||||||
secret_access_key = "_________________"
|
# secret_access_key = "XXX"
|
||||||
region = ""
|
# region = ""
|
||||||
bucket_name = "lysand"
|
# bucket_name = "lysand"
|
||||||
public_url = "https://cdn.test.com"
|
# public_url = "https://cdn.example.com"
|
||||||
|
|
||||||
[email]
|
|
||||||
# Sends an email to moderators when a report is received
|
|
||||||
send_on_report = false
|
|
||||||
# Sends an email to moderators when a user is suspended
|
|
||||||
send_on_suspend = false
|
|
||||||
# Sends an email to moderators when a user is unsuspended
|
|
||||||
send_on_unsuspend = false
|
|
||||||
# Verify user emails when signing up (except via OIDC)
|
|
||||||
verify_email = false
|
|
||||||
|
|
||||||
[validation]
|
[validation]
|
||||||
# Checks user data
|
# Checks user data
|
||||||
|
|
@ -240,36 +227,8 @@ url_scheme_whitelist = [
|
||||||
# This can easily be spoofed, but if it is spoofed it will appear broken
|
# This can easily be spoofed, but if it is spoofed it will appear broken
|
||||||
# to normal clients until despoofed
|
# to normal clients until despoofed
|
||||||
enforce_mime_types = false
|
enforce_mime_types = false
|
||||||
allowed_mime_types = [
|
# Defaults to all valid MIME types
|
||||||
"image/jpeg",
|
# allowed_mime_types = []
|
||||||
"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",
|
|
||||||
]
|
|
||||||
|
|
||||||
[defaults]
|
[defaults]
|
||||||
# Default visibility for new notes
|
# Default visibility for new notes
|
||||||
|
|
@ -278,10 +237,10 @@ allowed_mime_types = [
|
||||||
visibility = "public"
|
visibility = "public"
|
||||||
# Default language for new notes (ISO code)
|
# Default language for new notes (ISO code)
|
||||||
language = "en"
|
language = "en"
|
||||||
# Default avatar, must be a valid URL or "" for a placeholder avatar
|
# Default avatar, must be a valid URL or left out for a placeholder avatar
|
||||||
avatar = ""
|
# avatar = ""
|
||||||
# Default header, must be a valid URL or "" for none
|
# Default header, must be a valid URL or left out for none
|
||||||
header = ""
|
# header = ""
|
||||||
# A style name from https://www.dicebear.com/styles
|
# A style name from https://www.dicebear.com/styles
|
||||||
placeholder_style = "thumbs"
|
placeholder_style = "thumbs"
|
||||||
|
|
||||||
|
|
@ -310,19 +269,20 @@ avatars = []
|
||||||
|
|
||||||
[instance]
|
[instance]
|
||||||
name = "Lysand"
|
name = "Lysand"
|
||||||
description = "A test instance of Lysand"
|
description = "A Lysand instance"
|
||||||
# Path to a file containing a longer description of your instance
|
# Path to a file containing a longer description of your instance
|
||||||
# This will be parsed as Markdown
|
# This will be parsed as Markdown
|
||||||
extended_description_path = ""
|
# extended_description_path = "config/description.md"
|
||||||
# URL to your instance logo (jpg files should be renamed to jpeg)
|
# URL to your instance logo
|
||||||
logo = ""
|
# logo = ""
|
||||||
# URL to your instance banner (jpg files should be renamed to jpeg)
|
# URL to your instance banner
|
||||||
banner = ""
|
# banner = ""
|
||||||
|
|
||||||
|
|
||||||
[filters]
|
[filters]
|
||||||
# Regex filters for federated and local data
|
# Regex filters for federated and local data
|
||||||
# Does not apply retroactively (try the CLI for that)
|
# Drops data matching the filters
|
||||||
|
# Does not apply retroactively to existing data
|
||||||
|
|
||||||
# Note contents
|
# Note contents
|
||||||
note_content = [
|
note_content = [
|
||||||
|
|
@ -341,7 +301,7 @@ log_requests = false
|
||||||
# Log request and their contents (warning: this is a lot of data)
|
# Log request and their contents (warning: this is a lot of data)
|
||||||
log_requests_verbose = false
|
log_requests_verbose = false
|
||||||
# Available levels: debug, info, warning, error, critical
|
# Available levels: debug, info, warning, error, critical
|
||||||
log_level = "info"
|
log_level = "debug"
|
||||||
# For GDPR compliance, you can disable logging of IPs
|
# For GDPR compliance, you can disable logging of IPs
|
||||||
log_ip = false
|
log_ip = false
|
||||||
|
|
||||||
|
|
@ -362,5 +322,5 @@ max_coeff = 1.0
|
||||||
[custom_ratelimits]
|
[custom_ratelimits]
|
||||||
# Add in any API route in this style here
|
# Add in any API route in this style here
|
||||||
# Applies before the global ratelimit changes
|
# Applies before the global ratelimit changes
|
||||||
"/api/v1/accounts/:id/block" = { duration = 30, max = 60 }
|
# "/api/v1/accounts/:id/block" = { duration = 30, max = 60 }
|
||||||
"/api/v1/timelines/public" = { duration = 60, max = 200 }
|
# "/api/v1/timelines/public" = { duration = 60, max = 200 }
|
||||||
|
|
|
||||||
3
index.ts
3
index.ts
|
|
@ -1,6 +1,7 @@
|
||||||
import { dualLogger } from "@loggers";
|
import { dualLogger } from "@loggers";
|
||||||
import { connectMeili } from "@meilisearch";
|
import { connectMeili } from "@meilisearch";
|
||||||
import { errorResponse, response } from "@response";
|
import { errorResponse, response } from "@response";
|
||||||
|
import chalk from "chalk";
|
||||||
import { config } from "config-manager";
|
import { config } from "config-manager";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { LogLevel, LogManager, type MultiLogManager } from "log-manager";
|
import { LogLevel, LogManager, type MultiLogManager } from "log-manager";
|
||||||
|
|
@ -70,7 +71,7 @@ if (isEntry) {
|
||||||
await dualServerLogger.log(
|
await dualServerLogger.log(
|
||||||
LogLevel.CRITICAL,
|
LogLevel.CRITICAL,
|
||||||
"Server",
|
"Server",
|
||||||
`${privateKey};${publicKey}`,
|
chalk.gray(`${privateKey};${publicKey}`),
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,6 @@
|
||||||
"blurhash": "^2.0.5",
|
"blurhash": "^2.0.5",
|
||||||
"bullmq": "^5.7.1",
|
"bullmq": "^5.7.1",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"cli-parser": "workspace:*",
|
|
||||||
"cli-progress": "^3.12.0",
|
"cli-progress": "^3.12.0",
|
||||||
"cli-table": "^0.3.11",
|
"cli-table": "^0.3.11",
|
||||||
"config-manager": "workspace:*",
|
"config-manager": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
export interface CliParameter {
|
|
||||||
name: string;
|
|
||||||
/* Like -v for --version */
|
|
||||||
shortName?: string;
|
|
||||||
/**
|
|
||||||
* If not positioned, the argument will need to be called with --name value instead of just value
|
|
||||||
* @default true
|
|
||||||
*/
|
|
||||||
positioned?: boolean;
|
|
||||||
/* Whether the argument needs a value (requires positioned to be false) */
|
|
||||||
needsValue?: boolean;
|
|
||||||
optional?: true;
|
|
||||||
type: CliParameterType;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum CliParameterType {
|
|
||||||
STRING = "string",
|
|
||||||
NUMBER = "number",
|
|
||||||
BOOLEAN = "boolean",
|
|
||||||
ARRAY = "array",
|
|
||||||
EMPTY = "empty",
|
|
||||||
}
|
|
||||||
|
|
@ -1,450 +0,0 @@
|
||||||
import chalk from "chalk";
|
|
||||||
import { type CliParameter, CliParameterType } from "./cli-builder.type";
|
|
||||||
|
|
||||||
export function startsWithArray(fullArray: string[], startArray: string[]) {
|
|
||||||
if (startArray.length > fullArray.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return fullArray
|
|
||||||
.slice(0, startArray.length)
|
|
||||||
.every((value, index) => value === startArray[index]);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TreeType {
|
|
||||||
[key: string]: CliCommand | TreeType;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builder for a CLI
|
|
||||||
* @param commands Array of commands to register
|
|
||||||
*/
|
|
||||||
export class CliBuilder {
|
|
||||||
constructor(public commands: CliCommand[] = []) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add command to the CLI
|
|
||||||
* @throws Error if command already exists
|
|
||||||
* @param command Command to add
|
|
||||||
*/
|
|
||||||
registerCommand(command: CliCommand) {
|
|
||||||
if (this.checkIfCommandAlreadyExists(command)) {
|
|
||||||
throw new Error(
|
|
||||||
`Command category '${command.categories.join(
|
|
||||||
" ",
|
|
||||||
)}' already exists`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
this.commands.push(command);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add multiple commands to the CLI
|
|
||||||
* @throws Error if command already exists
|
|
||||||
* @param commands Commands to add
|
|
||||||
*/
|
|
||||||
registerCommands(commands: CliCommand[]) {
|
|
||||||
const existingCommand = commands.find((command) =>
|
|
||||||
this.checkIfCommandAlreadyExists(command),
|
|
||||||
);
|
|
||||||
if (existingCommand) {
|
|
||||||
throw new Error(
|
|
||||||
`Command category '${existingCommand.categories.join(
|
|
||||||
" ",
|
|
||||||
)}' already exists`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
this.commands.push(...commands);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove command from the CLI
|
|
||||||
* @param command Command to remove
|
|
||||||
*/
|
|
||||||
deregisterCommand(command: CliCommand) {
|
|
||||||
this.commands = this.commands.filter(
|
|
||||||
(registeredCommand) => registeredCommand !== command,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove multiple commands from the CLI
|
|
||||||
* @param commands Commands to remove
|
|
||||||
*/
|
|
||||||
deregisterCommands(commands: CliCommand[]) {
|
|
||||||
this.commands = this.commands.filter(
|
|
||||||
(registeredCommand) => !commands.includes(registeredCommand),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
checkIfCommandAlreadyExists(command: CliCommand) {
|
|
||||||
return this.commands.some(
|
|
||||||
(registeredCommand) =>
|
|
||||||
registeredCommand.categories.length ===
|
|
||||||
command.categories.length &&
|
|
||||||
registeredCommand.categories.every(
|
|
||||||
(category, index) => category === command.categories[index],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get relevant args for the command (without executable or runtime)
|
|
||||||
* @param args Arguments passed to the CLI
|
|
||||||
*/
|
|
||||||
private getRelevantArgs(args: string[]) {
|
|
||||||
if (args[0].startsWith("./")) {
|
|
||||||
// Formatted like ./cli.ts [command]
|
|
||||||
return args.slice(1);
|
|
||||||
}
|
|
||||||
if (args[0].includes("bun")) {
|
|
||||||
// Formatted like bun cli.ts [command]
|
|
||||||
return args.slice(2);
|
|
||||||
}
|
|
||||||
return args;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Turn raw system args into a CLI command and run it
|
|
||||||
* @param args Args directly from process.argv
|
|
||||||
*/
|
|
||||||
async processArgs(args: string[]) {
|
|
||||||
const revelantArgs = this.getRelevantArgs(args);
|
|
||||||
|
|
||||||
// Handle "-h", "--help" and "help" commands as special cases
|
|
||||||
if (revelantArgs.length === 1) {
|
|
||||||
if (["-h", "--help", "help"].includes(revelantArgs[0])) {
|
|
||||||
this.displayHelp();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find revelant command
|
|
||||||
// Search for a command with as many categories matching args as possible
|
|
||||||
const matchingCommands = this.commands.filter((command) =>
|
|
||||||
startsWithArray(revelantArgs, command.categories),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (matchingCommands.length === 0) {
|
|
||||||
console.log(
|
|
||||||
`Invalid command "${revelantArgs.join(
|
|
||||||
" ",
|
|
||||||
)}". Please use the ${chalk.bold(
|
|
||||||
"help",
|
|
||||||
)} command to see a list of commands`,
|
|
||||||
);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get command with largest category size
|
|
||||||
const command = matchingCommands.reduce((prev, current) =>
|
|
||||||
prev.categories.length > current.categories.length ? prev : current,
|
|
||||||
);
|
|
||||||
|
|
||||||
const argsWithoutCategories = revelantArgs.slice(
|
|
||||||
command.categories.length,
|
|
||||||
);
|
|
||||||
|
|
||||||
return await command.run(argsWithoutCategories);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursively urns the commands into a tree where subcategories mark each sub-branch
|
|
||||||
* @example
|
|
||||||
* ```txt
|
|
||||||
* user verify
|
|
||||||
* user delete
|
|
||||||
* user new admin
|
|
||||||
* user new
|
|
||||||
* ->
|
|
||||||
* user
|
|
||||||
* verify
|
|
||||||
* delete
|
|
||||||
* new
|
|
||||||
* admin
|
|
||||||
* ""
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
getCommandTree(commands: CliCommand[]): TreeType {
|
|
||||||
const tree: TreeType = {};
|
|
||||||
|
|
||||||
for (const command of commands) {
|
|
||||||
let currentLevel = tree; // Start at the root
|
|
||||||
|
|
||||||
// Split the command into parts and iterate over them
|
|
||||||
for (const part of command.categories) {
|
|
||||||
// If this part doesn't exist in the current level of the tree, add it (__proto__ check to prevent prototype pollution)
|
|
||||||
if (!currentLevel[part] && part !== "__proto__") {
|
|
||||||
// If this is the last part of the command, add the command itself
|
|
||||||
if (
|
|
||||||
part ===
|
|
||||||
command.categories[command.categories.length - 1]
|
|
||||||
) {
|
|
||||||
currentLevel[part] = command;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
currentLevel[part] = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move down to the next level of the tree
|
|
||||||
currentLevel = currentLevel[part] as TreeType;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tree;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display help for every command in a tree manner
|
|
||||||
*/
|
|
||||||
displayHelp() {
|
|
||||||
/*
|
|
||||||
user
|
|
||||||
set
|
|
||||||
admin: List of admin commands
|
|
||||||
--prod: Whether to run in production
|
|
||||||
--dev: Whether to run in development
|
|
||||||
username: Username of the admin
|
|
||||||
Example: user set admin --prod --dev --username John
|
|
||||||
delete
|
|
||||||
...
|
|
||||||
verify
|
|
||||||
...
|
|
||||||
*/
|
|
||||||
const tree = this.getCommandTree(this.commands);
|
|
||||||
let writeBuffer = "";
|
|
||||||
|
|
||||||
const displayTree = (tree: TreeType, depth = 0) => {
|
|
||||||
for (const [key, value] of Object.entries(tree)) {
|
|
||||||
if (value instanceof CliCommand) {
|
|
||||||
writeBuffer += `${" ".repeat(depth)}${chalk.blue(
|
|
||||||
key,
|
|
||||||
)}|${chalk.underline(value.description)}\n`;
|
|
||||||
const positionedArgs = value.argTypes.filter(
|
|
||||||
(arg) => arg.positioned ?? true,
|
|
||||||
);
|
|
||||||
const unpositionedArgs = value.argTypes.filter(
|
|
||||||
(arg) => !(arg.positioned ?? true),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const arg of positionedArgs) {
|
|
||||||
writeBuffer += `${" ".repeat(
|
|
||||||
depth + 1,
|
|
||||||
)}${chalk.green(arg.name)}|${
|
|
||||||
arg.description ?? "(no description)"
|
|
||||||
} ${arg.optional ? chalk.gray("(optional)") : ""}\n`;
|
|
||||||
}
|
|
||||||
for (const arg of unpositionedArgs) {
|
|
||||||
writeBuffer += `${" ".repeat(
|
|
||||||
depth + 1,
|
|
||||||
)}${chalk.yellow(`--${arg.name}`)}${
|
|
||||||
arg.shortName
|
|
||||||
? `, ${chalk.yellow(`-${arg.shortName}`)}`
|
|
||||||
: ""
|
|
||||||
}|${arg.description ?? "(no description)"} ${
|
|
||||||
arg.optional ? chalk.gray("(optional)") : ""
|
|
||||||
}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value.example) {
|
|
||||||
writeBuffer += `${" ".repeat(depth + 1)}${chalk.bold(
|
|
||||||
"Example:",
|
|
||||||
)} ${chalk.bgGray(value.example)}\n`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
writeBuffer += `${" ".repeat(depth)}${chalk.blue(
|
|
||||||
key,
|
|
||||||
)}\n`;
|
|
||||||
displayTree(value, depth + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
displayTree(tree);
|
|
||||||
|
|
||||||
// Replace all "|" with enough dots so that the text on the left + the dots = the same length
|
|
||||||
const optimal_length = Number(
|
|
||||||
writeBuffer
|
|
||||||
.split("\n")
|
|
||||||
// @ts-expect-error I don't know how this works and I don't want to know
|
|
||||||
.reduce((prev, current) => {
|
|
||||||
// If previousValue is empty
|
|
||||||
if (!prev)
|
|
||||||
return current.includes("|")
|
|
||||||
? current.split("|")[0].length
|
|
||||||
: 0;
|
|
||||||
if (!current.includes("|")) return prev;
|
|
||||||
const [left] = current.split("|");
|
|
||||||
// Strip ANSI color codes or they mess up the length
|
|
||||||
return Math.max(Number(prev), Bun.stringWidth(left));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const line of writeBuffer.split("\n")) {
|
|
||||||
const [left, right] = line.split("|");
|
|
||||||
if (!right) {
|
|
||||||
console.log(left);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Strip ANSI color codes or they mess up the length
|
|
||||||
const dots = ".".repeat(optimal_length + 5 - Bun.stringWidth(left));
|
|
||||||
console.log(`${left}${dots}${right}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExecuteFunction<T> = (
|
|
||||||
instance: CliCommand,
|
|
||||||
args: Partial<T>,
|
|
||||||
) => Promise<number> | Promise<void> | number | void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A command that can be executed from the command line
|
|
||||||
* @param categories Example: `["user", "create"]` for the command `./cli user create --name John`
|
|
||||||
*/
|
|
||||||
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
|
||||||
export class CliCommand<T = any> {
|
|
||||||
constructor(
|
|
||||||
public categories: string[],
|
|
||||||
public argTypes: CliParameter[],
|
|
||||||
private execute: ExecuteFunction<T>,
|
|
||||||
public description?: string,
|
|
||||||
public example?: string,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display help message for the command
|
|
||||||
* formatted with Chalk and with emojis
|
|
||||||
*/
|
|
||||||
displayHelp() {
|
|
||||||
const positionedArgs = this.argTypes.filter(
|
|
||||||
(arg) => arg.positioned ?? true,
|
|
||||||
);
|
|
||||||
const unpositionedArgs = this.argTypes.filter(
|
|
||||||
(arg) => !(arg.positioned ?? true),
|
|
||||||
);
|
|
||||||
const helpMessage = `
|
|
||||||
${chalk.green("📚 Command:")} ${chalk.yellow(this.categories.join(" "))}
|
|
||||||
${this.description ? `${chalk.cyan(this.description)}\n` : ""}
|
|
||||||
${chalk.magenta("🔧 Arguments:")}
|
|
||||||
${positionedArgs
|
|
||||||
.map(
|
|
||||||
(arg) =>
|
|
||||||
`${chalk.bold(arg.name)}: ${chalk.blue(
|
|
||||||
arg.description ?? "(no description)",
|
|
||||||
)} ${arg.optional ? chalk.gray("(optional)") : ""}`,
|
|
||||||
)
|
|
||||||
.join("\n")}
|
|
||||||
${unpositionedArgs
|
|
||||||
.map(
|
|
||||||
(arg) =>
|
|
||||||
`--${chalk.bold(arg.name)}${
|
|
||||||
arg.shortName ? `, -${arg.shortName}` : ""
|
|
||||||
}: ${chalk.blue(arg.description ?? "(no description)")} ${
|
|
||||||
arg.optional ? chalk.gray("(optional)") : ""
|
|
||||||
}`,
|
|
||||||
)
|
|
||||||
.join("\n")}${
|
|
||||||
this.example
|
|
||||||
? `\n${chalk.magenta("🚀 Example:")}\n${chalk.bgGray(this.example)}`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
console.log(helpMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses string array arguments into a full JavaScript object
|
|
||||||
* @param argsWithoutCategories
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
private parseArgs(
|
|
||||||
argsWithoutCategories: string[],
|
|
||||||
): Record<string, string | number | boolean | string[]> {
|
|
||||||
const parsedArgs: Record<string, string | number | boolean | string[]> =
|
|
||||||
{};
|
|
||||||
let currentParameter: CliParameter | null = null;
|
|
||||||
|
|
||||||
for (let i = 0; i < argsWithoutCategories.length; i++) {
|
|
||||||
const arg = argsWithoutCategories[i];
|
|
||||||
|
|
||||||
if (arg.startsWith("--")) {
|
|
||||||
const argName = arg.substring(2);
|
|
||||||
currentParameter =
|
|
||||||
this.argTypes.find((argType) => argType.name === argName) ||
|
|
||||||
null;
|
|
||||||
if (currentParameter && !currentParameter.needsValue) {
|
|
||||||
parsedArgs[argName] = true;
|
|
||||||
currentParameter = null;
|
|
||||||
} else if (currentParameter?.needsValue) {
|
|
||||||
parsedArgs[argName] = this.castArgValue(
|
|
||||||
argsWithoutCategories[i + 1],
|
|
||||||
currentParameter.type,
|
|
||||||
);
|
|
||||||
i++;
|
|
||||||
currentParameter = null;
|
|
||||||
}
|
|
||||||
} else if (arg.startsWith("-")) {
|
|
||||||
const shortName = arg.substring(1);
|
|
||||||
const argType = this.argTypes.find(
|
|
||||||
(argType) => argType.shortName === shortName,
|
|
||||||
);
|
|
||||||
if (argType && !argType.needsValue) {
|
|
||||||
parsedArgs[argType.name] = true;
|
|
||||||
} else if (argType?.needsValue) {
|
|
||||||
parsedArgs[argType.name] = this.castArgValue(
|
|
||||||
argsWithoutCategories[i + 1],
|
|
||||||
argType.type,
|
|
||||||
);
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
} else if (currentParameter) {
|
|
||||||
parsedArgs[currentParameter.name] = this.castArgValue(
|
|
||||||
arg,
|
|
||||||
currentParameter.type,
|
|
||||||
);
|
|
||||||
currentParameter = null;
|
|
||||||
} else {
|
|
||||||
const positionedArgType = this.argTypes.find(
|
|
||||||
(argType) =>
|
|
||||||
argType.positioned && !parsedArgs[argType.name],
|
|
||||||
);
|
|
||||||
if (positionedArgType) {
|
|
||||||
parsedArgs[positionedArgType.name] = this.castArgValue(
|
|
||||||
arg,
|
|
||||||
positionedArgType.type,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsedArgs;
|
|
||||||
}
|
|
||||||
|
|
||||||
private castArgValue(
|
|
||||||
value: string,
|
|
||||||
type: CliParameter["type"],
|
|
||||||
): string | number | boolean | string[] {
|
|
||||||
switch (type) {
|
|
||||||
case CliParameterType.STRING:
|
|
||||||
return value;
|
|
||||||
case CliParameterType.NUMBER:
|
|
||||||
return Number(value);
|
|
||||||
case CliParameterType.BOOLEAN:
|
|
||||||
return value === "true";
|
|
||||||
case CliParameterType.ARRAY:
|
|
||||||
return value.split(",");
|
|
||||||
default:
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs the execute function with the parsed parameters as an argument
|
|
||||||
*/
|
|
||||||
async run(argsWithoutCategories: string[]) {
|
|
||||||
const args = this.parseArgs(argsWithoutCategories);
|
|
||||||
return await this.execute(this, args as T);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
{
|
|
||||||
"name": "cli-parser",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"main": "index.ts",
|
|
||||||
"dependencies": { "chalk": "^5.3.0", "strip-ansi": "^7.1.0" }
|
|
||||||
}
|
|
||||||
|
|
@ -1,488 +0,0 @@
|
||||||
import { beforeEach, describe, expect, it, jest, spyOn } from "bun:test";
|
|
||||||
import stripAnsi from "strip-ansi";
|
|
||||||
// FILEPATH: /home/jessew/Dev/lysand/packages/cli-parser/index.test.ts
|
|
||||||
import { CliBuilder, CliCommand, startsWithArray } from "..";
|
|
||||||
import { CliParameterType } from "../cli-builder.type";
|
|
||||||
|
|
||||||
describe("startsWithArray", () => {
|
|
||||||
it("should return true when fullArray starts with startArray", () => {
|
|
||||||
const fullArray = ["a", "b", "c", "d", "e"];
|
|
||||||
const startArray = ["a", "b", "c"];
|
|
||||||
expect(startsWithArray(fullArray, startArray)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false when fullArray does not start with startArray", () => {
|
|
||||||
const fullArray = ["a", "b", "c", "d", "e"];
|
|
||||||
const startArray = ["b", "c", "d"];
|
|
||||||
expect(startsWithArray(fullArray, startArray)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return true when startArray is empty", () => {
|
|
||||||
const fullArray = ["a", "b", "c", "d", "e"];
|
|
||||||
const startArray: string[] = [];
|
|
||||||
expect(startsWithArray(fullArray, startArray)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false when fullArray is shorter than startArray", () => {
|
|
||||||
const fullArray = ["a", "b", "c"];
|
|
||||||
const startArray = ["a", "b", "c", "d", "e"];
|
|
||||||
expect(startsWithArray(fullArray, startArray)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("CliCommand", () => {
|
|
||||||
let cliCommand: CliCommand;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
cliCommand = new CliCommand(
|
|
||||||
["category1", "category2"],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
name: "arg1",
|
|
||||||
type: CliParameterType.STRING,
|
|
||||||
needsValue: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "arg2",
|
|
||||||
shortName: "a",
|
|
||||||
type: CliParameterType.NUMBER,
|
|
||||||
needsValue: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "arg3",
|
|
||||||
type: CliParameterType.BOOLEAN,
|
|
||||||
needsValue: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "arg4",
|
|
||||||
type: CliParameterType.ARRAY,
|
|
||||||
needsValue: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
() => {
|
|
||||||
// Do nothing
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should parse string arguments correctly", () => {
|
|
||||||
// @ts-expect-error Testing private method
|
|
||||||
const args = cliCommand.parseArgs([
|
|
||||||
"--arg1",
|
|
||||||
"value1",
|
|
||||||
"--arg2",
|
|
||||||
"42",
|
|
||||||
"--arg3",
|
|
||||||
"--arg4",
|
|
||||||
"value1,value2",
|
|
||||||
]);
|
|
||||||
expect(args).toEqual({
|
|
||||||
arg1: "value1",
|
|
||||||
arg2: 42,
|
|
||||||
arg3: true,
|
|
||||||
arg4: ["value1", "value2"],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should parse short names for arguments too", () => {
|
|
||||||
// @ts-expect-error Testing private method
|
|
||||||
const args = cliCommand.parseArgs([
|
|
||||||
"--arg1",
|
|
||||||
"value1",
|
|
||||||
"-a",
|
|
||||||
"42",
|
|
||||||
"--arg3",
|
|
||||||
"--arg4",
|
|
||||||
"value1,value2",
|
|
||||||
]);
|
|
||||||
expect(args).toEqual({
|
|
||||||
arg1: "value1",
|
|
||||||
arg2: 42,
|
|
||||||
arg3: true,
|
|
||||||
arg4: ["value1", "value2"],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should cast argument values correctly", () => {
|
|
||||||
// @ts-expect-error Testing private method
|
|
||||||
expect(cliCommand.castArgValue("42", CliParameterType.NUMBER)).toBe(42);
|
|
||||||
// @ts-expect-error Testing private method
|
|
||||||
expect(cliCommand.castArgValue("true", CliParameterType.BOOLEAN)).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
// @ts-expect-error Testing private method
|
|
||||||
cliCommand.castArgValue("value1,value2", CliParameterType.ARRAY),
|
|
||||||
).toEqual(["value1", "value2"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should run the execute function with the parsed parameters", async () => {
|
|
||||||
const mockExecute = jest.fn();
|
|
||||||
cliCommand = new CliCommand(
|
|
||||||
["category1", "category2"],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
name: "arg1",
|
|
||||||
type: CliParameterType.STRING,
|
|
||||||
needsValue: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "arg2",
|
|
||||||
type: CliParameterType.NUMBER,
|
|
||||||
needsValue: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "arg3",
|
|
||||||
type: CliParameterType.BOOLEAN,
|
|
||||||
needsValue: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "arg4",
|
|
||||||
type: CliParameterType.ARRAY,
|
|
||||||
needsValue: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
mockExecute,
|
|
||||||
);
|
|
||||||
|
|
||||||
await cliCommand.run([
|
|
||||||
"--arg1",
|
|
||||||
"value1",
|
|
||||||
"--arg2",
|
|
||||||
"42",
|
|
||||||
"--arg3",
|
|
||||||
"--arg4",
|
|
||||||
"value1,value2",
|
|
||||||
]);
|
|
||||||
expect(mockExecute).toHaveBeenCalledWith(cliCommand, {
|
|
||||||
arg1: "value1",
|
|
||||||
arg2: 42,
|
|
||||||
arg3: true,
|
|
||||||
arg4: ["value1", "value2"],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should work with a mix of positioned and non-positioned arguments", async () => {
|
|
||||||
const mockExecute = jest.fn();
|
|
||||||
cliCommand = new CliCommand(
|
|
||||||
["category1", "category2"],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
name: "arg1",
|
|
||||||
type: CliParameterType.STRING,
|
|
||||||
needsValue: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "arg2",
|
|
||||||
type: CliParameterType.NUMBER,
|
|
||||||
needsValue: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "arg3",
|
|
||||||
type: CliParameterType.BOOLEAN,
|
|
||||||
needsValue: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "arg4",
|
|
||||||
type: CliParameterType.ARRAY,
|
|
||||||
needsValue: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "arg5",
|
|
||||||
type: CliParameterType.STRING,
|
|
||||||
needsValue: true,
|
|
||||||
positioned: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
mockExecute,
|
|
||||||
);
|
|
||||||
|
|
||||||
await cliCommand.run([
|
|
||||||
"--arg1",
|
|
||||||
"value1",
|
|
||||||
"--arg2",
|
|
||||||
"42",
|
|
||||||
"--arg3",
|
|
||||||
"--arg4",
|
|
||||||
"value1,value2",
|
|
||||||
"value5",
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(mockExecute).toHaveBeenCalledWith(cliCommand, {
|
|
||||||
arg1: "value1",
|
|
||||||
arg2: 42,
|
|
||||||
arg3: true,
|
|
||||||
arg4: ["value1", "value2"],
|
|
||||||
arg5: "value5",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should display help message correctly", () => {
|
|
||||||
const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {
|
|
||||||
// Do nothing
|
|
||||||
});
|
|
||||||
|
|
||||||
cliCommand = new CliCommand(
|
|
||||||
["category1", "category2"],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
name: "arg1",
|
|
||||||
type: CliParameterType.STRING,
|
|
||||||
needsValue: true,
|
|
||||||
description: "Argument 1",
|
|
||||||
optional: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "arg2",
|
|
||||||
type: CliParameterType.NUMBER,
|
|
||||||
needsValue: true,
|
|
||||||
description: "Argument 2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "arg3",
|
|
||||||
type: CliParameterType.BOOLEAN,
|
|
||||||
needsValue: false,
|
|
||||||
description: "Argument 3",
|
|
||||||
optional: true,
|
|
||||||
positioned: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "arg4",
|
|
||||||
type: CliParameterType.ARRAY,
|
|
||||||
needsValue: true,
|
|
||||||
description: "Argument 4",
|
|
||||||
positioned: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
() => {
|
|
||||||
// Do nothing
|
|
||||||
},
|
|
||||||
"This is a test command",
|
|
||||||
"category1 category2 --arg1 value1 --arg2 42 arg3 --arg4 value1,value2",
|
|
||||||
);
|
|
||||||
|
|
||||||
cliCommand.displayHelp();
|
|
||||||
|
|
||||||
const loggedString = consoleLogSpy.mock.calls.map((call) =>
|
|
||||||
stripAnsi(call[0]),
|
|
||||||
)[0];
|
|
||||||
|
|
||||||
consoleLogSpy.mockRestore();
|
|
||||||
|
|
||||||
expect(loggedString).toContain("📚 Command: category1 category2");
|
|
||||||
expect(loggedString).toContain("🔧 Arguments:");
|
|
||||||
expect(loggedString).toContain("arg1: Argument 1 (optional)");
|
|
||||||
expect(loggedString).toContain("arg2: Argument 2");
|
|
||||||
expect(loggedString).toContain("--arg3: Argument 3 (optional)");
|
|
||||||
expect(loggedString).toContain("--arg4: Argument 4");
|
|
||||||
expect(loggedString).toContain("🚀 Example:");
|
|
||||||
expect(loggedString).toContain(
|
|
||||||
"category1 category2 --arg1 value1 --arg2 42 arg3 --arg4 value1,value2",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("CliBuilder", () => {
|
|
||||||
let cliBuilder: CliBuilder;
|
|
||||||
let mockCommand1: CliCommand;
|
|
||||||
let mockCommand2: CliCommand;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockCommand1 = new CliCommand(["category1"], [], jest.fn());
|
|
||||||
mockCommand2 = new CliCommand(["category2"], [], jest.fn());
|
|
||||||
cliBuilder = new CliBuilder([mockCommand1]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should register a command correctly", () => {
|
|
||||||
cliBuilder.registerCommand(mockCommand2);
|
|
||||||
expect(cliBuilder.commands).toContain(mockCommand2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should register multiple commands correctly", () => {
|
|
||||||
const mockCommand3 = new CliCommand(["category3"], [], jest.fn());
|
|
||||||
cliBuilder.registerCommands([mockCommand2, mockCommand3]);
|
|
||||||
expect(cliBuilder.commands).toContain(mockCommand2);
|
|
||||||
expect(cliBuilder.commands).toContain(mockCommand3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should error when adding duplicates", () => {
|
|
||||||
expect(() => {
|
|
||||||
cliBuilder.registerCommand(mockCommand1);
|
|
||||||
}).toThrow();
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
cliBuilder.registerCommands([mockCommand1]);
|
|
||||||
}).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should deregister a command correctly", () => {
|
|
||||||
cliBuilder.deregisterCommand(mockCommand1);
|
|
||||||
expect(cliBuilder.commands).not.toContain(mockCommand1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should deregister multiple commands correctly", () => {
|
|
||||||
cliBuilder.registerCommand(mockCommand2);
|
|
||||||
cliBuilder.deregisterCommands([mockCommand1, mockCommand2]);
|
|
||||||
expect(cliBuilder.commands).not.toContain(mockCommand1);
|
|
||||||
expect(cliBuilder.commands).not.toContain(mockCommand2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should process args correctly", async () => {
|
|
||||||
const mockExecute = jest.fn();
|
|
||||||
const mockCommand = new CliCommand(
|
|
||||||
["category1", "sub1"],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
name: "arg1",
|
|
||||||
type: CliParameterType.STRING,
|
|
||||||
needsValue: true,
|
|
||||||
positioned: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
mockExecute,
|
|
||||||
);
|
|
||||||
cliBuilder.registerCommand(mockCommand);
|
|
||||||
await cliBuilder.processArgs([
|
|
||||||
"./cli.ts",
|
|
||||||
"category1",
|
|
||||||
"sub1",
|
|
||||||
"--arg1",
|
|
||||||
"value1",
|
|
||||||
]);
|
|
||||||
expect(mockExecute).toHaveBeenCalledWith(expect.anything(), {
|
|
||||||
arg1: "value1",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("should build command tree", () => {
|
|
||||||
let cliBuilder: CliBuilder;
|
|
||||||
let mockCommand1: CliCommand;
|
|
||||||
let mockCommand2: CliCommand;
|
|
||||||
let mockCommand3: CliCommand;
|
|
||||||
let mockCommand4: CliCommand;
|
|
||||||
let mockCommand5: CliCommand;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockCommand1 = new CliCommand(["user", "verify"], [], jest.fn());
|
|
||||||
mockCommand2 = new CliCommand(["user", "delete"], [], jest.fn());
|
|
||||||
mockCommand3 = new CliCommand(
|
|
||||||
["user", "new", "admin"],
|
|
||||||
[],
|
|
||||||
jest.fn(),
|
|
||||||
);
|
|
||||||
mockCommand4 = new CliCommand(["user", "new"], [], jest.fn());
|
|
||||||
mockCommand5 = new CliCommand(["admin", "delete"], [], jest.fn());
|
|
||||||
cliBuilder = new CliBuilder([
|
|
||||||
mockCommand1,
|
|
||||||
mockCommand2,
|
|
||||||
mockCommand3,
|
|
||||||
mockCommand4,
|
|
||||||
mockCommand5,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should build the command tree correctly", () => {
|
|
||||||
const tree = cliBuilder.getCommandTree(cliBuilder.commands);
|
|
||||||
expect(tree).toEqual({
|
|
||||||
user: {
|
|
||||||
verify: mockCommand1,
|
|
||||||
delete: mockCommand2,
|
|
||||||
new: {
|
|
||||||
admin: mockCommand3,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
admin: {
|
|
||||||
delete: mockCommand5,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should build the command tree correctly when there are no commands", () => {
|
|
||||||
cliBuilder = new CliBuilder([]);
|
|
||||||
const tree = cliBuilder.getCommandTree(cliBuilder.commands);
|
|
||||||
expect(tree).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should build the command tree correctly when there is only one command", () => {
|
|
||||||
cliBuilder = new CliBuilder([mockCommand1]);
|
|
||||||
const tree = cliBuilder.getCommandTree(cliBuilder.commands);
|
|
||||||
expect(tree).toEqual({
|
|
||||||
user: {
|
|
||||||
verify: mockCommand1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show help menu", () => {
|
|
||||||
const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {
|
|
||||||
// Do nothing
|
|
||||||
});
|
|
||||||
|
|
||||||
const cliBuilder = new CliBuilder();
|
|
||||||
|
|
||||||
const cliCommand = new CliCommand(
|
|
||||||
["category1", "category2"],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
name: "name",
|
|
||||||
type: CliParameterType.STRING,
|
|
||||||
needsValue: true,
|
|
||||||
description: "Name of new item",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "delete-previous",
|
|
||||||
type: CliParameterType.NUMBER,
|
|
||||||
needsValue: false,
|
|
||||||
positioned: false,
|
|
||||||
optional: true,
|
|
||||||
description: "Also delete the previous item",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "arg3",
|
|
||||||
type: CliParameterType.BOOLEAN,
|
|
||||||
needsValue: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "arg4",
|
|
||||||
type: CliParameterType.ARRAY,
|
|
||||||
needsValue: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
() => {
|
|
||||||
// Do nothing
|
|
||||||
},
|
|
||||||
"I love sussy sauces",
|
|
||||||
"emoji add --url https://site.com/image.png",
|
|
||||||
);
|
|
||||||
|
|
||||||
cliBuilder.registerCommand(cliCommand);
|
|
||||||
cliBuilder.displayHelp();
|
|
||||||
|
|
||||||
const loggedString = consoleLogSpy.mock.calls
|
|
||||||
.map((call) => stripAnsi(call[0]))
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
consoleLogSpy.mockRestore();
|
|
||||||
|
|
||||||
expect(loggedString).toContain("category1");
|
|
||||||
expect(loggedString).toContain(
|
|
||||||
" category2.................I love sussy sauces",
|
|
||||||
);
|
|
||||||
expect(loggedString).toContain(
|
|
||||||
" name..................Name of new item",
|
|
||||||
);
|
|
||||||
expect(loggedString).toContain(
|
|
||||||
" arg3..................(no description)",
|
|
||||||
);
|
|
||||||
expect(loggedString).toContain(
|
|
||||||
" arg4..................(no description)",
|
|
||||||
);
|
|
||||||
expect(loggedString).toContain(
|
|
||||||
" --delete-previous.....Also delete the previous item (optional)",
|
|
||||||
);
|
|
||||||
expect(loggedString).toContain(
|
|
||||||
" Example: emoji add --url https://site.com/image.png",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,477 +1,141 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
import { types as mimeTypes } from "mime-types";
|
||||||
|
|
||||||
export enum MediaBackendType {
|
export enum MediaBackendType {
|
||||||
LOCAL = "local",
|
LOCAL = "local",
|
||||||
S3 = "s3",
|
S3 = "s3",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Config {
|
export const configValidator = z.object({
|
||||||
database: {
|
database: z.object({
|
||||||
/** @default "localhost" */
|
host: z.string().min(1).default("localhost"),
|
||||||
host: string;
|
port: z
|
||||||
|
.number()
|
||||||
/** @default 5432 */
|
.int()
|
||||||
port: number;
|
.min(1)
|
||||||
|
.max(2 ** 16 - 1)
|
||||||
/** @default "lysand" */
|
.default(5432),
|
||||||
username: string;
|
username: z.string().min(1),
|
||||||
|
password: z.string().default(""),
|
||||||
/** @default "lysand" */
|
database: z.string().min(1).default("lysand"),
|
||||||
password: string;
|
}),
|
||||||
|
redis: z.object({
|
||||||
/** @default "lysand" */
|
queue: z
|
||||||
database: string;
|
.object({
|
||||||
};
|
host: z.string().min(1).default("localhost"),
|
||||||
|
port: z
|
||||||
redis: {
|
.number()
|
||||||
queue: {
|
.int()
|
||||||
/** @default "localhost" */
|
.min(1)
|
||||||
host: string;
|
.max(2 ** 16 - 1)
|
||||||
|
.default(6379),
|
||||||
/** @default 6379 */
|
password: z.string().default(""),
|
||||||
port: number;
|
database: z.number().int().default(0),
|
||||||
|
enabled: z.boolean().default(false),
|
||||||
/** @default "" */
|
})
|
||||||
password: string;
|
.default({
|
||||||
|
|
||||||
/** @default 0 */
|
|
||||||
database: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
cache: {
|
|
||||||
/** @default "localhost" */
|
|
||||||
host: string;
|
|
||||||
|
|
||||||
/** @default 6379 */
|
|
||||||
port: number;
|
|
||||||
|
|
||||||
/** @default "" */
|
|
||||||
password: string;
|
|
||||||
|
|
||||||
/** @default 1 */
|
|
||||||
database: number;
|
|
||||||
|
|
||||||
/** @default false */
|
|
||||||
enabled: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
meilisearch: {
|
|
||||||
/** @default "localhost" */
|
|
||||||
host: string;
|
|
||||||
|
|
||||||
/** @default 7700 */
|
|
||||||
port: number;
|
|
||||||
|
|
||||||
/** @default "______________________________" */
|
|
||||||
api_key: string;
|
|
||||||
|
|
||||||
/** @default false */
|
|
||||||
enabled: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
signups: {
|
|
||||||
/** @default "https://my-site.com/tos" */
|
|
||||||
tos_url: string;
|
|
||||||
|
|
||||||
/** @default true */
|
|
||||||
registration: boolean;
|
|
||||||
|
|
||||||
/** @default [] */
|
|
||||||
rules: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
oidc: {
|
|
||||||
/** @default [] */
|
|
||||||
providers: {
|
|
||||||
name: string;
|
|
||||||
id: string;
|
|
||||||
url: string;
|
|
||||||
client_id: string;
|
|
||||||
client_secret: string;
|
|
||||||
icon: string;
|
|
||||||
}[];
|
|
||||||
|
|
||||||
jwt_key: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
http: {
|
|
||||||
/** @default "https://lysand.social" */
|
|
||||||
base_url: string;
|
|
||||||
|
|
||||||
/** @default "0.0.0.0" */
|
|
||||||
bind: string;
|
|
||||||
|
|
||||||
/** @default "8080" */
|
|
||||||
bind_port: string;
|
|
||||||
|
|
||||||
banned_ips: string[];
|
|
||||||
|
|
||||||
banned_user_agents: string[];
|
|
||||||
|
|
||||||
tls: {
|
|
||||||
/** @default false */
|
|
||||||
enabled: boolean;
|
|
||||||
|
|
||||||
/** @default "" */
|
|
||||||
key: string;
|
|
||||||
|
|
||||||
/** @default "" */
|
|
||||||
cert: string;
|
|
||||||
|
|
||||||
/** @default "" */
|
|
||||||
passphrase: string;
|
|
||||||
|
|
||||||
/** @default "" */
|
|
||||||
ca: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
bait: {
|
|
||||||
/** @default false */
|
|
||||||
enabled: boolean;
|
|
||||||
|
|
||||||
/** @default "" */
|
|
||||||
send_file: string;
|
|
||||||
|
|
||||||
/** @default ["127.0.0.1","::1"] */
|
|
||||||
bait_ips: string[];
|
|
||||||
|
|
||||||
/** @default ["curl","wget"] */
|
|
||||||
bait_user_agents: string[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
frontend: {
|
|
||||||
/** @default true */
|
|
||||||
enabled: boolean;
|
|
||||||
|
|
||||||
/** @default "http://localhost:3000" */
|
|
||||||
url: string;
|
|
||||||
|
|
||||||
glitch: {
|
|
||||||
/** @default false */
|
|
||||||
enabled: boolean;
|
|
||||||
|
|
||||||
/** @default "glitch" */
|
|
||||||
assets: string;
|
|
||||||
|
|
||||||
/** @default [] */
|
|
||||||
server: string[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
smtp: {
|
|
||||||
/** @default "smtp.example.com" */
|
|
||||||
server: string;
|
|
||||||
|
|
||||||
/** @default 465 */
|
|
||||||
port: number;
|
|
||||||
|
|
||||||
/** @default "test@example.com" */
|
|
||||||
username: string;
|
|
||||||
|
|
||||||
/** @default "____________" */
|
|
||||||
password: string;
|
|
||||||
|
|
||||||
/** @default true */
|
|
||||||
tls: boolean;
|
|
||||||
|
|
||||||
/** @default false */
|
|
||||||
enabled: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
media: {
|
|
||||||
/** @default "local" */
|
|
||||||
backend: MediaBackendType;
|
|
||||||
|
|
||||||
/** @default true */
|
|
||||||
deduplicate_media: boolean;
|
|
||||||
|
|
||||||
/** @default "uploads" */
|
|
||||||
local_uploads_folder: string;
|
|
||||||
|
|
||||||
conversion: {
|
|
||||||
/** @default false */
|
|
||||||
convert_images: boolean;
|
|
||||||
|
|
||||||
/** @default "image/webp" */
|
|
||||||
convert_to: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
s3: {
|
|
||||||
/** @default "myhostname.banana.com" */
|
|
||||||
endpoint: string;
|
|
||||||
|
|
||||||
/** @default "_____________" */
|
|
||||||
access_key: string;
|
|
||||||
|
|
||||||
/** @default "_________________" */
|
|
||||||
secret_access_key: string;
|
|
||||||
|
|
||||||
/** @default "" */
|
|
||||||
region: string;
|
|
||||||
|
|
||||||
/** @default "lysand" */
|
|
||||||
bucket_name: string;
|
|
||||||
|
|
||||||
/** @default "https://cdn.test.com" */
|
|
||||||
public_url: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
email: {
|
|
||||||
/** @default false */
|
|
||||||
send_on_report: boolean;
|
|
||||||
|
|
||||||
/** @default false */
|
|
||||||
send_on_suspend: boolean;
|
|
||||||
|
|
||||||
/** @default false */
|
|
||||||
send_on_unsuspend: boolean;
|
|
||||||
|
|
||||||
/** @default false */
|
|
||||||
verify_email: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
validation: {
|
|
||||||
/** @default 50 */
|
|
||||||
max_displayname_size: number;
|
|
||||||
|
|
||||||
/** @default 160 */
|
|
||||||
max_bio_size: number;
|
|
||||||
|
|
||||||
/** @default 5000 */
|
|
||||||
max_note_size: number;
|
|
||||||
|
|
||||||
/** @default 5000000 */
|
|
||||||
max_avatar_size: number;
|
|
||||||
|
|
||||||
/** @default 5000000 */
|
|
||||||
max_header_size: number;
|
|
||||||
|
|
||||||
/** @default 40000000 */
|
|
||||||
max_media_size: number;
|
|
||||||
|
|
||||||
/** @default 10 */
|
|
||||||
max_media_attachments: number;
|
|
||||||
|
|
||||||
/** @default 1000 */
|
|
||||||
max_media_description_size: number;
|
|
||||||
|
|
||||||
/** @default 20 */
|
|
||||||
max_poll_options: number;
|
|
||||||
|
|
||||||
/** @default 500 */
|
|
||||||
max_poll_option_size: number;
|
|
||||||
|
|
||||||
/** @default 60 */
|
|
||||||
min_poll_duration: number;
|
|
||||||
|
|
||||||
/** @default 1893456000 */
|
|
||||||
max_poll_duration: number;
|
|
||||||
|
|
||||||
/** @default 30 */
|
|
||||||
max_username_size: number;
|
|
||||||
|
|
||||||
/** @default 10 */
|
|
||||||
max_field_count: number;
|
|
||||||
|
|
||||||
/** @default 1000 */
|
|
||||||
max_field_name_size: number;
|
|
||||||
|
|
||||||
/** @default 1000 */
|
|
||||||
max_field_value_size: number;
|
|
||||||
|
|
||||||
/** @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"] */
|
|
||||||
username_blacklist: string[];
|
|
||||||
|
|
||||||
/** @default false */
|
|
||||||
blacklist_tempmail: boolean;
|
|
||||||
|
|
||||||
email_blacklist: string[];
|
|
||||||
|
|
||||||
/** @default ["http","https","ftp","dat","dweb","gopher","hyper","ipfs","ipns","irc","xmpp","ircs","magnet","mailto","mumble","ssb","gemini"] */
|
|
||||||
url_scheme_whitelist: string[];
|
|
||||||
|
|
||||||
/** @default false */
|
|
||||||
enforce_mime_types: boolean;
|
|
||||||
|
|
||||||
/** @default ["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"] */
|
|
||||||
allowed_mime_types: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
defaults: {
|
|
||||||
/** @default "public" */
|
|
||||||
visibility: string;
|
|
||||||
|
|
||||||
/** @default "en" */
|
|
||||||
language: string;
|
|
||||||
|
|
||||||
/** @default "" */
|
|
||||||
avatar: string;
|
|
||||||
|
|
||||||
/** @default "" */
|
|
||||||
header: string;
|
|
||||||
|
|
||||||
/** @default "thumbs" */
|
|
||||||
placeholder_style: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
federation: {
|
|
||||||
blocked: string[];
|
|
||||||
|
|
||||||
followers_only: string[];
|
|
||||||
|
|
||||||
discard: {
|
|
||||||
reports: string[];
|
|
||||||
|
|
||||||
deletes: string[];
|
|
||||||
|
|
||||||
updates: string[];
|
|
||||||
|
|
||||||
media: string[];
|
|
||||||
|
|
||||||
follows: string[];
|
|
||||||
|
|
||||||
likes: string[];
|
|
||||||
|
|
||||||
reactions: string[];
|
|
||||||
|
|
||||||
banners: string[];
|
|
||||||
|
|
||||||
avatars: string[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
instance: {
|
|
||||||
/** @default "Lysand" */
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
/** @default "A test instance of Lysand" */
|
|
||||||
description: string;
|
|
||||||
|
|
||||||
/** @default "" */
|
|
||||||
extended_description_path: string;
|
|
||||||
|
|
||||||
/** @default "" */
|
|
||||||
logo: string;
|
|
||||||
|
|
||||||
/** @default "" */
|
|
||||||
banner: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
filters: {
|
|
||||||
note_content: string[];
|
|
||||||
|
|
||||||
emoji: string[];
|
|
||||||
|
|
||||||
username: string[];
|
|
||||||
|
|
||||||
displayname: string[];
|
|
||||||
|
|
||||||
bio: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
logging: {
|
|
||||||
/** @default false */
|
|
||||||
log_requests: boolean;
|
|
||||||
|
|
||||||
/** @default false */
|
|
||||||
log_requests_verbose: boolean;
|
|
||||||
|
|
||||||
/** @default "info" */
|
|
||||||
log_level: "info" | "debug" | "warning" | "error" | "critical";
|
|
||||||
|
|
||||||
/** @default false */
|
|
||||||
log_ip: boolean;
|
|
||||||
|
|
||||||
/** @default true */
|
|
||||||
log_filters: boolean;
|
|
||||||
|
|
||||||
storage: {
|
|
||||||
/** @default "logs/requests.log" */
|
|
||||||
requests: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
ratelimits: {
|
|
||||||
/** @default 1 */
|
|
||||||
duration_coeff: number;
|
|
||||||
|
|
||||||
/** @default 1 */
|
|
||||||
max_coeff: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** @default {} */
|
|
||||||
custom_ratelimits: Record<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
/** @default 30 */
|
|
||||||
duration: number;
|
|
||||||
|
|
||||||
/** @default 60 */
|
|
||||||
max: number;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const defaultConfig: Config = {
|
|
||||||
database: {
|
|
||||||
host: "localhost",
|
|
||||||
port: 5432,
|
|
||||||
username: "lysand",
|
|
||||||
password: "lysand",
|
|
||||||
database: "lysand",
|
|
||||||
},
|
|
||||||
redis: {
|
|
||||||
queue: {
|
|
||||||
host: "localhost",
|
host: "localhost",
|
||||||
port: 6379,
|
port: 6379,
|
||||||
password: "",
|
password: "",
|
||||||
database: 0,
|
database: 0,
|
||||||
},
|
enabled: false,
|
||||||
cache: {
|
}),
|
||||||
|
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),
|
||||||
|
})
|
||||||
|
.default({
|
||||||
host: "localhost",
|
host: "localhost",
|
||||||
port: 6379,
|
port: 6379,
|
||||||
password: "",
|
password: "",
|
||||||
database: 1,
|
database: 1,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
}),
|
||||||
},
|
}),
|
||||||
meilisearch: {
|
meilisearch: z.object({
|
||||||
host: "localhost",
|
host: z.string().min(1).default("localhost"),
|
||||||
port: 7700,
|
port: z
|
||||||
api_key: "______________________________",
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(2 ** 16 - 1)
|
||||||
|
.default(7700),
|
||||||
|
api_key: z.string().min(1),
|
||||||
|
enabled: z.boolean().default(false),
|
||||||
|
}),
|
||||||
|
signups: z.object({
|
||||||
|
tos_url: z.string().min(1).optional(),
|
||||||
|
registration: z.boolean().default(true),
|
||||||
|
rules: z.array(z.string()).default([]),
|
||||||
|
}),
|
||||||
|
oidc: z.object({
|
||||||
|
providers: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
id: z.string().min(1),
|
||||||
|
url: z.string().min(1),
|
||||||
|
client_id: z.string().min(1),
|
||||||
|
client_secret: z.string().min(1),
|
||||||
|
icon: z.string().min(1).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.default([]),
|
||||||
|
jwt_key: z.string().min(3).includes(";").default("").optional(),
|
||||||
|
}),
|
||||||
|
http: z.object({
|
||||||
|
base_url: z.string().min(1).default("http://lysand.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([]),
|
||||||
|
tls: z.object({
|
||||||
|
enabled: z.boolean().default(false),
|
||||||
|
key: z.string(),
|
||||||
|
cert: z.string(),
|
||||||
|
passphrase: z.string().optional(),
|
||||||
|
ca: z.string().optional(),
|
||||||
|
}),
|
||||||
|
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([]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
frontend: z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
url: z.string().min(1).url().default("http://localhost:3000"),
|
||||||
|
glitch: z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().default(false),
|
||||||
|
assets: z.string().min(1).default("glitch"),
|
||||||
|
server: z.array(z.string().url().min(1)).default([]),
|
||||||
|
})
|
||||||
|
.default({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
assets: "glitch",
|
||||||
signups: {
|
server: [],
|
||||||
tos_url: "https://my-site.com/tos",
|
}),
|
||||||
registration: true,
|
settings: z.record(z.string(), z.any()).default({}),
|
||||||
rules: [],
|
})
|
||||||
},
|
.default({
|
||||||
oidc: {
|
|
||||||
providers: [],
|
|
||||||
jwt_key: "",
|
|
||||||
},
|
|
||||||
http: {
|
|
||||||
base_url: "https://lysand.social",
|
|
||||||
bind: "0.0.0.0",
|
|
||||||
bind_port: "8080",
|
|
||||||
banned_ips: [],
|
|
||||||
banned_user_agents: [],
|
|
||||||
tls: {
|
|
||||||
enabled: false,
|
|
||||||
key: "",
|
|
||||||
cert: "",
|
|
||||||
passphrase: "",
|
|
||||||
ca: "",
|
|
||||||
},
|
|
||||||
bait: {
|
|
||||||
enabled: false,
|
|
||||||
send_file: "",
|
|
||||||
bait_ips: ["127.0.0.1", "::1"],
|
|
||||||
bait_user_agents: ["curl", "wget"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
frontend: {
|
|
||||||
enabled: true,
|
enabled: true,
|
||||||
url: "http://localhost:3000",
|
url: "http://localhost:3000",
|
||||||
glitch: {
|
glitch: {
|
||||||
|
|
@ -479,16 +143,48 @@ export const defaultConfig: Config = {
|
||||||
assets: "glitch",
|
assets: "glitch",
|
||||||
server: [],
|
server: [],
|
||||||
},
|
},
|
||||||
},
|
settings: {},
|
||||||
smtp: {
|
}),
|
||||||
server: "smtp.example.com",
|
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),
|
||||||
|
})
|
||||||
|
.default({
|
||||||
|
server: "",
|
||||||
port: 465,
|
port: 465,
|
||||||
username: "test@example.com",
|
username: "",
|
||||||
password: "____________",
|
password: "",
|
||||||
tls: true,
|
tls: true,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
}),
|
||||||
media: {
|
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"),
|
||||||
|
})
|
||||||
|
.default({
|
||||||
|
convert_images: false,
|
||||||
|
convert_to: "image/webp",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.default({
|
||||||
backend: MediaBackendType.LOCAL,
|
backend: MediaBackendType.LOCAL,
|
||||||
deduplicate_media: true,
|
deduplicate_media: true,
|
||||||
local_uploads_folder: "uploads",
|
local_uploads_folder: "uploads",
|
||||||
|
|
@ -496,22 +192,94 @@ export const defaultConfig: Config = {
|
||||||
convert_images: false,
|
convert_images: false,
|
||||||
convert_to: "image/webp",
|
convert_to: "image/webp",
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
s3: {
|
s3: z
|
||||||
endpoint: "myhostname.banana.com",
|
.object({
|
||||||
access_key: "_____________",
|
endpoint: z.string().min(1),
|
||||||
secret_access_key: "_________________",
|
access_key: z.string().min(1),
|
||||||
region: "",
|
secret_access_key: z.string().min(1),
|
||||||
bucket_name: "lysand",
|
region: z.string().optional(),
|
||||||
public_url: "https://cdn.test.com",
|
bucket_name: z.string().min(1).default("lysand"),
|
||||||
},
|
public_url: z.string().min(1).url(),
|
||||||
email: {
|
})
|
||||||
send_on_report: false,
|
.optional(),
|
||||||
send_on_suspend: false,
|
validation: z
|
||||||
send_on_unsuspend: false,
|
.object({
|
||||||
verify_email: false,
|
max_displayname_size: z.number().int().default(50),
|
||||||
},
|
max_bio_size: z.number().int().default(160),
|
||||||
validation: {
|
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_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)),
|
||||||
|
})
|
||||||
|
.default({
|
||||||
max_displayname_size: 50,
|
max_displayname_size: 50,
|
||||||
max_bio_size: 160,
|
max_bio_size: 160,
|
||||||
max_note_size: 5000,
|
max_note_size: 5000,
|
||||||
|
|
@ -578,45 +346,40 @@ export const defaultConfig: Config = {
|
||||||
"gemini",
|
"gemini",
|
||||||
],
|
],
|
||||||
enforce_mime_types: false,
|
enforce_mime_types: false,
|
||||||
allowed_mime_types: [
|
allowed_mime_types: Object.values(mimeTypes),
|
||||||
"image/jpeg",
|
}),
|
||||||
"image/png",
|
defaults: z
|
||||||
"image/gif",
|
.object({
|
||||||
"image/heic",
|
visibility: z.string().default("public"),
|
||||||
"image/heif",
|
language: z.string().default("en"),
|
||||||
"image/webp",
|
avatar: z.string().url().optional(),
|
||||||
"image/avif",
|
header: z.string().url().optional(),
|
||||||
"video/webm",
|
placeholder_style: z.string().default("thumbs"),
|
||||||
"video/mp4",
|
})
|
||||||
"video/quicktime",
|
.default({
|
||||||
"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",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
defaults: {
|
|
||||||
visibility: "public",
|
visibility: "public",
|
||||||
language: "en",
|
language: "en",
|
||||||
avatar: "",
|
avatar: undefined,
|
||||||
header: "",
|
header: undefined,
|
||||||
placeholder_style: "thumbs",
|
placeholder_style: "thumbs",
|
||||||
},
|
}),
|
||||||
federation: {
|
federation: z
|
||||||
|
.object({
|
||||||
|
blocked: z.array(z.string().url()).default([]),
|
||||||
|
followers_only: z.array(z.string().url()).default([]),
|
||||||
|
discard: z.object({
|
||||||
|
reports: z.array(z.string().url()).default([]),
|
||||||
|
deletes: z.array(z.string().url()).default([]),
|
||||||
|
updates: z.array(z.string().url()).default([]),
|
||||||
|
media: z.array(z.string().url()).default([]),
|
||||||
|
follows: z.array(z.string().url()).default([]),
|
||||||
|
likes: z.array(z.string().url()).default([]),
|
||||||
|
reactions: z.array(z.string().url()).default([]),
|
||||||
|
banners: z.array(z.string().url()).default([]),
|
||||||
|
avatars: z.array(z.string().url()).default([]),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.default({
|
||||||
blocked: [],
|
blocked: [],
|
||||||
followers_only: [],
|
followers_only: [],
|
||||||
discard: {
|
discard: {
|
||||||
|
|
@ -630,22 +393,43 @@ export const defaultConfig: Config = {
|
||||||
banners: [],
|
banners: [],
|
||||||
avatars: [],
|
avatars: [],
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
instance: {
|
instance: z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1).default("Lysand"),
|
||||||
|
description: z.string().min(1).default("A Lysand instance"),
|
||||||
|
extended_description_path: z.string().optional(),
|
||||||
|
logo: z.string().url().optional(),
|
||||||
|
banner: z.string().url().optional(),
|
||||||
|
})
|
||||||
|
.default({
|
||||||
name: "Lysand",
|
name: "Lysand",
|
||||||
description: "A test instance of Lysand",
|
description: "A Lysand instance",
|
||||||
extended_description_path: "",
|
extended_description_path: undefined,
|
||||||
logo: "",
|
logo: undefined,
|
||||||
banner: "",
|
banner: undefined,
|
||||||
},
|
}),
|
||||||
filters: {
|
filters: z.object({
|
||||||
note_content: [],
|
note_content: z.array(z.string()).default([]),
|
||||||
emoji: [],
|
emoji: z.array(z.string()).default([]),
|
||||||
username: [],
|
username: z.array(z.string()).default([]),
|
||||||
displayname: [],
|
displayname: z.array(z.string()).default([]),
|
||||||
bio: [],
|
bio: z.array(z.string()).default([]),
|
||||||
},
|
}),
|
||||||
logging: {
|
logging: z
|
||||||
|
.object({
|
||||||
|
log_requests: z.boolean().default(false),
|
||||||
|
log_requests_verbose: z.boolean().default(false),
|
||||||
|
log_level: z
|
||||||
|
.enum(["debug", "info", "warning", "error", "critical"])
|
||||||
|
.default("info"),
|
||||||
|
log_ip: z.boolean().default(false),
|
||||||
|
log_filters: z.boolean().default(true),
|
||||||
|
storage: z.object({
|
||||||
|
requests: z.string().default("logs/requests.log"),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.default({
|
||||||
log_requests: false,
|
log_requests: false,
|
||||||
log_requests_verbose: false,
|
log_requests_verbose: false,
|
||||||
log_level: "info",
|
log_level: "info",
|
||||||
|
|
@ -654,10 +438,20 @@ export const defaultConfig: Config = {
|
||||||
storage: {
|
storage: {
|
||||||
requests: "logs/requests.log",
|
requests: "logs/requests.log",
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
ratelimits: {
|
ratelimits: z.object({
|
||||||
duration_coeff: 1,
|
duration_coeff: z.number().default(1),
|
||||||
max_coeff: 1,
|
max_coeff: z.number().default(1),
|
||||||
},
|
custom: z
|
||||||
custom_ratelimits: {},
|
.record(
|
||||||
};
|
z.string(),
|
||||||
|
z.object({
|
||||||
|
duration: z.number().default(30),
|
||||||
|
max: z.number().default(60),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.default({}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Config = z.infer<typeof configValidator>;
|
||||||
|
|
|
||||||
|
|
@ -5,22 +5,43 @@
|
||||||
* Fuses both and provides a way to retrieve individual values
|
* Fuses both and provides a way to retrieve individual values
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { watchConfig } from "c12";
|
import { watchConfig, loadConfig } from "c12";
|
||||||
import { type Config, defaultConfig } from "./config.type";
|
import { configValidator, type Config } from "./config.type";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import chalk from "chalk";
|
||||||
|
|
||||||
const { config } = await watchConfig<Config>({
|
const { config } = await watchConfig({
|
||||||
configFile: "./config/config.toml",
|
configFile: "./config/config.toml",
|
||||||
defaultConfig: defaultConfig,
|
|
||||||
overrides:
|
overrides:
|
||||||
(
|
(
|
||||||
await watchConfig<Config>({
|
await loadConfig<Config>({
|
||||||
configFile: "./config/config.internal.toml",
|
configFile: "./config/config.internal.toml",
|
||||||
defaultConfig: {} as Config,
|
|
||||||
})
|
})
|
||||||
).config ?? undefined,
|
).config ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const exportedConfig = config ?? defaultConfig;
|
const parsed = await configValidator.safeParseAsync(config);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
console.log(
|
||||||
|
`${chalk.bgRed.white(
|
||||||
|
" CRITICAL ",
|
||||||
|
)} There was an error parsing the config file at ${chalk.bold(
|
||||||
|
"./config/config.toml",
|
||||||
|
)}. Please fix the file and try again.`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`${chalk.bgRed.white(
|
||||||
|
" CRITICAL ",
|
||||||
|
)} Follow the installation intructions and get a sample config file from the repository if needed.`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`${chalk.bgRed.white(" CRITICAL ")} ${fromError(parsed.error).message}`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportedConfig = parsed.data;
|
||||||
|
|
||||||
export { exportedConfig as config };
|
export { exportedConfig as config };
|
||||||
export type { Config };
|
export type { Config };
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"c12": "^1.10.0"
|
"c12": "^1.10.0",
|
||||||
|
"zod": "^3.23.8",
|
||||||
|
"zod-validation-error": "^3.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -365,8 +365,8 @@ export class User {
|
||||||
: await Bun.password.hash(data.password),
|
: await Bun.password.hash(data.password),
|
||||||
email: data.email,
|
email: data.email,
|
||||||
note: data.bio ?? "",
|
note: data.bio ?? "",
|
||||||
avatar: data.avatar ?? config.defaults.avatar,
|
avatar: data.avatar ?? config.defaults.avatar ?? "",
|
||||||
header: data.header ?? config.defaults.avatar,
|
header: data.header ?? config.defaults.avatar ?? "",
|
||||||
isAdmin: data.admin ?? false,
|
isAdmin: data.admin ?? false,
|
||||||
publicKey: keys.public_key,
|
publicKey: keys.public_key,
|
||||||
fields: [],
|
fields: [],
|
||||||
|
|
@ -399,7 +399,7 @@ export class User {
|
||||||
* @returns The raw URL for the user's header
|
* @returns The raw URL for the user's header
|
||||||
*/
|
*/
|
||||||
getHeaderUrl(config: Config) {
|
getHeaderUrl(config: Config) {
|
||||||
if (!this.user.header) return config.defaults.header;
|
if (!this.user.header) return config.defaults.header || "";
|
||||||
return this.user.header;
|
return this.user.header;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,8 @@ export const applyConfig = (routeMeta: APIRouteMetadata) => {
|
||||||
newMeta.ratelimits.duration *= config.ratelimits.duration_coeff;
|
newMeta.ratelimits.duration *= config.ratelimits.duration_coeff;
|
||||||
newMeta.ratelimits.max *= config.ratelimits.max_coeff;
|
newMeta.ratelimits.max *= config.ratelimits.max_coeff;
|
||||||
|
|
||||||
if (config.custom_ratelimits[routeMeta.route]) {
|
if (config.ratelimits.custom[routeMeta.route]) {
|
||||||
newMeta.ratelimits = config.custom_ratelimits[routeMeta.route];
|
newMeta.ratelimits = config.ratelimits.custom[routeMeta.route];
|
||||||
}
|
}
|
||||||
|
|
||||||
return newMeta;
|
return newMeta;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue