feat(plugin): Add override settings to plugin loading

This commit is contained in:
Jesse Wierzbinski 2024-10-06 15:55:15 +02:00
parent c0805ff125
commit f26ab0f0e6
No known key found for this signature in database
7 changed files with 705 additions and 548 deletions

View file

@ -287,6 +287,6 @@ max_coeff = 1.0
[plugins] [plugins]
[plugins."@versia/openid".keys] [plugins.config."@versia/openid".keys]
private = "MC4CAQAwBQYDK2VwBCIEID+H5n9PY3zVKZQcq4jrnE1IiRd2EWWr8ApuHUXmuOzl" private = "MC4CAQAwBQYDK2VwBCIEID+H5n9PY3zVKZQcq4jrnE1IiRd2EWWr8ApuHUXmuOzl"
public = "MCowBQYDK2VwAyEAzenliNkgpXYsh3gXTnAoUWzlCPjIOppmAVx2DBlLsC8=" public = "MCowBQYDK2VwAyEAzenliNkgpXYsh3gXTnAoUWzlCPjIOppmAVx2DBlLsC8="

11
app.ts
View file

@ -119,19 +119,24 @@ export const appFactory = async () => {
const loader = new PluginLoader(); const loader = new PluginLoader();
const plugins = await loader.loadPlugins(join(process.cwd(), "plugins")); const plugins = await loader.loadPlugins(
join(process.cwd(), "plugins"),
config.plugins?.autoload,
config.plugins?.overrides.enabled,
config.plugins?.overrides.disabled,
);
for (const data of plugins) { for (const data of plugins) {
serverLogger.info`Loading plugin ${chalk.blueBright(data.manifest.name)} ${chalk.blueBright(data.manifest.version)} ${chalk.gray(`[${plugins.indexOf(data) + 1}/${plugins.length}]`)}`; serverLogger.info`Loading plugin ${chalk.blueBright(data.manifest.name)} ${chalk.blueBright(data.manifest.version)} ${chalk.gray(`[${plugins.indexOf(data) + 1}/${plugins.length}]`)}`;
try { try {
// biome-ignore lint/complexity/useLiteralKeys: loadConfig is a private method // biome-ignore lint/complexity/useLiteralKeys: loadConfig is a private method
await data.plugin["_loadConfig"]( await data.plugin["_loadConfig"](
config.plugins?.[data.manifest.name], config.plugins?.config?.[data.manifest.name],
); );
} catch (e) { } catch (e) {
serverLogger.fatal`Plugin configuration is invalid: ${chalk.redBright(e as ValidationError)}`; serverLogger.fatal`Plugin configuration is invalid: ${chalk.redBright(e as ValidationError)}`;
serverLogger.fatal`Put your configuration at ${chalk.blueBright( serverLogger.fatal`Put your configuration at ${chalk.blueBright(
"plugins.<plugin-name>", "plugins.config.<plugin-name>",
)}`; )}`;
throw new Error("Plugin configuration is invalid"); throw new Error("Plugin configuration is invalid");
} }

View file

@ -201,7 +201,7 @@ describe("PluginLoader", () => {
default: mockPlugin, default: mockPlugin,
})); }));
const plugins = await pluginLoader.loadPlugins("/some/path"); const plugins = await pluginLoader.loadPlugins("/some/path", true);
expect(plugins).toEqual([ expect(plugins).toEqual([
{ {
manifest: manifestContent, manifest: manifestContent,

View file

@ -162,12 +162,42 @@ export class PluginLoader {
*/ */
public async loadPlugins( public async loadPlugins(
dir: string, dir: string,
autoload: boolean,
enabled?: string[],
disabled?: string[],
): Promise<{ manifest: Manifest; plugin: Plugin<ZodTypeAny> }[]> { ): Promise<{ manifest: Manifest; plugin: Plugin<ZodTypeAny> }[]> {
const plugins = await PluginLoader.findPlugins(dir); const plugins = await PluginLoader.findPlugins(dir);
const enabledOn = (enabled?.length ?? 0) > 0;
const disabledOn = (disabled?.length ?? 0) > 0;
if (enabledOn && disabledOn) {
this.logger
.fatal`Both enabled and disabled lists are specified. Only one of them can be used.`;
throw new Error("Invalid configuration");
}
return Promise.all( return Promise.all(
plugins.map(async (plugin) => { plugins.map(async (plugin) => {
const manifest = await this.parseManifest(dir, plugin); const manifest = await this.parseManifest(dir, plugin);
// If autoload is disabled, only load plugins explicitly enabled
if (
!(autoload || enabledOn || enabled?.includes(manifest.name))
) {
return null;
}
// If enabled is specified, only load plugins in the enabled list
// If disabled is specified, only load plugins not in the disabled list
if (enabledOn && !enabled?.includes(manifest.name)) {
return null;
}
if (disabled?.includes(manifest.name)) {
return null;
}
const pluginInstance = await this.loadPlugin( const pluginInstance = await this.loadPlugin(
dir, dir,
`${plugin}/index`, `${plugin}/index`,
@ -175,6 +205,6 @@ export class PluginLoader {
return { manifest, plugin: pluginInstance }; return { manifest, plugin: pluginInstance };
}), }),
); ).then((data) => data.filter((d) => d !== null));
} }
} }

View file

@ -4006,10 +4006,44 @@
} }
}, },
"plugins": { "plugins": {
"type": "object",
"properties": {
"autoload": {
"type": "boolean",
"default": true
},
"overrides": {
"type": "object",
"properties": {
"enabled": {
"type": "array",
"items": {
"type": "string"
},
"default": []
},
"disabled": {
"type": "array",
"items": {
"type": "string"
},
"default": []
}
},
"additionalProperties": false,
"default": {
"enabled": [],
"disabled": []
}
},
"config": {
"type": "object", "type": "object",
"additionalProperties": {} "additionalProperties": {}
} }
}, },
"additionalProperties": false
}
},
"required": [ "required": [
"database", "database",
"redis", "redis",
@ -4019,7 +4053,8 @@
"http", "http",
"smtp", "smtp",
"filters", "filters",
"ratelimits" "ratelimits",
"plugins"
], ],
"additionalProperties": false, "additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#" "$schema": "http://json-schema.org/draft-07/schema#"

View file

@ -21,8 +21,10 @@ const zUrl = z
.refine((arg) => URL.canParse(arg), "Invalid url") .refine((arg) => URL.canParse(arg), "Invalid url")
.transform((arg) => arg.replace(/\/$/, "")); .transform((arg) => arg.replace(/\/$/, ""));
export const configValidator = z.object({ export const configValidator = z
database: z.object({ .object({
database: z
.object({
host: z.string().min(1).default("localhost"), host: z.string().min(1).default("localhost"),
port: z port: z
.number() .number()
@ -35,7 +37,8 @@ export const configValidator = z.object({
database: z.string().min(1).default("versia"), database: z.string().min(1).default("versia"),
replicas: z replicas: z
.array( .array(
z.object({ z
.object({
host: z.string().min(1), host: z.string().min(1),
port: z port: z
.number() .number()
@ -46,11 +49,14 @@ export const configValidator = z.object({
username: z.string().min(1), username: z.string().min(1),
password: z.string().default(""), password: z.string().default(""),
database: z.string().min(1).default("versia"), database: z.string().min(1).default("versia"),
}), })
.strict(),
) )
.optional(), .optional(),
}), })
redis: z.object({ .strict(),
redis: z
.object({
queue: z queue: z
.object({ .object({
host: z.string().min(1).default("localhost"), host: z.string().min(1).default("localhost"),
@ -64,6 +70,7 @@ export const configValidator = z.object({
database: z.number().int().default(0), database: z.number().int().default(0),
enabled: z.boolean().default(false), enabled: z.boolean().default(false),
}) })
.strict()
.default({ .default({
host: "localhost", host: "localhost",
port: 6379, port: 6379,
@ -84,6 +91,7 @@ export const configValidator = z.object({
database: z.number().int().default(1), database: z.number().int().default(1),
enabled: z.boolean().default(false), enabled: z.boolean().default(false),
}) })
.strict()
.default({ .default({
host: "localhost", host: "localhost",
port: 6379, port: 6379,
@ -91,8 +99,10 @@ export const configValidator = z.object({
database: 1, database: 1,
enabled: false, enabled: false,
}), }),
}), })
sonic: z.object({ .strict(),
sonic: z
.object({
host: z.string().min(1).default("localhost"), host: z.string().min(1).default("localhost"),
port: z port: z
.number() .number()
@ -102,24 +112,30 @@ export const configValidator = z.object({
.default(7700), .default(7700),
password: z.string(), password: z.string(),
enabled: z.boolean().default(false), enabled: z.boolean().default(false),
}), })
signups: z.object({ .strict(),
signups: z
.object({
registration: z.boolean().default(true), registration: z.boolean().default(true),
rules: z.array(z.string()).default([]), rules: z.array(z.string()).default([]),
}), })
oidc: z.object({ .strict(),
oidc: z
.object({
forced: z.boolean().default(false), forced: z.boolean().default(false),
allow_registration: z.boolean().default(true), allow_registration: z.boolean().default(true),
providers: z providers: z
.array( .array(
z.object({ z
.object({
name: z.string().min(1), name: z.string().min(1),
id: z.string().min(1), id: z.string().min(1),
url: z.string().min(1), url: z.string().min(1),
client_id: z.string().min(1), client_id: z.string().min(1),
client_secret: z.string().min(1), client_secret: z.string().min(1),
icon: z.string().min(1).optional(), icon: z.string().min(1).optional(),
}), })
.strict(),
) )
.default([]), .default([]),
keys: z keys: z
@ -127,9 +143,12 @@ export const configValidator = z.object({
public: z.string().min(1).optional(), public: z.string().min(1).optional(),
private: z.string().min(1).optional(), private: z.string().min(1).optional(),
}) })
.strict()
.optional(), .optional(),
}), })
http: z.object({ .strict(),
http: z
.object({
base_url: z.string().min(1).default("http://versia.social"), base_url: z.string().min(1).default("http://versia.social"),
bind: z.string().min(1).default("0.0.0.0"), bind: z.string().min(1).default("0.0.0.0"),
bind_port: z bind_port: z
@ -146,6 +165,7 @@ export const configValidator = z.object({
enabled: z.boolean().default(false), enabled: z.boolean().default(false),
address: zUrl.or(z.literal("")), address: zUrl.or(z.literal("")),
}) })
.strict()
.default({ .default({
enabled: false, enabled: false,
address: "", address: "",
@ -166,6 +186,7 @@ export const configValidator = z.object({
passphrase: z.string().optional(), passphrase: z.string().optional(),
ca: z.string().optional(), ca: z.string().optional(),
}) })
.strict()
.default({ .default({
enabled: false, enabled: false,
key: "", key: "",
@ -180,13 +201,15 @@ export const configValidator = z.object({
bait_ips: z.array(z.string()).default([]), bait_ips: z.array(z.string()).default([]),
bait_user_agents: z.array(z.string()).default([]), bait_user_agents: z.array(z.string()).default([]),
}) })
.strict()
.default({ .default({
enabled: false, enabled: false,
send_file: "", send_file: "",
bait_ips: [], bait_ips: [],
bait_user_agents: [], bait_user_agents: [],
}), }),
}), })
.strict(),
frontend: z frontend: z
.object({ .object({
enabled: z.boolean().default(true), enabled: z.boolean().default(true),
@ -199,6 +222,7 @@ export const configValidator = z.object({
register: zUrlPath.default("/register"), register: zUrlPath.default("/register"),
password_reset: zUrlPath.default("/oauth/reset"), password_reset: zUrlPath.default("/oauth/reset"),
}) })
.strict()
.default({ .default({
home: "/", home: "/",
login: "/oauth/authorize", login: "/oauth/authorize",
@ -208,6 +232,7 @@ export const configValidator = z.object({
}), }),
settings: z.record(z.string(), z.any()).default({}), settings: z.record(z.string(), z.any()).default({}),
}) })
.strict()
.default({ .default({
enabled: true, enabled: true,
url: "http://localhost:3000", url: "http://localhost:3000",
@ -227,6 +252,7 @@ export const configValidator = z.object({
tls: z.boolean().default(true), tls: z.boolean().default(true),
enabled: z.boolean().default(false), enabled: z.boolean().default(false),
}) })
.strict()
.default({ .default({
server: "", server: "",
port: 465, port: 465,
@ -248,12 +274,14 @@ export const configValidator = z.object({
convert_to: z.string().default("image/webp"), convert_to: z.string().default("image/webp"),
convert_vector: z.boolean().default(false), convert_vector: z.boolean().default(false),
}) })
.strict()
.default({ .default({
convert_images: false, convert_images: false,
convert_to: "image/webp", convert_to: "image/webp",
convert_vector: false, convert_vector: false,
}), }),
}) })
.strict()
.default({ .default({
backend: MediaBackendType.Local, backend: MediaBackendType.Local,
deduplicate_media: true, deduplicate_media: true,
@ -272,6 +300,7 @@ export const configValidator = z.object({
bucket_name: z.string().default("versia"), bucket_name: z.string().default("versia"),
public_url: zUrl, public_url: zUrl,
}) })
.strict()
.default({ .default({
endpoint: "", endpoint: "",
access_key: "", access_key: "",
@ -361,6 +390,7 @@ export const configValidator = z.object({
expiration: z.number().int().positive().default(300), expiration: z.number().int().positive().default(300),
key: z.string().default(""), key: z.string().default(""),
}) })
.strict()
.default({ .default({
enabled: true, enabled: true,
difficulty: 50000, difficulty: 50000,
@ -368,6 +398,7 @@ export const configValidator = z.object({
key: "", key: "",
}), }),
}) })
.strict()
.default({ .default({
max_displayname_size: 50, max_displayname_size: 50,
max_bio_size: 5000, max_bio_size: 5000,
@ -450,6 +481,7 @@ export const configValidator = z.object({
header: zUrl.optional(), header: zUrl.optional(),
placeholder_style: z.string().default("thumbs"), placeholder_style: z.string().default("thumbs"),
}) })
.strict()
.default({ .default({
visibility: "public", visibility: "public",
language: "en", language: "en",
@ -461,7 +493,8 @@ export const configValidator = z.object({
.object({ .object({
blocked: z.array(zUrl).default([]), blocked: z.array(zUrl).default([]),
followers_only: z.array(zUrl).default([]), followers_only: z.array(zUrl).default([]),
discard: z.object({ discard: z
.object({
reports: z.array(zUrl).default([]), reports: z.array(zUrl).default([]),
deletes: z.array(zUrl).default([]), deletes: z.array(zUrl).default([]),
updates: z.array(zUrl).default([]), updates: z.array(zUrl).default([]),
@ -471,7 +504,8 @@ export const configValidator = z.object({
reactions: z.array(zUrl).default([]), reactions: z.array(zUrl).default([]),
banners: z.array(zUrl).default([]), banners: z.array(zUrl).default([]),
avatars: z.array(zUrl).default([]), avatars: z.array(zUrl).default([]),
}), })
.strict(),
bridge: z bridge: z
.object({ .object({
enabled: z.boolean().default(false), enabled: z.boolean().default(false),
@ -480,6 +514,7 @@ export const configValidator = z.object({
token: z.string().default(""), token: z.string().default(""),
url: zUrl.optional(), url: zUrl.optional(),
}) })
.strict()
.default({ .default({
enabled: false, enabled: false,
software: "versia-ap", software: "versia-ap",
@ -491,6 +526,7 @@ export const configValidator = z.object({
"When bridge is enabled, url must be set", "When bridge is enabled, url must be set",
), ),
}) })
.strict()
.default({ .default({
blocked: [], blocked: [],
followers_only: [], followers_only: [],
@ -524,13 +560,19 @@ export const configValidator = z.object({
keys: z keys: z
.object({ .object({
public: z.string().min(3).default("").or(z.literal("")), public: z.string().min(3).default("").or(z.literal("")),
private: z.string().min(3).default("").or(z.literal("")), private: z
.string()
.min(3)
.default("")
.or(z.literal("")),
}) })
.strict()
.default({ .default({
public: "", public: "",
private: "", private: "",
}), }),
}) })
.strict()
.default({ .default({
name: "Versia", name: "Versia",
description: "A Versia instance", description: "A Versia instance",
@ -552,20 +594,25 @@ export const configValidator = z.object({
default: z default: z
.array(z.nativeEnum(RolePermissions)) .array(z.nativeEnum(RolePermissions))
.default(DEFAULT_ROLES), .default(DEFAULT_ROLES),
admin: z.array(z.nativeEnum(RolePermissions)).default(ADMIN_ROLES), admin: z
.array(z.nativeEnum(RolePermissions))
.default(ADMIN_ROLES),
}) })
.strict()
.default({ .default({
anonymous: DEFAULT_ROLES, anonymous: DEFAULT_ROLES,
default: DEFAULT_ROLES, default: DEFAULT_ROLES,
admin: ADMIN_ROLES, admin: ADMIN_ROLES,
}), }),
filters: z.object({ filters: z
.object({
note_content: z.array(z.string()).default([]), note_content: z.array(z.string()).default([]),
emoji: z.array(z.string()).default([]), emoji: z.array(z.string()).default([]),
username: z.array(z.string()).default([]), username: z.array(z.string()).default([]),
displayname: z.array(z.string()).default([]), displayname: z.array(z.string()).default([]),
bio: z.array(z.string()).default([]), bio: z.array(z.string()).default([]),
}), })
.strict(),
logging: z logging: z
.object({ .object({
log_requests: z.boolean().default(false), log_requests: z.boolean().default(false),
@ -582,11 +629,18 @@ export const configValidator = z.object({
dsn: z.string().url().or(z.literal("")).optional(), dsn: z.string().url().or(z.literal("")).optional(),
debug: z.boolean().default(false), debug: z.boolean().default(false),
sample_rate: z.number().min(0).max(1.0).default(1.0), sample_rate: z.number().min(0).max(1.0).default(1.0),
traces_sample_rate: z.number().min(0).max(1.0).default(1.0), traces_sample_rate: z
trace_propagation_targets: z.array(z.string()).default([]), .number()
.min(0)
.max(1.0)
.default(1.0),
trace_propagation_targets: z
.array(z.string())
.default([]),
max_breadcrumbs: z.number().default(100), max_breadcrumbs: z.number().default(100),
environment: z.string().optional(), environment: z.string().optional(),
}) })
.strict()
.default({ .default({
enabled: false, enabled: false,
debug: false, debug: false,
@ -602,10 +656,12 @@ export const configValidator = z.object({
.object({ .object({
requests: z.string().default("logs/requests.log"), requests: z.string().default("logs/requests.log"),
}) })
.strict()
.default({ .default({
requests: "logs/requests.log", requests: "logs/requests.log",
}), }),
}) })
.strict()
.default({ .default({
log_requests: false, log_requests: false,
log_responses: false, log_responses: false,
@ -624,27 +680,55 @@ export const configValidator = z.object({
requests: "logs/requests.log", requests: "logs/requests.log",
}, },
}), }),
ratelimits: z.object({ ratelimits: z
.object({
duration_coeff: z.number().default(1), duration_coeff: z.number().default(1),
max_coeff: z.number().default(1), max_coeff: z.number().default(1),
custom: z custom: z
.record( .record(
z.string(), z.string(),
z.object({ z
.object({
duration: z.number().default(30), duration: z.number().default(30),
max: z.number().default(60), max: z.number().default(60),
}), })
.strict(),
) )
.default({}), .default({}),
}), })
.strict(),
debug: z debug: z
.object({ .object({
federation: z.boolean().default(false), federation: z.boolean().default(false),
}) })
.strict()
.default({ .default({
federation: false, federation: false,
}), }),
plugins: z.record(z.string(), z.any()).optional(), plugins: z
}); .object({
autoload: z.boolean().default(true),
overrides: z
.object({
enabled: z.array(z.string()).default([]),
disabled: z.array(z.string()).default([]),
})
.strict()
.default({
enabled: [],
disabled: [],
})
.refine(
// Only one of enabled or disabled can be set
(arg) =>
arg.enabled.length === 0 ||
arg.disabled.length === 0,
"Only one of enabled or disabled can be set",
),
config: z.record(z.string(), z.any()).optional(),
})
.strict(),
})
.strict();
export type Config = z.infer<typeof configValidator>; export type Config = z.infer<typeof configValidator>;

View file

@ -14,7 +14,10 @@ const scope = "openid profile email";
const secret = "test-secret"; const secret = "test-secret";
const privateKey = await crypto.subtle.importKey( const privateKey = await crypto.subtle.importKey(
"pkcs8", "pkcs8",
Buffer.from(config.plugins?.["@versia/openid"].keys.private, "base64"), Buffer.from(
config.plugins?.config?.["@versia/openid"].keys.private,
"base64",
),
"Ed25519", "Ed25519",
false, false,
["sign"], ["sign"],