From 9d328784ec7b29712d47313207725b0398fa3abb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acid=20Chicken=20=28=E7=A1=AB=E9=85=B8=E9=B6=8F=29?= Date: Sun, 10 Feb 2019 03:19:43 +0900 Subject: [PATCH] Create Mika --- CONTRIBUTING.md | 1 + src/config/load.ts | 145 ++++++++++++++++++++--- src/prelude/array.ts | 15 +++ src/sanctuary/mika.ts | 230 ++++++++++++++++++++++++++++++++++++ src/sanctuary/tsconfig.json | 7 ++ 5 files changed, 382 insertions(+), 16 deletions(-) create mode 100644 src/sanctuary/mika.ts create mode 100644 src/sanctuary/tsconfig.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8ef60b556..26e3788a9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,6 +68,7 @@ src ... ソースコード server ... Webサーバー client ... クライアント mfm ... MFM + sanctuary ... TypeScriptの制約を強くしたエリア ~~乃々の聖域ではない~~ test ... テスト diff --git a/src/config/load.ts b/src/config/load.ts index 3a82d45b4..296f9e744 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -6,7 +6,9 @@ import * as fs from 'fs'; import { URL } from 'url'; import * as yaml from 'js-yaml'; import { Source, Mixin } from './types'; +import Mika, { optional } from '../sanctuary/mika'; import * as pkg from '../../package.json'; +import Logger from '../misc/logger'; /** * Path of configuration directory @@ -21,27 +23,138 @@ const path = process.env.NODE_ENV == 'test' : `${dir}/default.yml`; export default function load() { - const config = yaml.safeLoad(fs.readFileSync(path, 'utf-8')) as Source; + const config: Source = yaml.safeLoad(fs.readFileSync(path, 'utf-8')); - const mixin = {} as Mixin; + const logger = new Logger('config'); + + const errors = new Mika({ + repository_url: 'string!?', + feedback_url: 'string!?', + url: 'string!', + port: 'number!', + https: { + [optional]: true, + key: 'string!', + cert: 'string!' + }, + disableHsts: 'boolean!?', + mongodb: { + host: 'string!', + port: 'number!', + db: 'string!', + user: 'string!?', + pass: 'string!?' + }, + elasticsearch: { + [optional]: true, + host: 'string!', + port: 'number!', + pass: 'string!' + }, + drive: { + [optional]: true, + storage: 'string!?' + }, + redis: { + [optional]: true, + host: 'string!', + port: 'number!', + pass: 'string!' + }, + autoAdmin: 'boolean!?', + proxy: 'string!?', + accesslog: 'string!?', + clusterLimit: 'number!?', + // The below properties are defined for backward compatibility. + name: 'string!?', + description: 'string!?', + localDriveCapacityMb: 'number!?', + remoteDriveCapacityMb: 'number!?', + preventCacheRemoteFiles: 'boolean!?', + recaptcha: { + [optional]: true, + enableRecaptcha: 'boolean!?', + recaptchaSiteKey: 'string!?', + recaptchaSecretKey: 'string!?' + }, + ghost: 'string!?', + maintainer: { + [optional]: true, + name: 'string!', + email: 'string!?' + }, + twitter: { + [optional]: true, + consumer_key: 'string!?', + consumer_secret: 'string!?' + }, + github: { + [optional]: true, + client_id: 'string!?', + client_secret: 'string!?' + }, + user_recommendation: { + [optional]: true, + engine: 'string!?', + timeout: 'number!?' + }, + sw: { + [optional]: true, + public_key: 'string!', + private_key: 'string!' + } + }).validate(config); + + if (!errors && config.drive.storage === 'minio') { + const minioErrors = new Mika({ + bucket: 'string!', + prefix: 'string!', + baseUrl: 'string!', + config: { + endPoint: 'string!', + accessKey: 'string!', + secretKey: 'string!', + useSSL: 'boolean!?', + port: 'number!?', + region: 'string!?', + transport: 'string!?', + sessionToken: 'string!?', + } + }).validate(config.drive); + + if (minioErrors) + for (const error of minioErrors) + errors.push(error); + } + + if (errors) { + for (const { path, excepted, actual } of errors) + logger.error(`Invalid config value detected at ${path}: excepted type is ${typeof excepted === 'string' ? excepted : 'object'}, but actual value type is ${actual}`); + + throw 'The configuration is invalid. Check your .config/default.yml'; + } const url = validateUrl(config.url); - config.url = normalizeUrl(config.url); - mixin.host = url.host; - mixin.hostname = url.hostname; - mixin.scheme = url.protocol.replace(/:$/, ''); - mixin.ws_scheme = mixin.scheme.replace('http', 'ws'); - mixin.ws_url = `${mixin.ws_scheme}://${mixin.host}`; - mixin.api_url = `${mixin.scheme}://${mixin.host}/api`; - mixin.auth_url = `${mixin.scheme}://${mixin.host}/auth`; - mixin.dev_url = `${mixin.scheme}://${mixin.host}/dev`; - mixin.docs_url = `${mixin.scheme}://${mixin.host}/docs`; - mixin.stats_url = `${mixin.scheme}://${mixin.host}/stats`; - mixin.status_url = `${mixin.scheme}://${mixin.host}/status`; - mixin.drive_url = `${mixin.scheme}://${mixin.host}/files`; - mixin.user_agent = `Misskey/${pkg.version} (${config.url})`; + const scheme = url.protocol.replace(/:$/, ''); + const ws_scheme = scheme.replace('http', 'ws'); + + const mixin: Mixin = { + host: url.host, + hostname: url.hostname, + scheme, + ws_scheme, + ws_url: `${ws_scheme}://${url.host}`, + api_url: `${scheme}://${url.host}/api`, + auth_url: `${scheme}://${url.host}/auth`, + dev_url: `${scheme}://${url.host}/dev`, + docs_url: `${scheme}://${url.host}/docs`, + stats_url: `${scheme}://${url.host}/stats`, + status_url: `${scheme}://${url.host}/status`, + drive_url: `${scheme}://${url.host}/files`, + user_agent: `Misskey/${pkg.version} (${config.url})` + }; if (config.autoAdmin == null) config.autoAdmin = false; diff --git a/src/prelude/array.ts b/src/prelude/array.ts index 560dfa080..ef955ccfe 100644 --- a/src/prelude/array.ts +++ b/src/prelude/array.ts @@ -115,3 +115,18 @@ export function cumulativeSum(xs: number[]): number[] { for (let i = 1; i < ys.length; i++) ys[i] += ys[i - 1]; return ys; } + +export function toEnglishString(x: string[], n = 'and'): string { + switch (x.length) { + case 0: + return ''; + + case 1: + return x[0]; + + default: + const y = [...x]; + const z = y.pop(); + return `${y.join(', ')}, ${n} ${z}`; + } +} diff --git a/src/sanctuary/mika.ts b/src/sanctuary/mika.ts new file mode 100644 index 000000000..f7d562e81 --- /dev/null +++ b/src/sanctuary/mika.ts @@ -0,0 +1,230 @@ +/* tslint:enable:strict-type-predicates triple-equals */ + +import { toEnglishString } from '../prelude/array'; + +/* KEYWORD DEFINITION + * { sakura: null } // 'sakura' is null. + * { izumi: undefined } // 'izumi' is undefined. + * {} // 'ako' is unprovided (not undefined in here). + * + * Reason: The undefined is a type, so you can define undefined. + */ + +export const additional = Symbol('Allows additional properties.'); +export const optional = Symbol('Allows unprovided (not undefined).'); +export const nullable = Symbol('Allows null.'); + +type Type = 'string' | 'number' | 'bigint' | 'boolean' | 'symbol' | 'undefined' | 'object' | 'function'; +type ExtendedType = Type | 'null' | 'unprovided'; + +type Everything = string | number | bigint | boolean | symbol | undefined | object | Function | null; + +/** + * Manifest + * Information for + * Reliance + * Identify + * Analysis + */ +type Miria = { + /** STRING TYPED SYNTAX + * type1 : Allows the type1 and null. + * type1|type2 : Allows type1, type2, and null. + * type1! : Allows type1 only. + * type1? : Allows type1, null, and unprovided. + * type1!? : Allows type1, and unprovided. + * + * (! and ? are suffix.) + * (The spaces(U+0020) are ignored.) + */ + [x: string]: string | Miria; + [additional]?: boolean; + [optional]?: boolean; + [nullable]?: boolean; +}; + +/** + * Reason + * Information for + * Keeping safety by + * Analysis + */ +type Rika = { + path: string; + excepted: string | Miria | null; + actual: ExtendedType; +}; + +type MiriaInternal = { + [x: string]: string | MiriaInternal | undefined; + [additional]?: boolean; + [optional]?: boolean; + [nullable]?: boolean; +}; + +const $ = () => {}; // trash + +// ^ https://github.com/Microsoft/TypeScript/issues/7061 + +/** + * Manifest based + * Identify objects for + * Keeping safety by this + * Analyzer class + */ +export default class Mika { + private static readonly fuhihi = 'Miria'; // < https://github.com/Microsoft/TypeScript/issues/1579 + + protected static readonly types = ['string', 'number', 'bigint', 'boolean', 'symbol', 'undefined', 'object', 'function']; + + protected static readonly syntax = new RegExp(`^\\s*(?:${Mika.types.join('|')})(?:\\s*\\|\\s*(?:${Mika.types.join('|')}))*\\s*!?\\s*\\??\\s*$`); + + public static readonly additional = additional; + + public static readonly optional = optional; + + public static readonly nullable = nullable; + + constructor( + readonly miria: Miria + ) { + Mika.ensure(miria, [], Object.keys({ miria })[0]); + // ^~~ #1579 (see above) ~~^ + } + + private static ensure( + source: Miria, + location: string[], + nameof: string + ) { + const errorMessage = `Specified ${nameof} is invalid.`; + + for (const [k, v] of Object.entries(source)) { + const invalidType = !['string', 'object'].includes(typeof v); + const header = () => `${errorMessage} ${[...location, k].join('.')} is`; + + if (invalidType || v === null) + throw `${header()} ${invalidType ? `${typeof v === 'undefined' ? 'an' : 'a'} ${typeof v}, neither string or object(: ${Mika.fuhihi})` : 'is null'}.`; + + if (typeof v === 'string' && !Mika.syntax.test(v)) + throw `${header()} '${v}', neither ${toEnglishString([...Mika.types, 'combined theirs'], 'or')}.`; + + if (typeof v === 'object') + this.ensure(v, [...location, k], errorMessage); + } + } + + protected static validateFromString( + target: Everything, + source: string, + location: string[] = [] + ) { + const path = location.join('.'); + const rikas: Rika[] = []; + const excepted = source.replace(/\s/, ''); + /*let it move! v + */let lastSpan = excepted.length - 1; + const optional = excepted[lastSpan] === '?'; + const nullable = excepted[optional ? --lastSpan : lastSpan] !== '!'; + const allowing = excepted.slice(0, nullable ? --lastSpan : lastSpan).split('|'); + const pushRika = (actual: ExtendedType) => rikas.push({ path, excepted, actual }); + + if (target === null) + (nullable || pushRika('null'), $)(); + else if (!allowing.includes(typeof target)) + pushRika(typeof target); + + return rikas; + } + + protected dive( + target: object, + source: MiriaInternal, + location: string[] = [] + ) { + const rikas: Rika[] = []; + /** DEFINITION + * | values | specs | given | + * |-----------:|:------|:------| + * | true | true | true | + * | false | false | true | + * | null | true | false | + * | unprovided | false | false | + */ + const keys = Object.keys(source).reduce>((a, c) => (a[c] = null, a), {}); + + for (const [k, v] of Object.entries(target) as [string, Everything][]) { + const inclusion = keys[k] !== undefined; + const x = source[k]; + const miria = x as Miria; + const here = [...location, k]; + const path = here.join('.'); + const pushRika = (actual: ExtendedType, excepted?: string | Miria | null) => rikas.push({ + path, + excepted: excepted === undefined ? miria : excepted, + actual + }); + const pushRikas = (iterable: Iterable) => { + for (const rika of iterable) + rikas.push(rika); + }; + + keys[k] = inclusion; + + if (!inclusion && !source[additional]) + pushRika(v === null ? 'null' : typeof v, null); + else if (typeof x === 'undefined') + continue; + else if (typeof x === 'string') + pushRikas(Mika.validateFromString(v, x, here)); + else if (v === undefined) + (x[optional] || pushRika(inclusion ? 'undefined' : 'unprovided'), $)(); + else if (v === null) + (x[nullable] || pushRika('null'), $)(); + else if (typeof v === 'object') + pushRikas(this.dive(v, x, here)); + else + pushRika(typeof v); + } + + for (const [k, v] of Object.entries(source as Miria).filter(([k]) => keys[k] === null)) { + const rika: Rika = { + path: [...location, k].join('.'), + excepted: v, + actual: 'unprovided' + }; + + if (typeof v === 'string') + (v.endsWith('?') || rikas.push(rika), $)(); + else if (!v[optional]) + rikas.push(rika); + } + + return rikas; + } + + /** + * Validates object. + * @param x The source object. + * @returns The difference points when the source object fails validation, otherwise null. + */ + public validate( + x: object + ) { + const root = (actual: ExtendedType): Rika[] => [{ + path: '', + excepted: this.miria, + actual + }]; + + if (typeof x !== 'object') + return root(typeof x); + + if (x === null) + return this.miria[nullable] ? null : root('null'); + + const rikas = this.dive(x, this.miria); + + return rikas.length ? rikas : null; + } +} diff --git a/src/sanctuary/tsconfig.json b/src/sanctuary/tsconfig.json new file mode 100644 index 000000000..ffb0e4e90 --- /dev/null +++ b/src/sanctuary/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "esnext", + "strictNullChecks": true + } +}