/** * Copyright (c) 2016 Jonas Hermsmeier * https://github.com/jhermsmeier/node-http-link-header */ /* istanbul ignore file */ 'use strict'; var COMPATIBLE_ENCODING_PATTERN = /^utf-?8|ascii|utf-?16-?le|ucs-?2|base-?64|latin-?1$/i; var WS_TRIM_PATTERN = /^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g; var WS_CHAR_PATTERN = /\s|\uFEFF|\xA0/; var WS_FOLD_PATTERN = /\r?\n[\x20\x09]+/g; var DELIMITER_PATTERN = /[;,"]/; var WS_DELIMITER_PATTERN = /[;,"]|\s/; /** * Token character pattern * @type {RegExp} * @see https://tools.ietf.org/html/rfc7230#section-3.2.6 */ var TOKEN_PATTERN = /^[!#$%&'*+\-\.^_`|~\da-zA-Z]+$/; var STATE = { IDLE: 1 << 0, URI: 1 << 1, ATTR: 1 << 2, }; function trim(value) { return value.replace(WS_TRIM_PATTERN, ''); } function hasWhitespace(value) { return WS_CHAR_PATTERN.test(value); } function skipWhitespace(value, offset) { while (hasWhitespace(value[offset])) { offset++; } return offset; } function needsQuotes(value) { return WS_DELIMITER_PATTERN.test(value) || !TOKEN_PATTERN.test(value); } class Link { /** * Link * @constructor * @param {String} [value] * @returns {Link} */ constructor(value) { /** @type {Array} URI references */ this.refs = []; if (value) { this.parse(value); } } /** * Get refs with given relation type * @param {String} value * @returns {Array} */ rel(value) { var links = []; var type = value.toLowerCase(); for (var i = 0; i < this.refs.length; i++) { if (this.refs[i].rel.toLowerCase() === type) { links.push(this.refs[i]); } } return links; } /** * Get refs where given attribute has a given value * @param {String} attr * @param {String} value * @returns {Array} */ get(attr, value) { attr = attr.toLowerCase(); var links = []; for (var i = 0; i < this.refs.length; i++) { if (this.refs[i][attr] === value) { links.push(this.refs[i]); } } return links; } set(link) { this.refs.push(link); return this; } has(attr, value) { attr = attr.toLowerCase(); for (var i = 0; i < this.refs.length; i++) { if (this.refs[i][attr] === value) { return true; } } return false; } parse(value, offset) { offset = offset || 0; value = offset ? value.slice(offset) : value; // Trim & unfold folded lines value = trim(value).replace(WS_FOLD_PATTERN, ''); var state = STATE.IDLE; var length = value.length; var offset = 0; var ref = null; while (offset < length) { if (state === STATE.IDLE) { if (hasWhitespace(value[offset])) { offset++; continue; } else if (value[offset] === '<') { if (ref != null) { ref.rel != null ? this.refs.push(...Link.expandRelations(ref)) : this.refs.push(ref); } var end = value.indexOf('>', offset); if (end === -1) throw new Error( 'Expected end of URI delimiter at offset ' + offset ); ref = { uri: value.slice(offset + 1, end) }; // this.refs.push( ref ) offset = end; state = STATE.URI; } else { throw new Error( 'Unexpected character "' + value[offset] + '" at offset ' + offset ); } offset++; } else if (state === STATE.URI) { if (hasWhitespace(value[offset])) { offset++; continue; } else if (value[offset] === ';') { state = STATE.ATTR; offset++; } else if (value[offset] === ',') { state = STATE.IDLE; offset++; } else { throw new Error( 'Unexpected character "' + value[offset] + '" at offset ' + offset ); } } else if (state === STATE.ATTR) { if (value[offset] === ';' || hasWhitespace(value[offset])) { offset++; continue; } var end = value.indexOf('=', offset); if (end === -1) throw new Error('Expected attribute delimiter at offset ' + offset); var attr = trim(value.slice(offset, end)).toLowerCase(); var attrValue = ''; offset = end + 1; offset = skipWhitespace(value, offset); if (value[offset] === '"') { offset++; while (offset < length) { if (value[offset] === '"') { offset++; break; } if (value[offset] === '\\') { offset++; } attrValue += value[offset]; offset++; } } else { var end = offset + 1; while (!DELIMITER_PATTERN.test(value[end]) && end < length) { end++; } attrValue = value.slice(offset, end); offset = end; } if (ref[attr] && Link.isSingleOccurenceAttr(attr)) { // Ignore multiples of attributes which may only appear once } else if (attr[attr.length - 1] === '*') { ref[attr] = Link.parseExtendedValue(attrValue); } else { attrValue = attr === 'type' ? attrValue.toLowerCase() : attrValue; if (ref[attr] != null) { if (Array.isArray(ref[attr])) { ref[attr].push(attrValue); } else { ref[attr] = [ref[attr], attrValue]; } } else { ref[attr] = attrValue; } } switch (value[offset]) { case ',': state = STATE.IDLE; break; case ';': state = STATE.ATTR; break; } offset++; } else { throw new Error('Unknown parser state "' + state + '"'); } } if (ref != null) { ref.rel != null ? this.refs.push(...Link.expandRelations(ref)) : this.refs.push(ref); } ref = null; return this; } toString() { var refs = []; var link = ''; var ref = null; for (var i = 0; i < this.refs.length; i++) { ref = this.refs[i]; link = Object.keys(this.refs[i]).reduce(function (link, attr) { if (attr === 'uri') return link; return link + '; ' + Link.formatAttribute(attr, ref[attr]); }, '<' + ref.uri + '>'); refs.push(link); } return refs.join(', '); } } /** * Determines whether an encoding can be * natively handled with a `Buffer` * @param {String} value * @returns {Boolean} */ Link.isCompatibleEncoding = function (value) { return COMPATIBLE_ENCODING_PATTERN.test(value); }; Link.parse = function (value, offset) { return new Link().parse(value, offset); }; Link.isSingleOccurenceAttr = function (attr) { return ( attr === 'rel' || attr === 'type' || attr === 'media' || attr === 'title' || attr === 'title*' ); }; Link.isTokenAttr = function (attr) { return attr === 'rel' || attr === 'type' || attr === 'anchor'; }; Link.escapeQuotes = function (value) { return value.replace(/"/g, '\\"'); }; Link.expandRelations = function (ref) { var rels = ref.rel.split(' '); return rels.map(function (rel) { var value = Object.assign({}, ref); value.rel = rel; return value; }); }; /** * Parses an extended value and attempts to decode it * @internal * @param {String} value * @return {Object} */ Link.parseExtendedValue = function (value) { var parts = /([^']+)?(?:'([^']+)')?(.+)/.exec(value); return { language: parts[2].toLowerCase(), encoding: Link.isCompatibleEncoding(parts[1]) ? null : parts[1].toLowerCase(), value: Link.isCompatibleEncoding(parts[1]) ? decodeURIComponent(parts[3]) : parts[3], }; }; /** * Format a given extended attribute and it's value * @param {String} attr * @param {Object} data * @return {String} */ Link.formatExtendedAttribute = function (attr, data) { var encoding = (data.encoding || 'utf-8').toUpperCase(); var language = data.language || 'en'; var encodedValue = ''; if (Buffer.isBuffer(data.value) && Link.isCompatibleEncoding(encoding)) { encodedValue = data.value.toString(encoding); } else if (Buffer.isBuffer(data.value)) { encodedValue = data.value.toString('hex').replace(/[0-9a-f]{2}/gi, '%$1'); } else { encodedValue = encodeURIComponent(data.value); } return attr + '=' + encoding + "'" + language + "'" + encodedValue; }; /** * Format a given attribute and it's value * @param {String} attr * @param {String|Object} value * @return {String} */ Link.formatAttribute = function (attr, value) { if (Array.isArray(value)) { return value .map((item) => { return Link.formatAttribute(attr, item); }) .join('; '); } if (attr[attr.length - 1] === '*' || typeof value !== 'string') { return Link.formatExtendedAttribute(attr, value); } if (Link.isTokenAttr(attr)) { value = needsQuotes(value) ? '"' + Link.escapeQuotes(value) + '"' : Link.escapeQuotes(value); } else if (needsQuotes(value)) { value = encodeURIComponent(value); // We don't need to escape <,> <;> within quotes value = value .replace(/%20/g, ' ') .replace(/%2C/g, ',') .replace(/%3B/g, ';'); value = '"' + value + '"'; } return attr + '=' + value; }; module.exports = Link;