1 | ;
|
2 |
|
3 | const debug = require('debug')('protocol-handler');
|
4 | const pCatchIf = require('p-catch-if');
|
5 | const pTry = require('p-try');
|
6 |
|
7 | const reProtocol = /^([a-z0-9.+-]+:)/i;
|
8 | const BLACKLISTED_PROTOCOLS = ['http:', 'https:', 'file:', 'blob:', 'about:'];
|
9 |
|
10 | const defineProperty = (obj, name, value) => Object.defineProperty(obj, name, { value });
|
11 | const isProtocolRelative = url => url.trim().startsWith('//');
|
12 |
|
13 | /**
|
14 | * Custom error indicating invalid, unknown or blacklisted protocol
|
15 | * @augments Error
|
16 | */
|
17 | class 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 | }
|
36 | defineProperty(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 | */
|
43 | ProtocolError.ERR_PROTOCOL_INVALID = -1;
|
44 | ProtocolError.ERR_PROTOCOL_UNKNOWN = 1;
|
45 | ProtocolError.ERR_PROTOCOL_BLACKLISTED = 2;
|
46 |
|
47 | /**
|
48 | * Create protocol handler
|
49 | * @class
|
50 | */
|
51 | class 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 | */
|
200 | module.exports = options => new ProtocolHandler(options);
|
201 | module.exports.ProtocolHandler = ProtocolHandler;
|
202 | module.exports.ProtocolError = ProtocolError;
|
203 |
|
204 | function getProtocol(str) {
|
205 | const match = str.trim().match(reProtocol);
|
206 | return match && match[0];
|
207 | }
|
208 |
|
209 | function 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 | */
|