diff --git a/src/custom-media.js b/src/custom-media.js index 5a8d4e74..0d95c023 100644 --- a/src/custom-media.js +++ b/src/custom-media.js @@ -1,8 +1,9 @@ import { ValidationError } from './errors'; -import { parse as parseURL } from 'url'; +import { parse as urlParse } from 'url'; import net from 'net'; import Media from './media'; import { hash } from './util/hash'; +import { get as httpGet } from 'http'; const SOURCE_QUALITIES = new Set([ 240, @@ -25,6 +26,73 @@ const SOURCE_CONTENT_TYPES = new Set([ 'video/webm' ]); +export function lookup(url, opts) { + if (!opts) opts = {}; + if (!opts.hasOwnProperty('timeout')) opts.timeout = 10000; + + return new Promise((resolve, reject) => { + const options = { + headers: { + 'Accept': 'application/json' + } + }; + + Object.assign(options, parseURL(url)); + + const req = httpGet(options); + + req.setTimeout(opts.timeout, () => { + const error = new Error('Request timed out'); + error.code = 'ETIMEDOUT'; + reject(error); + }); + + req.on('error', error => { + reject(error); + }); + + req.on('response', res => { + if (res.statusCode !== 200) { + req.abort(); + + reject(new Error( + `Expected HTTP 200 OK, not ${res.statusCode} ${res.statusMessage}` + )); + + return; + } + + if (!/^application\/json/.test(res.headers['content-type'])) { + req.abort(); + + reject(new Error( + `Expected content-type application/json, not ${res.headers['content-type']}` + )); + + return; + } + + let buffer = ''; + res.setEncoding('utf8'); + + res.on('data', data => { + buffer += data; + + if (buffer.length > 100 * 1024) { + req.abort(); + reject(new Error('Response size exceeds 100KB')); + } + }); + + res.on('end', () => { + resolve(buffer); + }); + }); + }).then(body => { + return convert(JSON.parse(body)); + }); +} + export function convert(data) { validate(data); @@ -138,15 +206,21 @@ function validateTextTracks(textTracks) { } } +function parseURL(urlstring) { + const url = urlParse(urlstring); + + // legacy url.parse doesn't check this + if (url.protocol == null || url.host == null) { + throw new Error(`Invalid URL "${urlstring}"`); + } + + return url; +} + function validateURL(urlstring) { let url; try { url = parseURL(urlstring); - - // legacy url.parse doesn't check this - if (url.protocol == null || url.host == null) { - throw new Error(); - } } catch (error) { throw new ValidationError(`invalid URL "${urlstring}"`); } diff --git a/test/custom-media.js b/test/custom-media.js index e3f24149..81bb3216 100644 --- a/test/custom-media.js +++ b/test/custom-media.js @@ -1,5 +1,6 @@ const assert = require('assert'); -const { validate, convert } = require('../lib/custom-media'); +const { validate, convert, lookup } = require('../lib/custom-media'); +const http = require('http'); describe('custom-media', () => { let valid, invalid; @@ -270,4 +271,148 @@ describe('custom-media', () => { assert.deepStrictEqual(actual, expected); }); }); + + describe('#lookup', () => { + let server; + let serveFunc; + + beforeEach(() => { + serveFunc = function (req, res) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.write(JSON.stringify(valid, null, 2)); + res.end(); + }; + + server = http.createServer((req, res) => serveFunc(req, res)); + server.listen(10111); + }); + + afterEach(done => { + server.close(() => done()); + }); + + it('retrieves metadata', () => { + function cleanForComparison(actual) { + actual = actual.pack(); + delete actual.id; + + // Strip out extraneous undefineds + for (let key in actual.meta) { + if (actual.meta[key] === undefined) delete actual.meta[key]; + } + + return actual; + } + + const expected = { + title: 'Test Video', + seconds: 10, + duration: '00:10', + type: 'cm', + meta: { + direct: { + 1080: [ + { + link: 'https://example.com/video.mp4', + contentType: 'video/mp4', + quality: 1080 + } + ] + }, + textTracks: [ + { + url: 'https://example.com/subtitles.vtt', + contentType: 'text/vtt', + name: 'English Subtitles' + } + ] + } + }; + + return lookup('http://127.0.0.1:10111/').then(result => { + assert.deepStrictEqual(cleanForComparison(result), expected); + }); + }); + + it('rejects the wrong content-type', () => { + serveFunc = (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.write(JSON.stringify(valid, null, 2)); + res.end(); + }; + + return lookup('http://127.0.0.1:10111/').then(() => { + throw new Error('Expected failure due to wrong content-type'); + }).catch(error => { + assert.strictEqual( + error.message, + 'Expected content-type application/json, not text/plain' + ); + }); + }); + + it('rejects non-200 status codes', () => { + serveFunc = (req, res) => { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.write(JSON.stringify(valid, null, 2)); + res.end(); + }; + + return lookup('http://127.0.0.1:10111/').then(() => { + throw new Error('Expected failure due to 404'); + }).catch(error => { + assert.strictEqual( + error.message, + 'Expected HTTP 200 OK, not 404 Not Found' + ); + }); + }); + + it('rejects responses >100KB', () => { + serveFunc = (req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.write(Buffer.alloc(200 * 1024)); + res.end(); + }; + + return lookup('http://127.0.0.1:10111/').then(() => { + throw new Error('Expected failure due to response size'); + }).catch(error => { + assert.strictEqual( + error.message, + 'Response size exceeds 100KB' + ); + }); + }); + + it('times out', () => { + serveFunc = (req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.write(JSON.stringify(valid, null, 2)); + + setTimeout(() => res.end(), 100); + }; + + return lookup('http://127.0.0.1:10111/', { timeout: 1 }).then(() => { + throw new Error('Expected failure due to request timeout'); + }).catch(error => { + assert.strictEqual( + error.message, + 'Request timed out' + ); + assert.strictEqual(error.code, 'ETIMEDOUT'); + }); + }); + + it('rejects invalid URLs', () => { + return lookup('not valid').then(() => { + throw new Error('Expected failure due to invalid URL'); + }).catch(error => { + assert.strictEqual( + error.message, + 'Invalid URL "not valid"' + ); + }); + }); + }); });