emilepharand/Babilonia

View on GitHub
server/model/inputValidator.ts

Summary

Maintainability
B
4 hrs
Test Coverage
import Ajv from 'ajv';
import fs from 'fs';
import path from 'path';
import type {ExpressionForAdding} from './ideas/expression';
import type {IdeaForAdding} from './ideas/ideaForAdding';
import {validateSchema as validateIdeaForAddingSchema} from './ideas/ideaForAdding';
import type {Language} from './languages/language';
import {validateForAdding, validate as validateLanguage} from './languages/language';
import type LanguageManager from './languages/languageManager';
import {validateSchema as validateSettingsSchema} from './settings/settings';

// Validates input received by the controller
export default class InputValidator {
    public static isValidOrdering(orderings: number[]): boolean {
        const orderingsSet = new Set<number>();
        orderings.forEach(o => orderingsSet.add(o));
        for (let i = 0; i < orderings.length; i += 1) {
            if (!orderingsSet.has(i)) {
                return false;
            }
        }
        return true;
    }

    constructor(private readonly lm: LanguageManager) {}

    public async validateLanguagesForEditing(toValidate: unknown): Promise<boolean> {
        // Object is an array
        if (!Array.isArray(toValidate)) {
            return false;
        }
        const ll = toValidate as Language[];
        // All languages exist
        const promises: Array<Promise<boolean>> = [];
        const languageIds = new Set(Array.from(ll.values(), l => l.id));
        languageIds.forEach(id => promises.push(this.lm.idExists(id)));
        if (!(await Promise.all(promises)).every(exist => exist)) {
            return false;
        }
        // Each language is valid
        if (ll.some(l => !validateLanguage(l))) {
            return false;
        }
        // No language name is blank
        if (ll.some(l => l.name.trim() === '')) {
            return false;
        }
        // There are no duplicate language ids
        if ((await this.lm.countLanguages()) !== ll.length) {
            return false;
        }
        // There are no duplicate language names
        const names = new Set(Array.from(ll.values(), l => l.name));
        if (names.size !== ll.length) {
            return false;
        }
        // Ordering is valid
        return InputValidator.isValidOrdering(ll.map(l => l.ordering));
    }

    public async validateLanguageForAdding(toValidate: unknown): Promise<boolean> {
        if (!validateForAdding(toValidate)) {
            return false;
        }
        const l = toValidate as {name: string};
        if (l.name.trim() === '') {
            return false;
        }
        return !(await this.lm.languageNameExists(l.name));
    }

    public async validateIdeaForAdding(ideaForAdding: IdeaForAdding): Promise<boolean> {
        // Shape is valid (properties and their types)
        if (!validateIdeaForAddingSchema(ideaForAdding)) {
            return false;
        }
        const asIdeaForAdding = ideaForAdding;
        // Contains at least one expression
        if (asIdeaForAdding.ee.length === 0) {
            return false;
        }
        // No expressions are blank
        if (asIdeaForAdding.ee.some(e => e.text.trim() === '')) {
            return false;
        }
        // All languages exist
        const languagesExist: Array<Promise<boolean>> = [];
        asIdeaForAdding.ee.forEach(e => languagesExist.push(this.lm.idExists(e.languageId)));
        if ((await Promise.all(languagesExist)).includes(false)) {
            return false;
        }
        // No expressions are identical (same language and text)
        const distinctExpressions = new Set<string>();
        asIdeaForAdding.ee.forEach(e => distinctExpressions.add(JSON.stringify(e)));
        if (distinctExpressions.size !== asIdeaForAdding.ee.length) {
            return false;
        }
        // Context parentheses are valid
        return validateContextParentheses(ideaForAdding.ee);
    }

    public validateSettings(settings: unknown): boolean {
        return validateSettingsSchema(settings);
    }

    public validateChangeDatabase(pathObject: unknown) {
        return validateChangeDatabaseSchema(pathObject);
    }

    public validateMigrateDatabase(migrateObject: unknown) {
        return validateMigrateDatabaseSchema(migrateObject);
    }
}

export function validateChangeDatabaseSchema(pathObject: unknown) {
    const ajv = new Ajv();
    const schema = {
        type: 'object',
        properties: {
            path: {type: 'string'},
        },
        required: ['path'],
        additionalProperties: false,
    };
    return ajv.compile(schema)(pathObject);
}

export function validateMigrateDatabaseSchema(pathObject: unknown) {
    const ajv = new Ajv();
    const schema = {
        type: 'object',
        properties: {
            path: {type: 'string'},
            noContentUpdate: {type: 'boolean'},
        },
        required: ['path'],
        additionalProperties: false,
    };
    return ajv.compile(schema)(pathObject);
}

export function resolveAndNormalizePathUnderWorkingDirectory(unsafePath: string) {
    const resolvedPath = path.resolve(process.cwd(), unsafePath);

    if (!resolvedPath.startsWith(process.cwd())) {
        return false;
    }

    if (!fs.existsSync(resolvedPath)) {
        const parentDir = path.dirname(resolvedPath);
        if (!fs.existsSync(parentDir)) {
            return false;
        }
        const realPathParent = fs.realpathSync(parentDir);
        const fileName = path.basename(resolvedPath);
        return path.resolve(process.cwd(), realPathParent, fileName);
    }

    return fs.realpathSync(resolvedPath);
}

export function validateContextParentheses(ee: ExpressionForAdding[]) {
    for (const e of ee) {
        const contexts = e.text.match(/\([^)(]*\)/g) ?? [];
        // No context is empty
        if (contexts.some(x => x.substring(1, x.length - 1).trim().length === 0)) {
            return false;
        }
        // Expression is not only made of context
        const expressionWithoutContexts = e.text.replaceAll(/\([^)(]*\)/g, '');
        if (expressionWithoutContexts.trim().length === 0) {
            return false;
        }
        // There are as many closing and opening parentheses as there are contexts
        const nbrOpeningParentheses = (e.text.match(/[(]/g) ?? []).length;
        const nbrClosingParentheses = (e.text.match(/[)]/g) ?? []).length;
        if (contexts.length !== nbrOpeningParentheses || contexts.length !== nbrClosingParentheses) {
            return false;
        }
    }
    return true;
}