diff options
author | Diego Magdaleno <diegomagdaleno@protonmail.com> | 2021-05-19 20:39:31 -0500 |
---|---|---|
committer | Diego Magdaleno <diegomagdaleno@protonmail.com> | 2021-05-19 20:39:31 -0500 |
commit | e3f6a29df79865ae9a0d842ba5d59a2851894081 (patch) | |
tree | 079b93be825cae82a66912c61d38a5fbb28f87be /src/util/Config.ts | |
parent | Config: Start working on the config refactor (diff) | |
download | server-e3f6a29df79865ae9a0d842ba5d59a2851894081.tar.xz |
Config: First rewrite of config and working implementation of getting values
Diffstat (limited to 'src/util/Config.ts')
-rw-r--r-- | src/util/Config.ts | 398 |
1 files changed, 324 insertions, 74 deletions
diff --git a/src/util/Config.ts b/src/util/Config.ts index 97322f9e..b3d23179 100644 --- a/src/util/Config.ts +++ b/src/util/Config.ts @@ -1,4 +1,12 @@ -import Ajv, {JTDSchemaType} from "ajv/dist/jtd" +import Ajv, {JSONSchemaType} from "ajv" +import {ValidateFunction} from 'ajv' +import ajvFormats from 'ajv-formats'; +import dotProp from "dot-prop"; +import envPaths from "env-paths"; +import path from "node:path"; +import fs from 'fs' +import assert from "assert"; +import atomically from "atomically" export interface RateLimitOptions { count: number; @@ -6,6 +14,7 @@ export interface RateLimitOptions { } export interface DefaultOptions { + gateway: string; general: { instance_id: string; }; @@ -69,13 +78,13 @@ export interface DefaultOptions { }; register: { email: { - required: boolean; + necessary: boolean; allowlist: boolean; blocklist: boolean; domains: string[]; }; dateOfBirth: { - required: boolean; + necessary: boolean; minimum: number; // in years }; requireCaptcha: boolean; @@ -92,139 +101,380 @@ export interface DefaultOptions { }; } -const schema: JTDSchemaType<DefaultOptions, {rateLimitOptions: RateLimitOptions}> = { +const schema: JSONSchemaType<DefaultOptions> & { + definitions: { + rateLimitOptions: JSONSchemaType<RateLimitOptions> + } +} = { + type: "object", definitions: { rateLimitOptions: { + type: "object", properties: { - count: {type: "int32"}, - timespan: {type: "int32"} - } - } + count: {type: "number"}, + timespan: {type: "number"}, + }, + required: ["count", "timespan"], + }, }, properties: { + gateway: { + type: "string" + }, general: { + type: "object", properties: { - instance_id: {type: "string"} - } + instance_id: { + type: "string" + } + }, + required: ["instance_id"], + additionalProperties: false }, permissions: { + type: "object", properties: { user: { + type: "object", properties: { - createGuilds: {type: "boolean"} - } + createGuilds: { + type: "boolean" + } + }, + required: ["createGuilds"], + additionalProperties: false } - } + }, + required: ["user"], + additionalProperties: false }, limits: { + type: "object", properties: { user: { + type: "object", properties: { - maxGuilds: {type: "int32"}, - maxFriends: {type: "int32"}, - maxUsername: {type: "int32"} - } + maxFriends: { + type: "number" + }, + maxGuilds: { + type: "number" + }, + maxUsername: { + type: "number" + } + }, + required: ["maxFriends", "maxGuilds", "maxUsername"], + additionalProperties: false }, guild: { + type: "object", properties: { - maxRoles: {type: "int32"}, - maxMembers: {type: "int32"}, - maxChannels: {type: "int32"}, - maxChannelsInCategory: {type: "int32"}, - hideOfflineMember: {type: "int32"} - } + maxRoles: { + type: "number" + }, + maxMembers: { + type: "number" + }, + maxChannels: { + type: "number" + }, + maxChannelsInCategory: { + type: "number" + }, + hideOfflineMember: { + type: "number" + } + }, + required: ["maxRoles", "maxMembers", "maxChannels", "maxChannelsInCategory", "hideOfflineMember"], + additionalProperties: false }, message: { + type: "object", properties: { - characters: {type: "int32"}, - ttsCharacters: {type: "int32"}, - maxReactions: {type: "int32"}, - maxAttachmentSize: {type: "int32"}, - maxBulkDelete: {type: "int32"} - } + characters: { + type: "number" + }, + ttsCharacters: { + type: "number" + }, + maxReactions: { + type: "number" + }, + maxAttachmentSize: { + type: "number" + }, + maxBulkDelete: { + type: "number" + } + }, + required: ["characters", "ttsCharacters", "maxReactions", "maxAttachmentSize", "maxBulkDelete"], + additionalProperties: false }, channel: { + type: "object", properties: { - maxPins: {type: "int32"}, - maxTopic: {type: "int32"}, + maxPins: { + type: "number" + }, + maxTopic: { + type: "number" + } }, + required: ["maxPins", "maxTopic"], + additionalProperties: false }, rate: { + type: "object", properties: { ip: { + type: "object", properties: { enabled: {type: "boolean"}, - count: {type: "int32"}, - timespan: {type: "int32"}, - } + count: {type: "number"}, + timespan: {type: "number"} + }, + required: ["enabled", "count", "timespan"], + additionalProperties: false }, routes: { - optionalProperties: { + type: "object", + properties: { auth: { - optionalProperties: { - login: {ref: 'rateLimitOptions'}, - register: {ref: 'rateLimitOptions'} - } + type: "object", + properties: { + login: {$ref: '#/definitions/rateLimitOptions'}, + register: {$ref: '#/definitions/rateLimitOptions'} + }, + nullable: true, + required: [], + additionalProperties: false }, - channel: {type: "string"} - } + channel: { + type: "string", + nullable: true + } + }, + required: [], + additionalProperties: false } - } + }, + required: ["ip", "routes"] } - } + }, + required: ["channel", "guild", "message", "rate", "user"], + additionalProperties: false }, security: { + type: "object", properties: { - jwtSecret: {type: "string"}, - forwadedFor: {type: "string", nullable: true}, + jwtSecret: { + type: "string" + }, + forwadedFor: { + type: "string", + nullable: true + }, captcha: { + type: "object", properties: { enabled: {type: "boolean"}, - service: {enum: ['hcaptcha', 'recaptcha'], nullable: true}, - sitekey: {type: "string", nullable: true}, - secret: {type: "string", nullable: true} - } + service: { + type: "string", + enum: ["hcaptcha", "recaptcha", null], + nullable: true + }, + sitekey: { + type: "string", + nullable: true + }, + secret: { + type: "string", + nullable: true + } + }, + required: ["enabled", "secret", "service", "sitekey"], + additionalProperties: false } - } + }, + required: ["captcha", "forwadedFor", "jwtSecret"], + additionalProperties: false }, login: { + type: "object", properties: { requireCaptcha: {type: "boolean"} - } + }, + required: ["requireCaptcha"], + additionalProperties: false }, register: { + type: "object", properties: { email: { + type: "object", properties: { - required: {type: "boolean"}, + necessary: {type: "boolean"}, allowlist: {type: "boolean"}, blocklist: {type: "boolean"}, - domains: { elements: { - type: "string" + domains: { + type: "array", + items: { + type: "string" + } } - } - } - }, - dateOfBirth: { - properties: { - required: {type: "boolean"}, - minimum: {type: "int32"} + }, + required: ["allowlist", "blocklist", "domains", "necessary"], + additionalProperties: false + }, + dateOfBirth: { + type: "object", + properties: { + necessary: {type: "boolean"}, + minimum: {type: "number"} + }, + required: ["minimum", "necessary"], + additionalProperties: false + }, + requireCaptcha: {type: "boolean"}, + requireInvite: {type: "boolean"}, + allowNewRegistration: {type: "boolean"}, + allowMultipleAccounts: {type: "boolean"}, + password: { + type: "object", + properties: { + minLength: {type: "number"}, + minNumbers: {type: "number"}, + minUpperCase: {type: "number"}, + minSymbols: {type: "number"}, + blockInsecureCommonPasswords: {type: "boolean"} + }, + required: ["minLength", "minNumbers", "minUpperCase", "minSymbols", "blockInsecureCommonPasswords"], + additionalProperties: false } }, - requireCaptcha: {type: "boolean"}, - requireInvite: {type: "boolean"}, - allowNewRegistration: {type: "boolean"}, - allowMultipleAccounts: {type: "boolean"}, - password: { - properties: { - minLength: {type: "int32"}, - minNumbers: {type: "int32"}, - minUpperCase: {type: "int32"}, - minSymbols: {type: "int32"}, - blockInsecureCommonPasswords: {type: "boolean"} - } + required: ["allowMultipleAccounts", "allowNewRegistration", "dateOfBirth", "email", "password", "requireCaptcha", "requireInvite"], + additionalProperties: false + }, + }, + required: ["gateway", "general", "limits", "login", "permissions", "register", "security"], + additionalProperties: false +} + + +const createPlainObject = <T = unknown>(): T => { + return Object.create(null); +}; +type Serialize<T> = (value: T) => string; +type Deserialize<T> = (text: string) => T; + + +class Config<T extends Record<string, any> = Record<string, unknown>> implements Iterable<[keyof T, T[keyof T]]> { + readonly path: string; + readonly #validator?: ValidateFunction; + readonly #defaultOptions: Partial<T> = {}; + + constructor() { + + const ajv = new Ajv(); + + ajvFormats(ajv); + + this.#validator = ajv.compile(schema); + + const base = envPaths('fosscord').config; + + this.path = path.resolve(base, 'api.json'); + + + const fileStore = this.store; + const store = Object.assign(createPlainObject<T>(), fileStore); + this._validate(store); + + try { + assert.deepStrictEqual(fileStore, store); + } catch { + this.store = store; + } + } + + private _validate(data: T | unknown): void { + if (!this.#validator) { + return; + } + + const valid = this.#validator(data); + if (valid || !this.#validator.errors) { + return; + } + + const errors = this.#validator.errors.map(({instancePath, message = ''}) => `\`${instancePath.slice(1)}\` ${message}`); + throw new Error('The config schema was violated!: ' + errors.join('; ')); + } + + get store(): T { + try { + const data = fs.readFileSync(this.path).toString(); + const deserializedData = this._deserialize(data); + this._validate(deserializedData); + return Object.assign(Object.create(null), deserializedData) + } catch (error) { + if (error.code == 'ENOENT') { + this._ensureDirectory(); + return createPlainObject(); + } + + throw error; + } + } + + private _ensureDirectory(): void { + fs.mkdirSync(path.dirname(this.path), {recursive: true}) + } + + set store(value: T) { + this._validate(value); + + this._write(value); + } + + private readonly _deserialize: Deserialize<T> = value => JSON.parse(value); + private readonly _serialize: Serialize<T> = value => JSON.stringify(value, undefined, '\t') + + get<Key extends keyof T>(key: Key): T[Key]; + get<Key extends keyof T>(key: Key, defaultValue: Required<T>[Key]): Required<T>[Key]; + get<Key extends string, Value = unknown>(key: Exclude<Key, keyof T>, defaultValue?: Value): Value; + get(key: string, defaultValue?: unknown): unknown { + return this._get(key, defaultValue); + } + + private _get<Key extends keyof T>(key: Key): T[Key] | undefined; + private _get<Key extends keyof T, Default = unknown>(key: Key, defaultValue: Default): T[Key] | Default; + private _get<Key extends keyof T, Default = unknown>(key: Key | string, defaultValue?: Default): Default | undefined { + return dotProp.get<T[Key] | undefined>(this.store, key as string, defaultValue as T[Key]); + } + + * [Symbol.iterator](): IterableIterator<[keyof T, T[keyof T]]> { + for (const [key, value] of Object.entries(this.store)) { + yield [key, value]; + } + } + + private _write(value: T): void { + let data: string | Buffer = this._serialize(value); + + try { + atomically.writeFileSync(this.path, data); + } catch (error) { + if (error.code == 'EXDEV') { + fs.writeFileSync(this.path, data) + return + } + + throw error; } } } -} \ No newline at end of file + +export const apiConfig = new Config(); \ No newline at end of file |