18F/federalist

View on GitHub
api/utils/validators.js

Summary

Maintainability
A
50 mins
Test Coverage
A
100%
/* eslint-disable max-classes-per-file */
const yaml = require('js-yaml');
const validator = require('validator');

const branchRegex = /^[\w.]+(?:[/-]*[\w.])*$/;
const githubUsernameRegex = /^[^-][a-zA-Z-]+$/;
const shaRegex = /^[a-f0-9]{40}$/;
const subdomainRegex = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/;

class ValidationError extends Error { }

class CustomError extends Error {
  constructor(message, status = 400) {
    super(message);
    this.status = status;
  }
}

function isValidJSON(json) {
  if (typeof json === 'number') {
    return false;
  }

  try {
    if (typeof json === 'string') {
      const parsed = JSON.parse(json);

      if (typeof parsed !== 'object') {
        throw new Error('Invalid json type');
      }
    }
  } catch {
    return false;
  }
  // if no error, then the string was valid
  return true;
}

function isValidYaml(yamlString) {
  try {
    yaml.load(yamlString);
  } catch (e) {
    // for Sequelize validators, we need to throw an error
    // on invalid values
    throw new Error('input is not valid YAML');
  }
  // if no error, then the string was valid
  return true;
}

function parseSiteConfig(siteConfig, configName = null) {
  let obj = null;

  try {
    if ((typeof siteConfig) === 'string' && siteConfig.length > 0) {
      obj = yaml.load(siteConfig);
    }

    if ((typeof siteConfig) === 'object') { return siteConfig; }
  } catch (e) {
    // on invalid values
    let msg = 'input is not valid YAML';
    if (configName) {
      msg = `${configName}: ${msg}`;
    }

    obj = new Error(msg);
    obj.name = 'InvalidYaml';
    obj.status = 403;
  }
  return obj;
}

function parseSiteConfigs(siteConfigs) {
  let siteConfig;
  const parsedSiteConfigs = {};
  Object.keys(siteConfigs).forEach((configName) => {
    siteConfig = siteConfigs[configName];
    parsedSiteConfigs[configName] = parseSiteConfig(siteConfig.value, siteConfig.label);
  });
  const errorMsgs = [];
  Object.keys(parsedSiteConfigs).forEach((configName) => {
    if (parsedSiteConfigs[configName] && parsedSiteConfigs[configName].status) {
      errorMsgs.push(parsedSiteConfigs[configName].message);
    }
  });

  if (errorMsgs.length > 0) {
    const error = new Error(errorMsgs.join('\n'));
    error.name = 'InvalidYaml';
    error.status = '403';
    throw error;
  }
  return parsedSiteConfigs;
}

function isEmptyOrUrl(value) {
  const validUrlOptions = {
    require_protocol: true,
    protocols: ['https'],
  };

  if (value && value.length && !validator.isURL(value, validUrlOptions)) {
    throw new Error('URL must start with https://');
  }
}

function isValidSubdomain(value) {
  const msg = 'Subdomains may only contain up to 63 alphanumeric and hyphen characters.';
  if (!subdomainRegex.test(value)) {
    throw new Error(msg);
  }
}

const validBasicAuthUsername = s => /^(?!.*[:])(?=.*[a-zA-Z0-9]).{4,255}$/.test(s);

const validBasicAuthPassword = s => /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{4,255}$/.test(s);

const isDelimitedFQDN = (str) => {
  const msg = 'must be a comma-separated list of valid fully qualified domain names';
  const isValid = str.split(',').every(s => validator.isFQDN(s));
  if (!isValid) {
    throw new Error(msg);
  }
};

module.exports = {
  branchRegex,
  CustomError,
  shaRegex,
  githubUsernameRegex,
  isValidJSON,
  isValidYaml,
  parseSiteConfig,
  parseSiteConfigs,
  isEmptyOrUrl,
  ValidationError,
  validBasicAuthUsername,
  validBasicAuthPassword,
  isValidSubdomain,
  isDelimitedFQDN,
};