UNPKG

7.46 kBJavaScriptView Raw
1'use strict';
2
3const debug = require('debug')('protocol-handler');
4const pCatchIf = require('p-catch-if');
5const pTry = require('p-try');
6
7const reProtocol = /^([a-z0-9.+-]+:)/i;
8const BLACKLISTED_PROTOCOLS = ['http:', 'https:', 'file:', 'blob:', 'about:'];
9
10const defineProperty = (obj, name, value) => Object.defineProperty(obj, name, { value });
11const isProtocolRelative = url => url.trim().startsWith('//');
12
13/**
14 * Custom error indicating invalid, unknown or blacklisted protocol
15 * @augments Error
16 */
17class ProtocolError extends Error {
18 /**
19 *
20 * @param {ProtocolError.code} code Error code
21 * @param {String} message Error message
22 */
23 constructor(code, message) {
24 super(message);
25 this.code = code;
26 }
27
28 toJSON() {
29 return {
30 name: this.name,
31 code: this.code,
32 message: this.message
33 };
34 }
35}
36defineProperty(ProtocolError.prototype, 'name', ProtocolError.name);
37/**
38 * @typedef {Object} ProtocolError.code
39 * @property {Number} ERR_PROTOCOL_INVALID
40 * @property {Number} ERR_PROTOCOL_UNKNOWN
41 * @property {Number} ERR_PROTOCOL_BLACKLISTED
42 */
43ProtocolError.ERR_PROTOCOL_INVALID = -1;
44ProtocolError.ERR_PROTOCOL_UNKNOWN = 1;
45ProtocolError.ERR_PROTOCOL_BLACKLISTED = 2;
46
47/**
48 * Create protocol handler
49 * @class
50 */
51class ProtocolHandler {
52 /**
53 * @constructor
54 * @param {ProtocolHandlerOptions} [options={}] protocol handler options
55 */
56 constructor({ blacklist = [] } = {}) {
57 this._blacklist = [...BLACKLISTED_PROTOCOLS, ...blacklist];
58 this._handlers = new Map();
59 }
60
61 /**
62 * Registers protocol handler
63 * @param {String} scheme protocol scheme
64 * @param {ProtocolCallback} handler protocol handler
65 * @returns {ProtocolHandler} instance to allow chaining
66 * @throws {ProtocolError} throws if protocol scheme is invalid or blacklisted
67 *
68 * @example
69 * // register multiple handlers
70 * const handler = new ProtocolHandler();
71 * handler
72 * .protocol('s3://', resolve)
73 * .protocol('gdrive://', resolve);
74 */
75 protocol(scheme, handler) {
76 debug('atempt register scheme: %s', scheme);
77 const protocol = getProtocol(scheme);
78 if (!protocol) {
79 throw new ProtocolError(
80 ProtocolError.ERR_PROTOCOL_INVALID,
81 `Invalid protocol: \`${scheme}\``
82 );
83 }
84 debug('protocol=%s', protocol);
85 if (this._blacklist.includes(protocol)) {
86 throw new ProtocolError(
87 ProtocolError.ERR_PROTOCOL_BLACKLISTED,
88 `Registering handler for \`${scheme}\` is not allowed.`
89 );
90 }
91 this._handlers.set(protocol, handler);
92 debug('scheme registered: %s', scheme);
93 return this;
94 }
95
96 /**
97 * @property {Set<String>} protocols registered protocols
98 *
99 * @example
100 * // check if protocol is registered
101 * const handler = new ProtocolHandler();
102 * handler.protocol('s3://', resolve);
103 * console.log(handler.protocols.has('s3:'));
104 * //=> true
105 */
106 get protocols() {
107 return new Set(this._handlers.keys());
108 }
109
110 async _resolve(url) {
111 debug('url=%s', url);
112 if (url && isProtocolRelative(url)) return url;
113 const protocol = getProtocol(url);
114 if (!protocol) {
115 throw new ProtocolError(
116 ProtocolError.ERR_PROTOCOL_INVALID,
117 `Invalid url: \`${url}\``
118 );
119 }
120 debug('protocol=%s', protocol);
121 const handler = this._handlers.get(protocol);
122 if (handler) {
123 const resolvedUrl = await pTry(() => handler(url));
124 debug('resolved url=%s', resolvedUrl || '');
125 return resolvedUrl;
126 }
127 if (this._blacklist.includes(protocol)) {
128 throw new ProtocolError(
129 ProtocolError.ERR_PROTOCOL_BLACKLISTED,
130 `Blacklisted protocol: \`${protocol}\``
131 );
132 }
133 throw new ProtocolError(
134 ProtocolError.ERR_PROTOCOL_UNKNOWN,
135 `Unknown protocol: \`${protocol}\``
136 );
137 }
138
139 /**
140 * Asynchronously resolves url with registered protocol handler
141 * @param {String} url target url
142 * @returns {Promise<String>} resolved url, redirect location
143 * @throws {ProtocolError} throws if url contains invalid or unknown protocol
144 *
145 * @example
146 * // create handler
147 * const handler = new ProtocolHandler();
148 * handler.protocol('s3://', url => 'https://example.com');
149 * // resolve url
150 * handler.resolve('s3://test').then(url => console.log(url));
151 * //=> https://example.com
152 * handler.resolve('file:///local/file.txt').then(url => console.log(url));
153 * //=> file:///local/file.txt
154 * handler.resolve('dummy://unknown/protocol');
155 * //=> throws ProtocolError
156 */
157 resolve(url) {
158 return this._resolve(url).catch(pCatchIf(isBlacklisted, () => url));
159 }
160
161 /**
162 * Returns [Express](https://expressjs.com) middleware
163 * @param {String} [param='url'] name of query param containing target url
164 * @param {ProtocolErrorCallback} [cb] custom error handling callback
165 * @param {import('@types/express').RequestHandler} Express middleware
166 *
167 * @example
168 * // create handler
169 * const handler = new ProtocolHandler();
170 * handler.protocol('s3://', resolve);
171 * // attach to express app
172 * app.use(handler.middleware());
173 */
174 middleware(param = 'url', cb) {
175 return async (req, res, next) => {
176 const url = decodeURIComponent(req.query[param]);
177 try {
178 const redirectUrl = await this._resolve(url, null);
179 debug('redirect url=%s', redirectUrl || '');
180 return res.redirect(redirectUrl);
181 } catch (err) {
182 if (!(err instanceof ProtocolError)) next(err);
183 if (cb) return cb(err, url, res);
184 if (isBlacklisted(err)) return res.redirect(url);
185 res.status(400).json({ error: err });
186 }
187 };
188 }
189}
190
191/**
192 * Create new ProtocolHandler instance
193 * @name module.exports
194 * @param {ProtocolHandlerOptions} [options={}] protocol handler options
195 * @returns {ProtocolHandler} instance
196 *
197 * @example
198 * const handler = require('custom-protocol-handler')();
199 */
200module.exports = options => new ProtocolHandler(options);
201module.exports.ProtocolHandler = ProtocolHandler;
202module.exports.ProtocolError = ProtocolError;
203
204function getProtocol(str) {
205 const match = str.trim().match(reProtocol);
206 return match && match[0];
207}
208
209function isBlacklisted(err) {
210 return err instanceof ProtocolError &&
211 err.code === ProtocolError.ERR_PROTOCOL_BLACKLISTED;
212}
213
214/**
215 * @typedef {Object} ProtocolHandlerOptions
216 * @property {Array<String>} [blacklist=[]] array of blacklisted schemes
217 */
218
219/**
220 * Resolver function for specific protocol
221 * @callback ProtocolCallback
222 * @param {String} url target url
223 * @returns {String|Promise<String>} resolved url _redirect location_
224 *
225 * @example
226 * // Resolve gdrive urls
227 * const { fetchInfo } = require('gdrive-file-info');
228 *
229 * async function resolve(url) {
230 * const itemId = new URL(url).pathname;
231 * const fileInfo = await fetchInfo(itemId);
232 * return fileInfo.downloadUrl;
233 * }
234 */
235
236/**
237 * Custom error calback for Express middleware
238 * @callback ProtocolErrorCallback
239 * @param {ProtocolError} err protocol error
240 * @param {String} url target url
241 * @param {import('@types/express').Response} res middleware response
242 *
243 * @example
244 * const handler = new ProtocolHandler();
245 * handler.protocol('s3://', resolve);
246 * // Act as passthrough proxy for blacklisted protocols
247 * app.use(handler.middleware('url', (err, url, res) => {
248 * if (err.code !== ProtocolError.ERR_PROTOCOL_BLACKLISTED) {
249 * return res.sendStatus(400);
250 * }
251 * res.redirect(url);
252 * }));
253 */