'use strict'; const Assert = require('@hapi/hoek/lib/assert'); const Domain = require('@sideway/address/lib/domain'); const Email = require('@sideway/address/lib/email'); const Ip = require('@sideway/address/lib/ip'); const EscapeRegex = require('@hapi/hoek/lib/escapeRegex'); const Tlds = require('@sideway/address/lib/tlds'); const Uri = require('@sideway/address/lib/uri'); const Any = require('./any'); const Common = require('../common'); const internals = { tlds: Tlds instanceof Set ? { tlds: { allow: Tlds, deny: null } } : false, // $lab:coverage:ignore$ base64Regex: { // paddingRequired true: { // urlSafe true: /^(?:[\w\-]{2}[\w\-]{2})*(?:[\w\-]{2}==|[\w\-]{3}=)?$/, false: /^(?:[A-Za-z0-9+\/]{2}[A-Za-z0-9+\/]{2})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/ }, false: { true: /^(?:[\w\-]{2}[\w\-]{2})*(?:[\w\-]{2}(==)?|[\w\-]{3}=?)?$/, false: /^(?:[A-Za-z0-9+\/]{2}[A-Za-z0-9+\/]{2})*(?:[A-Za-z0-9+\/]{2}(==)?|[A-Za-z0-9+\/]{3}=?)?$/ } }, dataUriRegex: /^data:[\w+.-]+\/[\w+.-]+;((charset=[\w-]+|base64),)?(.*)$/, hexRegex: /^[a-f0-9]+$/i, ipRegex: Ip.regex().regex, isoDurationRegex: /^P(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?$/, guidBrackets: { '{': '}', '[': ']', '(': ')', '': '' }, guidVersions: { uuidv1: '1', uuidv2: '2', uuidv3: '3', uuidv4: '4', uuidv5: '5' }, guidSeparators: new Set([undefined, true, false, '-', ':']), normalizationForms: ['NFC', 'NFD', 'NFKC', 'NFKD'] }; module.exports = Any.extend({ type: 'string', flags: { insensitive: { default: false }, truncate: { default: false } }, terms: { replacements: { init: null } }, coerce: { from: 'string', method(value, { schema, state, prefs }) { const normalize = schema.$_getRule('normalize'); if (normalize) { value = value.normalize(normalize.args.form); } const casing = schema.$_getRule('case'); if (casing) { value = casing.args.direction === 'upper' ? value.toLocaleUpperCase() : value.toLocaleLowerCase(); } const trim = schema.$_getRule('trim'); if (trim && trim.args.enabled) { value = value.trim(); } if (schema.$_terms.replacements) { for (const replacement of schema.$_terms.replacements) { value = value.replace(replacement.pattern, replacement.replacement); } } const hex = schema.$_getRule('hex'); if (hex && hex.args.options.byteAligned && value.length % 2 !== 0) { value = `0${value}`; } if (schema.$_getRule('isoDate')) { const iso = internals.isoDate(value); if (iso) { value = iso; } } if (schema._flags.truncate) { const rule = schema.$_getRule('max'); if (rule) { let limit = rule.args.limit; if (Common.isResolvable(limit)) { limit = limit.resolve(value, state, prefs); if (!Common.limit(limit)) { return { value, errors: schema.$_createError('any.ref', limit, { ref: rule.args.limit, arg: 'limit', reason: 'must be a positive integer' }, state, prefs) }; } } value = value.slice(0, limit); } } return { value }; } }, validate(value, { error }) { if (typeof value !== 'string') { return { value, errors: error('string.base') }; } if (value === '') { return { value, errors: error('string.empty') }; } }, rules: { alphanum: { method() { return this.$_addRule('alphanum'); }, validate(value, helpers) { if (/^[a-zA-Z0-9]+$/.test(value)) { return value; } return helpers.error('string.alphanum'); } }, base64: { method(options = {}) { Common.assertOptions(options, ['paddingRequired', 'urlSafe']); options = { urlSafe: false, paddingRequired: true, ...options }; Assert(typeof options.paddingRequired === 'boolean', 'paddingRequired must be boolean'); Assert(typeof options.urlSafe === 'boolean', 'urlSafe must be boolean'); return this.$_addRule({ name: 'base64', args: { options } }); }, validate(value, helpers, { options }) { const regex = internals.base64Regex[options.paddingRequired][options.urlSafe]; if (regex.test(value)) { return value; } return helpers.error('string.base64'); } }, case: { method(direction) { Assert(['lower', 'upper'].includes(direction), 'Invalid case:', direction); return this.$_addRule({ name: 'case', args: { direction } }); }, validate(value, helpers, { direction }) { if (direction === 'lower' && value === value.toLocaleLowerCase() || direction === 'upper' && value === value.toLocaleUpperCase()) { return value; } return helpers.error(`string.${direction}case`); }, convert: true }, creditCard: { method() { return this.$_addRule('creditCard'); }, validate(value, helpers) { let i = value.length; let sum = 0; let mul = 1; while (i--) { const char = value.charAt(i) * mul; sum = sum + (char - (char > 9) * 9); mul = mul ^ 3; } if (sum > 0 && sum % 10 === 0) { return value; } return helpers.error('string.creditCard'); } }, dataUri: { method(options = {}) { Common.assertOptions(options, ['paddingRequired']); options = { paddingRequired: true, ...options }; Assert(typeof options.paddingRequired === 'boolean', 'paddingRequired must be boolean'); return this.$_addRule({ name: 'dataUri', args: { options } }); }, validate(value, helpers, { options }) { const matches = value.match(internals.dataUriRegex); if (matches) { if (!matches[2]) { return value; } if (matches[2] !== 'base64') { return value; } const base64regex = internals.base64Regex[options.paddingRequired].false; if (base64regex.test(matches[3])) { return value; } } return helpers.error('string.dataUri'); } }, domain: { method(options) { if (options) { Common.assertOptions(options, ['allowUnicode', 'maxDomainSegments', 'minDomainSegments', 'tlds']); } const address = internals.addressOptions(options); return this.$_addRule({ name: 'domain', args: { options }, address }); }, validate(value, helpers, args, { address }) { if (Domain.isValid(value, address)) { return value; } return helpers.error('string.domain'); } }, email: { method(options = {}) { Common.assertOptions(options, ['allowUnicode', 'ignoreLength', 'maxDomainSegments', 'minDomainSegments', 'multiple', 'separator', 'tlds']); Assert(options.multiple === undefined || typeof options.multiple === 'boolean', 'multiple option must be an boolean'); const address = internals.addressOptions(options); const regex = new RegExp(`\\s*[${options.separator ? EscapeRegex(options.separator) : ','}]\\s*`); return this.$_addRule({ name: 'email', args: { options }, regex, address }); }, validate(value, helpers, { options }, { regex, address }) { const emails = options.multiple ? value.split(regex) : [value]; const invalids = []; for (const email of emails) { if (!Email.isValid(email, address)) { invalids.push(email); } } if (!invalids.length) { return value; } return helpers.error('string.email', { value, invalids }); } }, guid: { alias: 'uuid', method(options = {}) { Common.assertOptions(options, ['version', 'separator']); let versionNumbers = ''; if (options.version) { const versions = [].concat(options.version); Assert(versions.length >= 1, 'version must have at least 1 valid version specified'); const set = new Set(); for (let i = 0; i < versions.length; ++i) { const version = versions[i]; Assert(typeof version === 'string', 'version at position ' + i + ' must be a string'); const versionNumber = internals.guidVersions[version.toLowerCase()]; Assert(versionNumber, 'version at position ' + i + ' must be one of ' + Object.keys(internals.guidVersions).join(', ')); Assert(!set.has(versionNumber), 'version at position ' + i + ' must not be a duplicate'); versionNumbers += versionNumber; set.add(versionNumber); } } Assert(internals.guidSeparators.has(options.separator), 'separator must be one of true, false, "-", or ":"'); const separator = options.separator === undefined ? '[:-]?' : options.separator === true ? '[:-]' : options.separator === false ? '[]?' : `\\${options.separator}`; const regex = new RegExp(`^([\\[{\\(]?)[0-9A-F]{8}(${separator})[0-9A-F]{4}\\2?[${versionNumbers || '0-9A-F'}][0-9A-F]{3}\\2?[${versionNumbers ? '89AB' : '0-9A-F'}][0-9A-F]{3}\\2?[0-9A-F]{12}([\\]}\\)]?)$`, 'i'); return this.$_addRule({ name: 'guid', args: { options }, regex }); }, validate(value, helpers, args, { regex }) { const results = regex.exec(value); if (!results) { return helpers.error('string.guid'); } // Matching braces if (internals.guidBrackets[results[1]] !== results[results.length - 1]) { return helpers.error('string.guid'); } return value; } }, hex: { method(options = {}) { Common.assertOptions(options, ['byteAligned']); options = { byteAligned: false, ...options }; Assert(typeof options.byteAligned === 'boolean', 'byteAligned must be boolean'); return this.$_addRule({ name: 'hex', args: { options } }); }, validate(value, helpers, { options }) { if (!internals.hexRegex.test(value)) { return helpers.error('string.hex'); } if (options.byteAligned && value.length % 2 !== 0) { return helpers.error('string.hexAlign'); } return value; } }, hostname: { method() { return this.$_addRule('hostname'); }, validate(value, helpers) { if (Domain.isValid(value, { minDomainSegments: 1 }) || internals.ipRegex.test(value)) { return value; } return helpers.error('string.hostname'); } }, insensitive: { method() { return this.$_setFlag('insensitive', true); } }, ip: { method(options = {}) { Common.assertOptions(options, ['cidr', 'version']); const { cidr, versions, regex } = Ip.regex(options); const version = options.version ? versions : undefined; return this.$_addRule({ name: 'ip', args: { options: { cidr, version } }, regex }); }, validate(value, helpers, { options }, { regex }) { if (regex.test(value)) { return value; } if (options.version) { return helpers.error('string.ipVersion', { value, cidr: options.cidr, version: options.version }); } return helpers.error('string.ip', { value, cidr: options.cidr }); } }, isoDate: { method() { return this.$_addRule('isoDate'); }, validate(value, { error }) { if (internals.isoDate(value)) { return value; } return error('string.isoDate'); } }, isoDuration: { method() { return this.$_addRule('isoDuration'); }, validate(value, helpers) { if (internals.isoDurationRegex.test(value)) { return value; } return helpers.error('string.isoDuration'); } }, length: { method(limit, encoding) { return internals.length(this, 'length', limit, '=', encoding); }, validate(value, helpers, { limit, encoding }, { name, operator, args }) { const length = encoding ? Buffer && Buffer.byteLength(value, encoding) : value.length; // $lab:coverage:ignore$ if (Common.compare(length, limit, operator)) { return value; } return helpers.error('string.' + name, { limit: args.limit, value, encoding }); }, args: [ { name: 'limit', ref: true, assert: Common.limit, message: 'must be a positive integer' }, 'encoding' ] }, lowercase: { method() { return this.case('lower'); } }, max: { method(limit, encoding) { return internals.length(this, 'max', limit, '<=', encoding); }, args: ['limit', 'encoding'] }, min: { method(limit, encoding) { return internals.length(this, 'min', limit, '>=', encoding); }, args: ['limit', 'encoding'] }, normalize: { method(form = 'NFC') { Assert(internals.normalizationForms.includes(form), 'normalization form must be one of ' + internals.normalizationForms.join(', ')); return this.$_addRule({ name: 'normalize', args: { form } }); }, validate(value, { error }, { form }) { if (value === value.normalize(form)) { return value; } return error('string.normalize', { value, form }); }, convert: true }, pattern: { alias: 'regex', method(regex, options = {}) { Assert(regex instanceof RegExp, 'regex must be a RegExp'); Assert(!regex.flags.includes('g') && !regex.flags.includes('y'), 'regex should not use global or sticky mode'); if (typeof options === 'string') { options = { name: options }; } Common.assertOptions(options, ['invert', 'name']); const errorCode = ['string.pattern', options.invert ? '.invert' : '', options.name ? '.name' : '.base'].join(''); return this.$_addRule({ name: 'pattern', args: { regex, options }, errorCode }); }, validate(value, helpers, { regex, options }, { errorCode }) { const patternMatch = regex.test(value); if (patternMatch ^ options.invert) { return value; } return helpers.error(errorCode, { name: options.name, regex, value }); }, args: ['regex', 'options'], multi: true }, replace: { method(pattern, replacement) { if (typeof pattern === 'string') { pattern = new RegExp(EscapeRegex(pattern), 'g'); } Assert(pattern instanceof RegExp, 'pattern must be a RegExp'); Assert(typeof replacement === 'string', 'replacement must be a String'); const obj = this.clone(); if (!obj.$_terms.replacements) { obj.$_terms.replacements = []; } obj.$_terms.replacements.push({ pattern, replacement }); return obj; } }, token: { method() { return this.$_addRule('token'); }, validate(value, helpers) { if (/^\w+$/.test(value)) { return value; } return helpers.error('string.token'); } }, trim: { method(enabled = true) { Assert(typeof enabled === 'boolean', 'enabled must be a boolean'); return this.$_addRule({ name: 'trim', args: { enabled } }); }, validate(value, helpers, { enabled }) { if (!enabled || value === value.trim()) { return value; } return helpers.error('string.trim'); }, convert: true }, truncate: { method(enabled = true) { Assert(typeof enabled === 'boolean', 'enabled must be a boolean'); return this.$_setFlag('truncate', enabled); } }, uppercase: { method() { return this.case('upper'); } }, uri: { method(options = {}) { Common.assertOptions(options, ['allowRelative', 'allowQuerySquareBrackets', 'domain', 'relativeOnly', 'scheme']); if (options.domain) { Common.assertOptions(options.domain, ['allowUnicode', 'maxDomainSegments', 'minDomainSegments', 'tlds']); } const { regex, scheme } = Uri.regex(options); const domain = options.domain ? internals.addressOptions(options.domain) : null; return this.$_addRule({ name: 'uri', args: { options }, regex, domain, scheme }); }, validate(value, helpers, { options }, { regex, domain, scheme }) { if (['http:/', 'https:/'].includes(value)) { // scheme:/ is technically valid but makes no sense return helpers.error('string.uri'); } const match = regex.exec(value); if (match) { const matched = match[1] || match[2]; if (domain && (!options.allowRelative || matched) && !Domain.isValid(matched, domain)) { return helpers.error('string.domain', { value: matched }); } return value; } if (options.relativeOnly) { return helpers.error('string.uriRelativeOnly'); } if (options.scheme) { return helpers.error('string.uriCustomScheme', { scheme, value }); } return helpers.error('string.uri'); } } }, manifest: { build(obj, desc) { if (desc.replacements) { for (const { pattern, replacement } of desc.replacements) { obj = obj.replace(pattern, replacement); } } return obj; } }, messages: { 'string.alphanum': '{{#label}} must only contain alpha-numeric characters', 'string.base': '{{#label}} must be a string', 'string.base64': '{{#label}} must be a valid base64 string', 'string.creditCard': '{{#label}} must be a credit card', 'string.dataUri': '{{#label}} must be a valid dataUri string', 'string.domain': '{{#label}} must contain a valid domain name', 'string.email': '{{#label}} must be a valid email', 'string.empty': '{{#label}} is not allowed to be empty', 'string.guid': '{{#label}} must be a valid GUID', 'string.hex': '{{#label}} must only contain hexadecimal characters', 'string.hexAlign': '{{#label}} hex decoded representation must be byte aligned', 'string.hostname': '{{#label}} must be a valid hostname', 'string.ip': '{{#label}} must be a valid ip address with a {{#cidr}} CIDR', 'string.ipVersion': '{{#label}} must be a valid ip address of one of the following versions {{#version}} with a {{#cidr}} CIDR', 'string.isoDate': '{{#label}} must be in iso format', 'string.isoDuration': '{{#label}} must be a valid ISO 8601 duration', 'string.length': '{{#label}} length must be {{#limit}} characters long', 'string.lowercase': '{{#label}} must only contain lowercase characters', 'string.max': '{{#label}} length must be less than or equal to {{#limit}} characters long', 'string.min': '{{#label}} length must be at least {{#limit}} characters long', 'string.normalize': '{{#label}} must be unicode normalized in the {{#form}} form', 'string.token': '{{#label}} must only contain alpha-numeric and underscore characters', 'string.pattern.base': '{{#label}} with value {:[.]} fails to match the required pattern: {{#regex}}', 'string.pattern.name': '{{#label}} with value {:[.]} fails to match the {{#name}} pattern', 'string.pattern.invert.base': '{{#label}} with value {:[.]} matches the inverted pattern: {{#regex}}', 'string.pattern.invert.name': '{{#label}} with value {:[.]} matches the inverted {{#name}} pattern', 'string.trim': '{{#label}} must not have leading or trailing whitespace', 'string.uri': '{{#label}} must be a valid uri', 'string.uriCustomScheme': '{{#label}} must be a valid uri with a scheme matching the {{#scheme}} pattern', 'string.uriRelativeOnly': '{{#label}} must be a valid relative uri', 'string.uppercase': '{{#label}} must only contain uppercase characters' } }); // Helpers internals.addressOptions = function (options) { if (!options) { return options; } // minDomainSegments Assert(options.minDomainSegments === undefined || Number.isSafeInteger(options.minDomainSegments) && options.minDomainSegments > 0, 'minDomainSegments must be a positive integer'); // maxDomainSegments Assert(options.maxDomainSegments === undefined || Number.isSafeInteger(options.maxDomainSegments) && options.maxDomainSegments > 0, 'maxDomainSegments must be a positive integer'); // tlds if (options.tlds === false) { return options; } if (options.tlds === true || options.tlds === undefined) { Assert(internals.tlds, 'Built-in TLD list disabled'); return Object.assign({}, options, internals.tlds); } Assert(typeof options.tlds === 'object', 'tlds must be true, false, or an object'); const deny = options.tlds.deny; if (deny) { if (Array.isArray(deny)) { options = Object.assign({}, options, { tlds: { deny: new Set(deny) } }); } Assert(options.tlds.deny instanceof Set, 'tlds.deny must be an array, Set, or boolean'); Assert(!options.tlds.allow, 'Cannot specify both tlds.allow and tlds.deny lists'); internals.validateTlds(options.tlds.deny, 'tlds.deny'); return options; } const allow = options.tlds.allow; if (!allow) { return options; } if (allow === true) { Assert(internals.tlds, 'Built-in TLD list disabled'); return Object.assign({}, options, internals.tlds); } if (Array.isArray(allow)) { options = Object.assign({}, options, { tlds: { allow: new Set(allow) } }); } Assert(options.tlds.allow instanceof Set, 'tlds.allow must be an array, Set, or boolean'); internals.validateTlds(options.tlds.allow, 'tlds.allow'); return options; }; internals.validateTlds = function (set, source) { for (const tld of set) { Assert(Domain.isValid(tld, { minDomainSegments: 1, maxDomainSegments: 1 }), `${source} must contain valid top level domain names`); } }; internals.isoDate = function (value) { if (!Common.isIsoDate(value)) { return null; } if (/.*T.*[+-]\d\d$/.test(value)) { // Add missing trailing zeros to timeshift value += '00'; } const date = new Date(value); if (isNaN(date.getTime())) { return null; } return date.toISOString(); }; internals.length = function (schema, name, limit, operator, encoding) { Assert(!encoding || Buffer && Buffer.isEncoding(encoding), 'Invalid encoding:', encoding); // $lab:coverage:ignore$ return schema.$_addRule({ name, method: 'length', args: { limit, encoding }, operator }); };