summary refs log tree commit diff
path: root/src/util/Config.ts
diff options
context:
space:
mode:
authorDiego Magdaleno <diegomagdaleno@protonmail.com>2021-05-19 20:39:31 -0500
committerDiego Magdaleno <diegomagdaleno@protonmail.com>2021-05-19 20:39:31 -0500
commite3f6a29df79865ae9a0d842ba5d59a2851894081 (patch)
tree079b93be825cae82a66912c61d38a5fbb28f87be /src/util/Config.ts
parentConfig: Start working on the config refactor (diff)
downloadserver-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.ts398
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