diff --git a/src/custom-media.js b/src/custom-media.js new file mode 100644 index 00000000..92f8573b --- /dev/null +++ b/src/custom-media.js @@ -0,0 +1,121 @@ +import { ValidationError } from './errors'; +import { URL } from 'url'; +import net from 'net'; + +const SOURCE_QUALITIES = new Set([ + 240, + 360, + 480, + 540, + 720, + 1080, + 1440, + 2160 +]); + +const SOURCE_CONTENT_TYPES = new Set([ + 'application/x-mpegURL', + 'audio/aac', + 'audio/ogg', + 'audio/mpeg', + 'video/mp4', + 'video/ogg', + 'video/webm' +]); + +export function validate(data) { + if (typeof data.title !== 'string') + throw new ValidationError('title must be a string'); + if (!data.title) + throw new ValidationError('title must not be blank'); + + if (typeof data.duration !== 'number') + throw new ValidationError('duration must be a number'); + if (!isFinite(data.duration) || data.duration < 0) + throw new ValidationError('duration must be a non-negative finite number'); + + if (data.hasOwnProperty('live') && typeof data.live !== 'boolean') + throw new ValidationError('live must be a boolean'); + + if (data.hasOwnProperty('thumbnail')) { + if (typeof data.thumbnail !== 'string') + throw new ValidationError('thumbnail must be a string'); + validateURL(data.thumbnail); + } + + validateSources(data.sources); + validateTextTracks(data.textTracks); +} + +function validateSources(sources) { + if (!Array.isArray(sources)) + throw new ValidationError('sources must be a list'); + if (sources.length === 0) + throw new ValidationError('source list must be nonempty'); + + for (let source of sources) { + if (typeof source.url !== 'string') + throw new ValidationError('source URL must be a string'); + validateURL(source.url); + + if (!SOURCE_CONTENT_TYPES.has(source.contentType)) + throw new ValidationError( + `unacceptable source contentType "${source.contentType}"` + ); + + if (!SOURCE_QUALITIES.has(source.quality)) + throw new ValidationError(`unacceptable source quality "${source.quality}"`); + + if (source.hasOwnProperty('bitrate')) { + if (typeof source.bitrate !== 'number') + throw new ValidationError('source bitrate must be a number'); + if (!isFinite(source.bitrate) || source.bitrate < 0) + throw new ValidationError( + 'source bitrate must be a non-negative finite number' + ); + } + } +} + +function validateTextTracks(textTracks) { + if (typeof textTracks === 'undefined') { + return; + } + + if (!Array.isArray(textTracks)) + throw new ValidationError('textTracks must be a list'); + + for (let track of textTracks) { + if (typeof track.url !== 'string') + throw new ValidationError('text track URL must be a string'); + validateURL(track.url); + + if (track.contentType !== 'text/vtt') + throw new ValidationError( + `unacceptable text track contentType "${track.contentType}"` + ); + + if (typeof track.name !== 'string') + throw new ValidationError('text track name must be a string'); + if (!track.name) + throw new ValidationError('text track name must be nonempty'); + } +} + +function validateURL(urlstring) { + let url; + try { + url = new URL(urlstring); + } catch (error) { + throw new ValidationError(`invalid URL "${urlstring}"`); + } + + if (url.protocol !== 'https:') + throw new ValidationError(`URL protocol must be HTTPS (invalid: "${urlstring}")`); + + if (net.isIP(url.hostname)) + throw new ValidationError( + 'URL hostname must be a domain name, not an IP address' + + ` (invalid: "${urlstring}")` + ); +} diff --git a/test/custom-media.js b/test/custom-media.js new file mode 100644 index 00000000..9234563a --- /dev/null +++ b/test/custom-media.js @@ -0,0 +1,206 @@ +const assert = require('assert'); +const { validate } = require('../lib/custom-media'); + +describe('custom-media', () => { + let valid, invalid; + beforeEach(() => { + invalid = valid = { + title: 'Test Video', + duration: 10, + live: false, + thumbnail: 'https://example.com/thumb.jpg', + sources: [ + { + url: 'https://example.com/video.mp4', + contentType: 'video/mp4', + quality: 1080, + bitrate: 5000 + } + ], + textTracks: [ + { + url: 'https://example.com/subtitles.vtt', + contentType: 'text/vtt', + name: 'English Subtitles' + } + ] + }; + }); + + describe('#validate', () => { + it('accepts valid metadata', () => { + validate(valid); + }); + + it('accepts valid metadata with no optional params', () => { + delete valid.live; + delete valid.thumbnail; + delete valid.textTracks; + delete valid.sources[0].bitrate; + + validate(valid); + }); + + it('rejects missing title', () => { + delete invalid.title; + + assert.throws(() => validate(invalid), /title must be a string/); + }); + + it('rejects blank title', () => { + invalid.title = ''; + + assert.throws(() => validate(invalid), /title must not be blank/); + }); + + it('rejects non-numeric duration', () => { + invalid.duration = 'twenty four seconds'; + + assert.throws(() => validate(invalid), /duration must be a number/); + }); + + it('rejects non-finite duration', () => { + invalid.duration = NaN; + + assert.throws(() => validate(invalid), /duration must be a non-negative finite number/); + }); + + it('rejects negative duration', () => { + invalid.duration = -1; + + assert.throws(() => validate(invalid), /duration must be a non-negative finite number/); + }); + + it('rejects non-boolean live', () => { + invalid.live = 'false'; + + assert.throws(() => validate(invalid), /live must be a boolean/); + }); + + it('rejects non-string thumbnail', () => { + invalid.thumbnail = 1234; + + assert.throws(() => validate(invalid), /thumbnail must be a string/); + }); + + it('rejects invalid thumbnail URL', () => { + invalid.thumbnail = 'http://example.com/thumb.jpg'; + + assert.throws(() => validate(invalid), /URL protocol must be HTTPS/); + }); + }); + + describe('#validateSources', () => { + it('rejects non-array sources', () => { + invalid.sources = { a: 'b' }; + + assert.throws(() => validate(invalid), /sources must be a list/); + }); + + it('rejects empty source list', () => { + invalid.sources = []; + + assert.throws(() => validate(invalid), /source list must be nonempty/); + }); + + it('rejects non-string source url', () => { + invalid.sources[0].url = 1234; + + assert.throws(() => validate(invalid), /source URL must be a string/); + }); + + it('rejects invalid source URL', () => { + invalid.sources[0].url = 'http://example.com/thumb.jpg'; + + assert.throws(() => validate(invalid), /URL protocol must be HTTPS/); + }); + + it('rejects unacceptable source contentType', () => { + invalid.sources[0].contentType = 'rtmp/flv'; + + assert.throws(() => validate(invalid), /unacceptable source contentType/); + }); + + it('rejects unacceptable source quality', () => { + invalid.sources[0].quality = 144; + + assert.throws(() => validate(invalid), /unacceptable source quality/); + }); + + it('rejects non-numeric source bitrate', () => { + invalid.sources[0].bitrate = '1000kbps' + + assert.throws(() => validate(invalid), /source bitrate must be a number/); + }); + + it('rejects non-finite source bitrate', () => { + invalid.sources[0].bitrate = Infinity; + + assert.throws(() => validate(invalid), /source bitrate must be a non-negative finite number/); + }); + + it('rejects negative source bitrate', () => { + invalid.sources[0].bitrate = -1000; + + assert.throws(() => validate(invalid), /source bitrate must be a non-negative finite number/); + }); + }); + + describe('#validateTextTracks', () => { + it('rejects non-array text track list', () => { + invalid.textTracks = { a: 'b' }; + + assert.throws(() => validate(invalid), /textTracks must be a list/); + }); + + it('rejects non-string track url', () => { + invalid.textTracks[0].url = 1234; + + assert.throws(() => validate(invalid), /text track URL must be a string/); + }); + + it('rejects invalid track URL', () => { + invalid.textTracks[0].url = 'http://example.com/thumb.jpg'; + + assert.throws(() => validate(invalid), /URL protocol must be HTTPS/); + }); + + it('rejects unacceptable track contentType', () => { + invalid.textTracks[0].contentType = 'text/plain'; + + assert.throws(() => validate(invalid), /unacceptable text track contentType/); + }); + + it('rejects non-string track name', () => { + invalid.textTracks[0].name = 1234; + + assert.throws(() => validate(invalid), /text track name must be a string/); + }); + + it('rejects blank track name', () => { + invalid.textTracks[0].name = ''; + + assert.throws(() => validate(invalid), /text track name must be nonempty/); + }); + }); + + describe('#validateURL', () => { + it('rejects non-URLs', () => { + invalid.sources[0].url = 'not a url'; + + assert.throws(() => validate(invalid), /invalid URL/); + }); + + it('rejects non-https', () => { + invalid.sources[0].url = 'http://example.com/thumb.jpg'; + + assert.throws(() => validate(invalid), /URL protocol must be HTTPS/); + }); + + it('rejects IP addresses', () => { + invalid.sources[0].url = 'https://0.0.0.0/thumb.jpg'; + + assert.throws(() => validate(invalid), /URL hostname must be a domain name/); + }); + }); +});