Replace config manager with unjs/c12

This commit is contained in:
Jesse Wierzbinski 2024-04-06 18:16:54 -10:00
parent 6b2e4044b6
commit 6a54c5b805
No known key found for this signature in database
30 changed files with 744 additions and 733 deletions

View file

@ -3,9 +3,7 @@
*/
import chalk from "chalk";
import { ConfigManager } from "config-manager";
const config = await new ConfigManager({}).getConfig();
import { config } from "config-manager";
const token = process.env.TOKEN;
const requestCount = Number(process.argv[2]) || 100;

BIN
bun.lockb

Binary file not shown.

4
cli.ts
View file

@ -7,7 +7,7 @@ import extract from "extract-zip";
import { client } from "~database/datasource";
import { CliBuilder, CliCommand } from "cli-parser";
import { CliParameterType } from "~packages/cli-parser/cli-builder.type";
import { ConfigManager } from "~packages/config-manager";
import { config } from "~packages/config-manager";
import { Parser } from "@json2csv/plainjs";
import type { Prisma } from "@prisma/client";
import { MediaBackend } from "media-manager";
@ -17,8 +17,6 @@ import { tmpdir } from "os";
const args = process.argv;
const config = await new ConfigManager({}).getConfig();
const filterObjects = <T extends object>(output: T[], fields: string[]) => {
if (fields.length === 0) return output;

View file

@ -1,17 +1,28 @@
# 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]
# Main PostgreSQL database connection
host = "localhost"
port = 5432
username = "lysand"
password = "password123"
password = "lysand"
database = "lysand"
[redis.queue]
# Redis instance for storing the federation queue
# Required for federation
host = "localhost"
port = 6379
password = ""
database = 0
[redis.cache]
# Redis instance to be used as a timeline cache
# Optional, can be the same as the queue instance
host = "localhost"
port = 6379
password = ""
@ -19,14 +30,15 @@ database = 1
enabled = false
[meilisearch]
# If Meilisearch is not configured, search will not be enabled
host = "localhost"
port = 40007
api_key = ""
enabled = true
port = 7700
api_key = "______________________________"
enabled = false
[signups]
# URL of your Terms of Service
tos_url = "https://example.com/tos"
tos_url = "https://my-site.com/tos"
# Whether to enable registrations or not
registration = true
rules = [
@ -41,40 +53,64 @@ rules = [
# The provider MUST support OpenID Connect with .well-known discovery
# Most notably, GitHub does not support this
[[oidc.providers]]
# Test with custom Authentik instance
name = "CPlusPatch ID"
id = "cpluspatch-id"
url = "https://id.cpluspatch.com/application/o/lysand-testing/"
client_id = "XXXXXXXXXXXXXXXX"
client_secret = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
client_id = "______________________________"
client_secret = "__________________________________"
icon = "https://cpluspatch.com/images/icons/logo.svg"
[http]
# The full URL Lysand will be reachable by (paths are not supported)
base_url = "https://lysand.social"
bind = "http://localhost"
# Address to bind to
bind = "0.0.0.0"
bind_port = "8080"
# Bans IPv4 or IPv6 IPs (wildcards, networks and ranges are supported)
banned_ips = []
# Banned user agents, regex format
banned_user_agents = [
# "curl\/7.68.0",
# "wget\/1.20.3",
]
[http.bait]
# Enable the bait feature (sends fake data to those who are flagged)
enabled = false
# Path to file of bait data (if not provided, Lysand will send the entire Bee Movie script)
send_file = ""
# IPs to send bait data to (wildcards, networks and ranges are supported)
bait_ips = ["127.0.0.1", "::1"]
# User agents to send bait data to (regex format)
bait_user_agents = ["curl", "wget"]
[smtp]
# SMTP server to use for sending emails
server = "smtp.example.com"
port = 465
username = "test@example.com"
password = "password123"
password = "____________"
tls = true
# Disable all email functions (this will allow people to sign up without verifying
# their email)
enabled = false
[media]
# Can be "s3" or "local", where "local" uploads the file to the local filesystem
# If you need to change this value after setting up your instance, you must move all the files
# from one backend to the other manually
backend = "s3"
# from one backend to the other manually (the CLI will have an option to do this later)
# TODO: Add CLI command to move files
backend = "local"
# Whether to check the hash of media when uploading to avoid duplication
deduplicate_media = true
# If media backend is "local", this is the folder where the files will be stored
# Can be any path
local_uploads_folder = "uploads"
[media.conversion]
# Whether to automatically convert images to another format on upload
convert_images = false
# Can be: "jxl", "webp", "avif", "png", "jpg", "heif"
# JXL support will likely not work
@ -82,26 +118,26 @@ convert_to = "webp"
[s3]
# Can be left blank if you don't use the S3 media backend
endpoint = "https://s3-us-west-2.amazonaws.com"
access_key = ""
secret_access_key = ""
region = "us-west-2"
endpoint = "myhostname.banana.com"
access_key = "_____________"
secret_access_key = "_________________"
region = ""
bucket_name = "lysand"
public_url = "https://cdn.example.com"
public_url = "https://cdn.test.com"
[email]
# Sends an email to moderators when a report is received
# NOT IMPLEMENTED
send_on_report = false
# Sends an email to moderators when a user is suspended
# NOT IMPLEMENTED
send_on_suspend = false
# Sends an email to moderators when a user is unsuspended
# NOT IMPLEMENTED
send_on_unsuspend = false
# Verify user emails when signing up (except via OIDC)
verify_email = false
[validation]
# Self explanatory
# Checks user data
# Does not retroactively apply to previously entered data
max_displayname_size = 50
max_bio_size = 160
max_note_size = 5000
@ -115,7 +151,7 @@ max_poll_option_size = 500
min_poll_duration = 60
max_poll_duration = 1893456000
max_username_size = 30
# An array of strings, defaults are from Akkoma
# Forbidden usernames, defaults are from Akkoma
username_blacklist = [
".well-known",
"~",
@ -146,7 +182,7 @@ username_blacklist = [
]
# Whether to blacklist known temporary email providers
blacklist_tempmail = false
# Additional email providers to blacklist
# Additional email providers to blacklist (list of domains)
email_blacklist = []
# Valid URL schemes, otherwise the URL is parsed as text
url_scheme_whitelist = [
@ -167,8 +203,10 @@ url_scheme_whitelist = [
"mumble",
"ssb",
"gemini",
] # NOT IMPLEMENTED
]
# Only allow those MIME types of data to be uploaded
# This can easily be spoofed, but if it is spoofed it will appear broken
# to normal clients until despoofed
enforce_mime_types = false
allowed_mime_types = [
"image/jpeg",
@ -203,46 +241,38 @@ allowed_mime_types = [
[defaults]
# Default visibility for new notes
# Can be public, unlisted, private or direct
# Private only sends to followers, unlisted doesn't show up in timelines
visibility = "public"
# Default language for new notes
# Default language for new notes (ISO code)
language = "en"
# Default avatar, must be a valid URL or ""
# Default avatar, must be a valid URL or "" for none
avatar = ""
# Default header, must be a valid URL or ""
# Default header, must be a valid URL or "" for none
header = ""
[activitypub]
# Use ActivityPub Tombstones instead of deleting objects
use_tombstones = true
# Fetch all members of collections (followers, following, etc) when receiving them
# WARNING: This can be a lot of data, and is not recommended
fetch_all_collection_members = false # NOT IMPLEMENTED
[federation]
# This is a list of domain names, such as "mastodon.social" or "pleroma.site"
# These changes will not retroactively apply to existing data before they were changed
# For that, please use the CLI
# The following values must be instance domain names without "https" or glob patterns
# Rejects all activities from these instances (fediblocking)
reject_activities = []
# Force posts from this instance to be followers only
force_followers_only = [] # NOT IMPLEMENTED
# Discard all reports from these instances
discard_reports = [] # NOT IMPLEMENTED
# Discard all deletes from these instances
discard_deletes = []
# Discard all updates (edits) from these instances
discard_updates = []
# Discard all banners from these instances
discard_banners = [] # NOT IMPLEMENTED
# Discard all avatars from these instances
discard_avatars = [] # NOT IMPLEMENTED
# Discard all follow requests from these instances
discard_follows = []
# Force set these instances' media as sensitive
force_sensitive = [] # NOT IMPLEMENTED
# Remove theses instances' media
remove_media = [] # NOT IMPLEMENTED
# These instances will not be federated with
blocked = []
# These instances' data will only be shown to followers, not in public timelines
followers_only = []
# Whether to verify HTTP signatures for every request (warning: can slow down your server
# significantly depending on processing power)
authorized_fetch = false
[federation.discard]
# These objects will be discarded when received from these instances
reports = []
deletes = []
updates = []
media = []
follows = []
# If instance reactions are blocked, likes will also be discarded
likes = []
reactions = []
banners = []
avatars = []
[instance]
name = "Lysand"
@ -254,22 +284,23 @@ banner = ""
[filters]
# Drop notes with these regex filters (only applies to new activities)
note_filters = [
# Regex filters for federated and local data
# Does not apply retroactively (try the CLI for that)
# Note contents
note_content = [
# "(https?://)?(www\\.)?youtube\\.com/watch\\?v=[a-zA-Z0-9_-]+",
# "(https?://)?(www\\.)?youtu\\.be/[a-zA-Z0-9_-]+",
]
# Drop users with these regex filters (only applies to new activities)
username_filters = []
# Drop users with these regex filters (only applies to new activities)
displayname_filters = []
# Drop users with these regex filters (only applies to new activities)
bio_filters = []
emoji_filters = [] # NOT IMPLEMENTED
emoji = []
# These will drop users matching the filters
username = []
displayname = []
bio = []
[logging]
# Log all requests (warning: this is a lot of data)
log_requests = true
log_requests = false
# Log request and their contents (warning: this is a lot of data)
log_requests_verbose = false
# For GDPR compliance, you can disable logging of IPs
@ -278,12 +309,19 @@ log_ip = false
# Log all filtered objects
log_filters = true
[logging.storage]
# Path to logfile for requests
requests = "logs/requests.log"
[ratelimits]
# These settings apply to every route at once
# Amount to multiply every route's duration by
duration_coeff = 1.0
# Amount to multiply every route's max by
# Amount to multiply every route's max requests per [duration] by
max_coeff = 1.0
[custom_ratelimits]
# Add in any API route in this style here
# Applies before the global ratelimit changes
"/api/v1/accounts/:id/block" = { duration = 30, max = 60 }
"/api/v1/timelines/public" = { duration = 60, max = 200 }

View file

@ -1,8 +1,6 @@
// import { Queue } from "bullmq";
import { PrismaClient } from "@prisma/client";
import { ConfigManager } from "config-manager";
const config = await new ConfigManager({}).getConfig();
import { config } from "config-manager";
const client = new PrismaClient({
datasourceUrl: `postgresql://${config.database.username}:${config.database.password}@${config.database.host}:${config.database.port}/${config.database.database}`,

View file

@ -4,9 +4,7 @@ import type { Like } from "@prisma/client";
import { client } from "~database/datasource";
import type { UserWithRelations } from "./User";
import type { StatusWithRelations } from "./Status";
import { ConfigManager } from "config-manager";
const config = await new ConfigManager({}).getConfig();
import { config } from "config-manager";
/**
* Represents a Like entity in the database.

View file

@ -1,9 +1,7 @@
// import { Worker } from "bullmq";
import { statusToLysand, type StatusWithRelations } from "./Status";
import type { User } from "@prisma/client";
import { ConfigManager } from "config-manager";
const config = await new ConfigManager({}).getConfig();
import { config } from "config-manager";
/* export const federationWorker = new Worker(
"federation",

View file

@ -23,11 +23,9 @@ import { parse } from "marked";
import linkifyStr from "linkify-string";
import linkifyHtml from "linkify-html";
import { addStausToMeilisearch } from "@meilisearch";
import { ConfigManager } from "config-manager";
import { config } from "config-manager";
import { statusAndUserRelations, userRelations } from "./relations";
const config = await new ConfigManager({}).getConfig();
const statusRelations = Prisma.validator<Prisma.StatusDefaultArgs>()({
include: statusAndUserRelations,
});

View file

@ -1,5 +1,5 @@
import type { APIAccount } from "~types/entities/account";
import type { LysandUser as LysandUser } from "~types/lysand/Object";
import type { LysandUser } from "~types/lysand/Object";
import { htmlToText } from "html-to-text";
import type { User } from "@prisma/client";
import { Prisma } from "@prisma/client";
@ -8,13 +8,10 @@ import { addEmojiIfNotExists, emojiToAPI, emojiToLysand } from "./Emoji";
import { addInstanceIfNotExists } from "./Instance";
import type { APISource } from "~types/entities/source";
import { addUserToMeilisearch } from "@meilisearch";
import { ConfigManager, type ConfigType } from "config-manager";
import { config, type Config } from "config-manager";
import { userRelations } from "./relations";
import { MediaBackendType } from "~packages/media-manager";
const configManager = new ConfigManager({});
const config = await configManager.getConfig();
export interface AuthData {
user: UserWithRelations | null;
token: string;
@ -36,7 +33,7 @@ export type UserWithRelations = Prisma.UserGetPayload<typeof userRelations2>;
* @param config The config to use
* @returns The raw URL for the user's avatar
*/
export const getAvatarUrl = (user: User, config: ConfigType) => {
export const getAvatarUrl = (user: User, config: Config) => {
if (!user.avatar) return config.defaults.avatar;
if (config.media.backend === MediaBackendType.LOCAL) {
return `${config.http.base_url}/media/${user.avatar}`;
@ -52,7 +49,7 @@ export const getAvatarUrl = (user: User, config: ConfigType) => {
* @param config The config to use
* @returns The raw URL for the user's header
*/
export const getHeaderUrl = (user: User, config: ConfigType) => {
export const getHeaderUrl = (user: User, config: Config) => {
if (!user.header) return config.defaults.header;
if (config.media.backend === MediaBackendType.LOCAL) {
return `${config.http.base_url}/media/${user.header}`;
@ -192,8 +189,6 @@ export const createNewLocalUser = async (data: {
header?: string;
admin?: boolean;
}) => {
const config = await configManager.getConfig();
const keys = await generateUserKeys();
const user = await client.user.create({

View file

@ -1,7 +1,7 @@
import type { PrismaClientInitializationError } from "@prisma/client/runtime/library";
import { initializeRedisCache } from "@redis";
import { connectMeili } from "@meilisearch";
import { ConfigManager } from "config-manager";
import { config } from "config-manager";
import { client } from "~database/datasource";
import { LogLevel, LogManager, MultiLogManager } from "log-manager";
import { moduleIsEntry } from "@module";
@ -10,9 +10,6 @@ import { exists, mkdir } from "fs/promises";
const timeAtStart = performance.now();
const configManager = new ConfigManager({});
const config = await configManager.getConfig();
const requests_log = Bun.file(process.cwd() + "/logs/requests.log");
const isEntry = moduleIsEntry(import.meta.url);
// If imported as a module, redirect logs to /dev/null to not pollute console (e.g. in tests)
@ -22,7 +19,7 @@ const consoleLogger = new LogManager(
);
const dualLogger = new MultiLogManager([logger, consoleLogger]);
if (!(await exists(process.cwd() + "/logs/"))) {
if (!(await exists(config.logging.storage.requests))) {
await consoleLogger.log(
LogLevel.WARNING,
"Lysand",
@ -59,7 +56,7 @@ try {
process.exit(1);
}
const server = createServer(config, configManager, dualLogger, isProd);
const server = createServer(config, dualLogger, isProd);
await dualLogger.log(
LogLevel.INFO,

View file

@ -61,6 +61,8 @@
"@typescript-eslint/eslint-plugin": "latest",
"@typescript-eslint/parser": "latest",
"@unocss/cli": "latest",
"@vitejs/plugin-vue": "latest",
"@vueuse/head": "^2.0.0",
"activitypub-types": "^1.0.3",
"bun-types": "latest",
"eslint": "^8.54.0",
@ -71,8 +73,7 @@
"prettier": "^3.1.0",
"typescript": "latest",
"unocss": "latest",
"@vitejs/plugin-vue": "latest",
"@vueuse/head": "^2.0.0",
"untyped": "^1.4.2",
"vite": "latest",
"vite-ssr": "^0.17.1",
"vue": "^3.3.9",
@ -86,11 +87,12 @@
"@aws-sdk/client-s3": "^3.461.0",
"@iarna/toml": "^2.2.5",
"@json2csv/plainjs": "^7.0.6",
"cli-parser": "workspace:*",
"@prisma/client": "^5.6.0",
"blurhash": "^2.0.5",
"bullmq": "latest",
"c12": "^1.10.0",
"chalk": "^5.3.0",
"cli-parser": "workspace:*",
"cli-table": "^0.3.11",
"config-manager": "workspace:*",
"eventemitter3": "^5.0.1",

View file

@ -1,377 +0,0 @@
import { MediaBackendType } from "media-manager";
export interface ConfigType {
database: {
host: string;
port: number;
username: string;
password: string;
database: string;
};
redis: {
queue: {
host: string;
port: number;
password: string;
database: number | null;
};
cache: {
host: string;
port: number;
password: string;
database: number | null;
enabled: boolean;
};
};
meilisearch: {
host: string;
port: number;
api_key: string;
enabled: boolean;
};
signups: {
tos_url: string;
rules: string[];
registration: boolean;
};
oidc: {
providers: {
name: string;
id: string;
url: string;
client_id: string;
client_secret: string;
icon: string;
}[];
};
http: {
base_url: string;
bind: string;
bind_port: string;
banned_ips: string[];
banned_user_agents: string[];
bait: {
enabled: boolean;
send_file?: string;
bait_ips: string[];
bait_user_agents: string[];
};
};
instance: {
name: string;
description: string;
banner: string;
logo: string;
};
smtp: {
server: string;
port: number;
username: string;
password: string;
tls: boolean;
};
validation: {
max_displayname_size: number;
max_bio_size: number;
max_username_size: number;
max_note_size: number;
max_avatar_size: number;
max_header_size: number;
max_media_size: number;
max_media_attachments: number;
max_media_description_size: number;
max_poll_options: number;
max_poll_option_size: number;
min_poll_duration: number;
max_poll_duration: number;
username_blacklist: string[];
blacklist_tempmail: boolean;
email_blacklist: string[];
url_scheme_whitelist: string[];
enforce_mime_types: boolean;
allowed_mime_types: string[];
};
media: {
backend: MediaBackendType;
deduplicate_media: boolean;
conversion: {
convert_images: boolean;
convert_to: string;
};
local_uploads_folder: string;
};
s3: {
endpoint: string;
access_key: string;
secret_access_key: string;
region: string;
bucket_name: string;
public_url: string;
};
defaults: {
visibility: string;
language: string;
avatar: string;
header: string;
};
email: {
send_on_report: boolean;
send_on_suspend: boolean;
send_on_unsuspend: boolean;
};
activitypub: {
use_tombstones: boolean;
reject_activities: string[];
force_followers_only: string[];
discard_reports: string[];
discard_deletes: string[];
discard_banners: string[];
discard_avatars: string[];
discard_updates: string[];
discard_follows: string[];
force_sensitive: string[];
remove_media: string[];
fetch_all_collection_members: boolean;
authorized_fetch: boolean;
};
filters: {
note_filters: string[];
username_filters: string[];
displayname_filters: string[];
bio_filters: string[];
emoji_filters: string[];
};
logging: {
log_requests: boolean;
log_requests_verbose: boolean;
log_ip: boolean;
log_filters: boolean;
};
ratelimits: {
duration_coeff: number;
max_coeff: number;
};
custom_ratelimits: Record<
string,
{
duration: number;
max: number;
}
>;
[key: string]: unknown;
}
export const configDefaults: ConfigType = {
http: {
bind: "http://0.0.0.0",
bind_port: "8000",
base_url: "http://lysand.localhost:8000",
banned_ips: [],
banned_user_agents: [],
bait: {
enabled: false,
send_file: "",
bait_ips: [],
bait_user_agents: [],
},
},
database: {
host: "localhost",
port: 5432,
username: "postgres",
password: "postgres",
database: "lysand",
},
redis: {
queue: {
host: "localhost",
port: 6379,
password: "",
database: 0,
},
cache: {
host: "localhost",
port: 6379,
password: "",
database: 1,
enabled: false,
},
},
meilisearch: {
host: "localhost",
port: 1491,
api_key: "",
enabled: false,
},
signups: {
tos_url: "",
rules: [],
registration: false,
},
oidc: {
providers: [],
},
instance: {
banner: "",
description: "",
logo: "",
name: "",
},
smtp: {
password: "",
port: 465,
server: "",
tls: true,
username: "",
},
media: {
backend: MediaBackendType.LOCAL,
deduplicate_media: true,
conversion: {
convert_images: false,
convert_to: "webp",
},
local_uploads_folder: "uploads",
},
email: {
send_on_report: false,
send_on_suspend: false,
send_on_unsuspend: false,
},
s3: {
access_key: "",
bucket_name: "",
endpoint: "",
public_url: "",
region: "",
secret_access_key: "",
},
validation: {
max_displayname_size: 50,
max_bio_size: 6000,
max_note_size: 5000,
max_avatar_size: 5_000_000,
max_header_size: 5_000_000,
max_media_size: 40_000_000,
max_media_attachments: 10,
max_media_description_size: 1000,
max_poll_options: 20,
max_poll_option_size: 500,
min_poll_duration: 60,
max_poll_duration: 1893456000,
max_username_size: 30,
username_blacklist: [
".well-known",
"~",
"about",
"activities",
"api",
"auth",
"dev",
"inbox",
"internal",
"main",
"media",
"nodeinfo",
"notice",
"oauth",
"objects",
"proxy",
"push",
"registration",
"relay",
"settings",
"status",
"tag",
"users",
"web",
"search",
"mfa",
],
blacklist_tempmail: false,
email_blacklist: [],
url_scheme_whitelist: [
"http",
"https",
"ftp",
"dat",
"dweb",
"gopher",
"hyper",
"ipfs",
"ipns",
"irc",
"xmpp",
"ircs",
"magnet",
"mailto",
"mumble",
"ssb",
],
enforce_mime_types: false,
allowed_mime_types: [],
},
defaults: {
visibility: "public",
language: "en",
avatar: "",
header: "",
},
activitypub: {
use_tombstones: true,
reject_activities: [],
force_followers_only: [],
discard_reports: [],
discard_deletes: [],
discard_banners: [],
discard_avatars: [],
force_sensitive: [],
discard_updates: [],
discard_follows: [],
remove_media: [],
fetch_all_collection_members: false,
authorized_fetch: false,
},
filters: {
note_filters: [],
username_filters: [],
displayname_filters: [],
bio_filters: [],
emoji_filters: [],
},
logging: {
log_requests: false,
log_requests_verbose: false,
log_ip: false,
log_filters: true,
},
ratelimits: {
duration_coeff: 1,
max_coeff: 1,
},
custom_ratelimits: {},
};

View file

@ -0,0 +1,579 @@
import { MediaBackendType } from "~packages/media-manager";
export interface Config {
database: {
/** @default "localhost" */
host: string;
/** @default 5432 */
port: number;
/** @default "lysand" */
username: string;
/** @default "lysand" */
password: string;
/** @default "lysand" */
database: string;
};
redis: {
queue: {
/** @default "localhost" */
host: string;
/** @default 6379 */
port: number;
/** @default "" */
password: string;
/** @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 ["Do not harass others","Be nice to people","Don't spam","Don't post illegal content"] */
rules: string[];
};
oidc: {
/** @default [] */
providers: Record<string, any>[];
};
http: {
/** @default "https://lysand.social" */
base_url: string;
/** @default "0.0.0.0" */
bind: string;
/** @default "8080" */
bind_port: string;
banned_ips: any[];
banned_user_agents: any[];
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[];
};
};
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 "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 [".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: any[];
/** @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;
};
federation: {
blocked: any[];
followers_only: any[];
discard: {
reports: any[];
deletes: any[];
updates: any[];
media: any[];
follows: any[];
likes: any[];
reactions: any[];
banners: any[];
avatars: any[];
};
};
instance: {
/** @default "Lysand" */
name: string;
/** @default "A test instance of Lysand" */
description: string;
/** @default "" */
logo: string;
/** @default "" */
banner: string;
};
filters: {
note_content: any[];
emoji: any[];
username: any[];
displayname: any[];
bio: any[];
};
logging: {
/** @default false */
log_requests: boolean;
/** @default false */
log_requests_verbose: boolean;
/** @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",
port: 6379,
password: "",
database: 0,
},
cache: {
host: "localhost",
port: 6379,
password: "",
database: 1,
enabled: false,
},
},
meilisearch: {
host: "localhost",
port: 7700,
api_key: "______________________________",
enabled: false,
},
signups: {
tos_url: "https://my-site.com/tos",
registration: true,
rules: [
"Do not harass others",
"Be nice to people",
"Don't spam",
"Don't post illegal content",
],
},
oidc: {
providers: [[]],
},
http: {
base_url: "https://lysand.social",
bind: "0.0.0.0",
bind_port: "8080",
banned_ips: [],
banned_user_agents: [],
bait: {
enabled: false,
send_file: "",
bait_ips: ["127.0.0.1", "::1"],
bait_user_agents: ["curl", "wget"],
},
},
smtp: {
server: "smtp.example.com",
port: 465,
username: "test@example.com",
password: "____________",
tls: true,
enabled: false,
},
media: {
backend: MediaBackendType.LOCAL,
deduplicate_media: true,
local_uploads_folder: "uploads",
conversion: {
convert_images: false,
convert_to: "webp",
},
},
s3: {
endpoint: "myhostname.banana.com",
access_key: "_____________",
secret_access_key: "_________________",
region: "",
bucket_name: "lysand",
public_url: "https://cdn.test.com",
},
email: {
send_on_report: false,
send_on_suspend: false,
send_on_unsuspend: false,
verify_email: false,
},
validation: {
max_displayname_size: 50,
max_bio_size: 160,
max_note_size: 5000,
max_avatar_size: 5000000,
max_header_size: 5000000,
max_media_size: 40000000,
max_media_attachments: 10,
max_media_description_size: 1000,
max_poll_options: 20,
max_poll_option_size: 500,
min_poll_duration: 60,
max_poll_duration: 1893456000,
max_username_size: 30,
username_blacklist: [
".well-known",
"~",
"about",
"activities",
"api",
"auth",
"dev",
"inbox",
"internal",
"main",
"media",
"nodeinfo",
"notice",
"oauth",
"objects",
"proxy",
"push",
"registration",
"relay",
"settings",
"status",
"tag",
"users",
"web",
"search",
"mfa",
],
blacklist_tempmail: false,
email_blacklist: [],
url_scheme_whitelist: [
"http",
"https",
"ftp",
"dat",
"dweb",
"gopher",
"hyper",
"ipfs",
"ipns",
"irc",
"xmpp",
"ircs",
"magnet",
"mailto",
"mumble",
"ssb",
"gemini",
],
enforce_mime_types: false,
allowed_mime_types: [
"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",
],
},
defaults: {
visibility: "public",
language: "en",
avatar: "",
header: "",
},
federation: {
blocked: [],
followers_only: [],
discard: {
reports: [],
deletes: [],
updates: [],
media: [],
follows: [],
likes: [],
reactions: [],
banners: [],
avatars: [],
},
},
instance: {
name: "Lysand",
description: "A test instance of Lysand",
logo: "",
banner: "",
},
filters: {
note_content: [],
emoji: [],
username: [],
displayname: [],
bio: [],
},
logging: {
log_requests: false,
log_requests_verbose: false,
log_ip: false,
log_filters: true,
storage: {
requests: "logs/requests.log",
},
},
ratelimits: {
duration_coeff: 1,
max_coeff: 1,
},
custom_ratelimits: {},
};

View file

@ -5,122 +5,22 @@
* Fuses both and provides a way to retrieve individual values
*/
import { parse, stringify, type JsonMap } from "@iarna/toml";
import type { ConfigType } from "./config-type.type";
import { configDefaults } from "./config-type.type";
import merge from "merge-deep-ts";
import { watchConfig } from "c12";
import { defaultConfig, type Config } from "./config.type";
export class ConfigManager {
constructor(
public config: {
configPathOverride?: string;
internalConfigPathOverride?: string;
}
) {}
const { config } = await watchConfig<Config>({
configFile: "./config/config.toml",
defaultConfig: defaultConfig,
overrides:
(
await watchConfig<Config>({
configFile: "./config/config.internal.toml",
defaultConfig: {} as Config,
})
).config ?? undefined,
});
/**
* @summary Reads the config files and returns the merge as a JSON object
* @returns {Promise<T = ConfigType>} The merged config file as a JSON object
*/
async getConfig<T = ConfigType>() {
const config = await this.readConfig<T>();
const internalConfig = await this.readInternalConfig<T>();
const exportedConfig = config ?? defaultConfig;
return this.mergeConfigs<T>(
configDefaults as T,
config,
internalConfig
);
}
getConfigPath() {
return (
this.config.configPathOverride ||
process.cwd() + "/config/config.toml"
);
}
getInternalConfigPath() {
return (
this.config.internalConfigPathOverride ||
process.cwd() + "/config/config.internal.toml"
);
}
/**
* @summary Reads the internal config file and returns it as a JSON object
* @returns {Promise<T = ConfigType>} The internal config file as a JSON object
*/
private async readInternalConfig<T = ConfigType>() {
const config = Bun.file(this.getInternalConfigPath());
if (!(await config.exists())) {
await Bun.write(config, "");
}
return this.parseConfig<T>(await config.text());
}
/**
* @summary Reads the config file and returns it as a JSON object
* @returns {Promise<T = ConfigType>} The config file as a JSON object
*/
private async readConfig<T = ConfigType>() {
const config = Bun.file(this.getConfigPath());
if (!(await config.exists())) {
throw new Error(
`Error while reading config at path ${this.getConfigPath()}: Config file not found`
);
}
return this.parseConfig<T>(await config.text());
}
/**
* @summary Parses a TOML string and returns it as a JSON object
* @param text The TOML string to parse
* @returns {T = ConfigType} The parsed TOML string as a JSON object
* @throws {Error} If the TOML string is invalid
* @private
*/
private parseConfig<T = ConfigType>(text: string) {
try {
// To all [Symbol] keys from the object
return JSON.parse(JSON.stringify(parse(text))) as T;
} catch (e: any) {
throw new Error(
`Error while parsing config at path ${this.getConfigPath()}: ${e}`
);
}
}
/**
* Writes changed values to the internal config
* @param config The new config object
*/
async writeConfig<T = ConfigType>(config: T) {
const path = this.getInternalConfigPath();
const file = Bun.file(path);
await Bun.write(
file,
`# THIS FILE IS AUTOMATICALLY GENERATED. DO NOT EDIT IT MANUALLY, EDIT THE STANDARD CONFIG.TOML INSTEAD.\n${stringify(
config as JsonMap
)}`
);
}
/**
* @summary Merges two config objects together, with
* the latter configs' values taking precedence
* @param configs
* @returns
*/
private mergeConfigs<T = ConfigType>(...configs: T[]) {
return merge(configs) as T;
}
}
export type { ConfigType };
export const defaultConfig = configDefaults;
export { exportedConfig as config };
export type { Config };

View file

@ -1,96 +0,0 @@
// FILEPATH: /home/jessew/Dev/lysand/packages/config-manager/config-manager.test.ts
import { stringify } from "@iarna/toml";
import { ConfigManager } from "..";
import { describe, beforeEach, spyOn, it, expect } from "bun:test";
describe("ConfigManager", () => {
let configManager: ConfigManager;
beforeEach(() => {
configManager = new ConfigManager({
configPathOverride: "./config/config.toml",
internalConfigPathOverride: "./config/config.internal.toml",
});
});
it("should get the correct config path", () => {
expect(configManager.getConfigPath()).toEqual("./config/config.toml");
});
it("should get the correct internal config path", () => {
expect(configManager.getInternalConfigPath()).toEqual(
"./config/config.internal.toml"
);
});
it("should read the config file correctly", async () => {
const mockConfig = { key: "value" };
// @ts-expect-error This is a mock
spyOn(Bun, "file").mockImplementationOnce(() => ({
exists: () =>
new Promise(resolve => {
resolve(true);
}),
text: () =>
new Promise(resolve => {
resolve(stringify(mockConfig));
}),
}));
const config = await configManager.getConfig<typeof mockConfig>();
expect(config).toContainKeys(Object.keys(mockConfig));
});
it("should read the internal config file correctly", async () => {
const mockConfig = { key: "value" };
// @ts-expect-error This is a mock
spyOn(Bun, "file").mockImplementationOnce(() => ({
exists: () =>
new Promise(resolve => {
resolve(true);
}),
text: () =>
new Promise(resolve => {
resolve(stringify(mockConfig));
}),
}));
const config =
// @ts-expect-error Force call private function for testing
await configManager.readInternalConfig<typeof mockConfig>();
expect(config).toEqual(mockConfig);
});
it("should write to the internal config file correctly", async () => {
const mockConfig = { key: "value" };
spyOn(Bun, "write").mockImplementationOnce(
() =>
new Promise(resolve => {
resolve(10);
})
);
await configManager.writeConfig(mockConfig);
});
it("should merge configs correctly", () => {
const config1 = { key1: "value1", key2: "value2" };
const config2 = { key2: "newValue2", key3: "value3" };
// @ts-expect-error Force call private function for testing
const mergedConfig = configManager.mergeConfigs<Record<string, string>>(
config1,
config2
);
expect(mergedConfig).toEqual({
key1: "value1",
key2: "newValue2",
key3: "value3",
});
});
});

View file

@ -1,7 +1,6 @@
import { ConfigManager } from "config-manager";
import { config } from "config-manager";
// Proxies all `bunx prisma` commands with an environment variable
const config = await new ConfigManager({}).getConfig();
process.stdout.write(
`postgresql://${config.database.username}:${config.database.password}@${config.database.host}:${config.database.port}/${config.database.database}\n`

View file

@ -1,15 +1,14 @@
import { errorResponse, jsonResponse } from "@response";
import { matches } from "ip-matching";
import { getFromRequest } from "~database/entities/User";
import type { ConfigManager, ConfigType } from "config-manager";
import { type Config } from "config-manager";
import type { LogManager, MultiLogManager } from "log-manager";
import { LogLevel } from "log-manager";
import { RequestParser } from "request-parser";
import { matchRoute } from "~routes";
export const createServer = (
config: ConfigType,
configManager: ConfigManager,
config: Config,
logger: LogManager | MultiLogManager,
isProd: boolean
) =>
@ -182,8 +181,11 @@ export const createServer = (
return await file.default(req.clone(), matchedRoute, {
auth,
configManager,
parsedRequest,
// To avoid having to rewrite each route
configManager: {
getConfig: () => Promise.resolve(config),
},
});
} else if (matchedRoute?.name === "/[...404]" || !matchedRoute) {
if (new URL(req.url).pathname.startsWith("/api")) {

View file

@ -103,7 +103,7 @@ export default apiRoute<{
// Check if display name doesnt match filters
if (
config.filters.displayname_filters.some(filter =>
config.filters.displayname.some(filter =>
sanitizedDisplayName.match(filter)
)
) {
@ -126,11 +126,7 @@ export default apiRoute<{
}
// Check if bio doesnt match filters
if (
config.filters.bio_filters.some(filter =>
sanitizedNote.match(filter)
)
) {
if (config.filters.bio.some(filter => sanitizedNote.match(filter))) {
return errorResponse("Bio contains blocked words", 422);
}

View file

@ -89,7 +89,7 @@ export default apiRoute<{
content_type,
"poll[expires_in]": expires_in,
"poll[options]": options,
media_ids: media_ids,
media_ids,
spoiler_text,
sensitive,
} = extraData.parsedRequest;
@ -181,7 +181,7 @@ export default apiRoute<{
// Check if status body doesnt match filters
if (
config.filters.note_filters.some(filter =>
config.filters.note_content.some(filter =>
statusText?.match(filter)
)
) {

View file

@ -194,7 +194,7 @@ export default apiRoute<{
}
// Check if status body doesnt match filters
if (config.filters.note_filters.some(filter => status?.match(filter))) {
if (config.filters.note_content.some(filter => status?.match(filter))) {
return errorResponse("Status contains blocked words", 422);
}

View file

@ -1,5 +1,5 @@
import type { MatchedRoute } from "bun";
import type { ConfigManager } from "config-manager";
import type { Config } from "config-manager";
import type { AuthData } from "~database/entities/User";
export type RouteHandler<T> = (
@ -8,6 +8,8 @@ export type RouteHandler<T> = (
extraData: {
auth: AuthData;
parsedRequest: Partial<T>;
configManager: ConfigManager;
configManager: {
getConfig: () => Promise<Config>;
};
}
) => Response | Promise<Response>;

View file

@ -1,6 +1,6 @@
import type { Token } from "@prisma/client";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { ConfigManager } from "config-manager";
import { config } from "config-manager";
import { client } from "~database/datasource";
import { TokenType } from "~database/entities/Token";
import {
@ -11,7 +11,6 @@ import type { APIEmoji } from "~types/entities/emoji";
import type { APIInstance } from "~types/entities/instance";
import { sendTestRequest, wrapRelativeUrl } from "./utils";
const config = await new ConfigManager({}).getConfig();
const base_url = config.http.base_url;
let token: Token;

View file

@ -9,10 +9,9 @@ import {
import type { APIAccount } from "~types/entities/account";
import type { APIRelationship } from "~types/entities/relationship";
import type { APIStatus } from "~types/entities/status";
import { ConfigManager } from "config-manager";
import { config } from "config-manager";
import { sendTestRequest, wrapRelativeUrl } from "~tests/utils";
const config = await new ConfigManager({}).getConfig();
const base_url = config.http.base_url;
let token: Token;

View file

@ -10,10 +10,9 @@ import type { APIAccount } from "~types/entities/account";
import type { APIAsyncAttachment } from "~types/entities/async_attachment";
import type { APIContext } from "~types/entities/context";
import type { APIStatus } from "~types/entities/status";
import { ConfigManager } from "config-manager";
import { config } from "config-manager";
import { sendTestRequest, wrapRelativeUrl } from "~tests/utils";
const config = await new ConfigManager({}).getConfig();
const base_url = config.http.base_url;
let token: Token;

View file

@ -4,7 +4,6 @@ import { client } from "~database/datasource";
import { createNewLocalUser } from "~database/entities/User";
import { sendTestRequest, wrapRelativeUrl } from "./utils";
// const config = await new ConfigManager({}).getConfig();
const base_url = "http://lysand.localhost:8080"; //config.http.base_url;
let client_id: string;

View file

@ -1,9 +1,7 @@
import { ConfigManager } from "config-manager";
import { config } from "config-manager";
import type { RouteHandler } from "~server/api/routes.type";
import type { APIRouteMeta } from "~types/api";
const config = await new ConfigManager({}).getConfig();
export const applyConfig = (routeMeta: APIRouteMeta) => {
const newMeta = routeMeta;

View file

@ -1,6 +1,4 @@
import { ConfigManager } from "config-manager";
const config = await new ConfigManager({}).getConfig();
import { config } from "config-manager";
export const oauthRedirectUri = (issuer: string) =>
`${config.http.base_url}/oauth/callback/${issuer}`;

View file

@ -2,11 +2,9 @@ import chalk from "chalk";
import { client } from "~database/datasource";
import { Meilisearch } from "meilisearch";
import type { Status, User } from "@prisma/client";
import { ConfigManager } from "config-manager";
import { config } from "config-manager";
import { LogLevel, type LogManager, type MultiLogManager } from "log-manager";
const config = await new ConfigManager({}).getConfig();
export const meilisearch = new Meilisearch({
host: `${config.meilisearch.host}:${config.meilisearch.port}`,
apiKey: config.meilisearch.api_key,

View file

@ -1,17 +1,15 @@
import type { Prisma } from "@prisma/client";
import chalk from "chalk";
import { ConfigManager } from "config-manager";
import { config } from "config-manager";
import Redis from "ioredis";
import { createPrismaRedisCache } from "prisma-redis-middleware";
const config = await new ConfigManager({}).getConfig();
const cacheRedis = config.redis.cache.enabled
? new Redis({
host: config.redis.cache.host,
port: Number(config.redis.cache.port),
password: config.redis.cache.password,
db: Number(config.redis.cache.database ?? 0),
db: Number(config.redis.cache.database),
})
: null;

View file

@ -1,9 +1,7 @@
import { ConfigManager } from "config-manager";
import { config } from "config-manager";
import { sanitize } from "isomorphic-dompurify";
export const sanitizeHtml = async (html: string) => {
const config = await new ConfigManager({}).getConfig();
const sanitizedHtml = sanitize(html, {
ALLOWED_TAGS: [
"a",