initial commit
[urlittler] / src / manager.js
1 'use strict';
2
3 const Authenticator = require('./authenticator');
4 const common = require('./common');
5 const Enum = require('./enum');
6 const { ResponseError, ServeStaticFile, SlugGeneratorExhausted } = require('./errors');
7 const { makeSlugGenerator } = require('./slug');
8 const Template = require('./template');
9
10 const _fileScope = common.fileScope(__filename);
11
12 const defaultOptions = {
13 pageTitle: require('../package.json').name,
14 selfBaseUrl: '',
15 staticDirectory: '/static/',
16 };
17
18 class Manager {
19 constructor(logger, db, options = {}) {
20 common.setOptions(this, defaultOptions, options);
21 this.logger = logger;
22 this.db = db;
23 this.makeSlugGenerator = makeSlugGenerator;
24 }
25
26
27 /**
28 * Render root content. Shared by GET and POST.
29 * @param {Object} ctx
30 * @param {string} ctx.authToken
31 * @param {string} ctx.createdLink
32 * @param {string} ctx.message
33 * @param {string} ctx.sourceLink
34 */
35 rootContent(ctx) {
36 // eslint-disable-next-line sonarjs/no-small-switch
37 switch (ctx.responseType) {
38 case Enum.ContentType.ApplicationJson:
39 return JSON.stringify({
40 link: ctx.createdLink,
41 src: ctx.sourceLink,
42 token: ctx.authToken,
43 });
44
45 case Enum.ContentType.TextHTML:
46 default:
47 return Template.rootHTML(ctx, this.pageTitle);
48 }
49 }
50
51
52 /**
53 * GET request for root.
54 * NB only this manager gets req, to access headers for cache response.
55 * @param {http.ClientRequest} req
56 * @param {http.ServerResponse} res
57 * @param {object} ctx
58 */
59 async getRoot(req, res, ctx) {
60 const _scope = _fileScope('getRoot');
61 this.logger.debug(_scope, 'called', { ctx });
62
63 const content = this.rootContent(ctx);
64
65 /* Since this GET response will never change while server is running,
66 * set headers to make it cacheable.
67 */
68 if (!this.startTime) {
69 const now = new Date();
70 this.startTime = now.toGMTString();
71 this.startTimeMs = now.getTime();
72 this.getRootETag = common.generateETag(undefined, undefined, content);
73 }
74 res.setHeader(Enum.Header.LastModified, this.startTime);
75 res.setHeader(Enum.Header.ETag, this.getRootETag);
76
77 if (common.isClientCached(req, this.startTimeMs, this.getRootETag)) {
78 this.logger.debug(_scope, 'client cached response', { ctx });
79 res.statusCode = 304;
80 res.end();
81 return;
82 }
83 this.logger.debug(_scope, 'response', { ctx });
84 res.end(content);
85 }
86
87
88 /**
89 * The heart of the littler.
90 * TODO: other styles of generators
91 * @param {*} dbCtx
92 */
93 async _getNewIdentifier(dbCtx, url) {
94 const idGen = this.makeSlugGenerator(url);
95 let id, done;
96 do {
97 ({ done, value: id } = await idGen.next());
98 if (done || !id) {
99 throw new SlugGeneratorExhausted();
100 }
101 } while (await this.db.getLinkById(dbCtx, id));
102 return id;
103 }
104
105
106 /**
107 * Validates the ctx.sourceLink is acceptable to add.
108 * @param {object} ctx
109 * @param {string} ctx.sourceLink
110 * @param {string=} ctx.authenticationId
111 */
112 _validateContextURL(ctx) {
113 const _scope = _fileScope('_validateContextURL');
114
115 // Admin is allowed to create locally served entries.
116 if (ctx.sourceLink && ctx.sourceLink.startsWith(this.staticDirectory)
117 && ctx.authenticationId) {
118 return;
119 }
120 // Otherwise, url must be complete.
121 try {
122 new URL(ctx.sourceLink);
123 } catch (e) {
124 this.logger.debug(_scope, 'invalid url', { error: e, ctx });
125 throw new ResponseError(Enum.ErrorResponse.InvalidURLParameter);
126 }
127 }
128
129
130 /**
131 * Get request for link, redirect.
132 * @param {http.ServerResponse} res
133 * @param {object} ctx
134 */
135 async getById(res, ctx) {
136 const _scope = _fileScope('getById');
137 this.logger.debug(_scope, 'called', { ctx });
138
139 let link;
140 await this.db.context(async (dbCtx) => {
141 link = await this.db.accessLink(dbCtx, ctx.params.id);
142 });
143 if (!link) {
144 this.logger.info(_scope, 'no such link', { ctx });
145 throw new ResponseError(Enum.ErrorResponse.InvalidLinkId);
146 }
147 this.logger.debug(_scope, 'found link', { link, ctx });
148
149 if (link.expires && link.expires <= Date.now()) {
150 this.logger.info(_scope, 'link expired', { link, ctx });
151 const expiresDate = new Date(link.expires * 1000).toGMTString();
152 res.setHeader(Enum.Header.LastSeen, expiresDate);
153 throw new ResponseError(Enum.ErrorResponse.Gone);
154 }
155
156 if (link.url.startsWith(this.staticDirectory)) {
157 /* This is only a little awkward: throw back up to service to let the
158 * dingus handle the static file serving.
159 */
160 const fileName = link.url.slice(this.staticDirectory.length);
161 this.logger.info(_scope, 'static link', { link, ctx });
162 throw new ServeStaticFile(fileName);
163 }
164
165 this.logger.info(_scope, 'redirect link', { link, ctx });
166
167 res.statusCode = 307; // Temporary Redirect
168 res.setHeader('Location', link.url);
169 res.end('Redirecting to ' + link.url);
170 }
171
172
173 /**
174 * POST request for root, create new link.
175 * @param {http.ServerResponse} res
176 * @param {object} ctx
177 */
178 async postRoot(res, ctx) {
179 const _scope = _fileScope('postRoot');
180 this.logger.debug(_scope, 'called', { ctx });
181
182 let content;
183 ctx.sourceLink = ctx.parsedBody.url;
184 if (!ctx.sourceLink) {
185 throw new ResponseError(Enum.ErrorResponse.BadRequest, 'missing required parameter');
186 }
187 this._validateContextURL(ctx);
188 await this.db.context(async (dbCtx) => {
189 await this.db.transaction(dbCtx, async (txCtx) => {
190 const existingLink = await this.db.getLinkByUrl(txCtx, ctx.sourceLink);
191 if (existingLink) {
192 res.statusCode = 200; // OK
193 ctx.createdLink = `${this.selfBaseUrl}/${existingLink.id}`;
194 if (existingLink.expires < Date.now()) {
195 await this.db.expireLink(txCtx, existingLink.id, null);
196 ctx.authToken = existingLink.authToken;
197 this.logger.info(_scope, 'un-expired link', { existingLink, ctx });
198 }
199 } else {
200 res.statusCode = 201; // Created
201 const id = await this._getNewIdentifier(txCtx, ctx.sourceLink);
202 ctx.authToken = await Authenticator.generateToken();
203 await this.db.insertLink(txCtx, id, ctx.sourceLink, ctx.authToken);
204 ctx.createdLink = `${this.selfBaseUrl}/${id}`;
205 this.logger.info(_scope, 'created new link', { id, ctx });
206 }
207 content = this.rootContent(ctx);
208 return res.end(content);
209 });
210 });
211 }
212
213
214 /**
215 * Put request for link, create with id, or update url.
216 * @param {http.ServerResponse} res
217 * @param {object} ctx
218 */
219 async putById(res, ctx) {
220 const _scope = _fileScope('putById');
221 this.logger.debug(_scope, 'called', { ctx });
222
223 ctx.sourceLink = ctx.parsedBody.url;
224 if (!ctx.sourceLink) {
225 throw new ResponseError(Enum.ErrorResponse.BadRequest, 'missing required parameter');
226 }
227 this._validateContextURL(ctx);
228 await this.db.context(async (dbCtx) => {
229 await this.db.transaction(dbCtx, async (txCtx) => {
230 const existingLink = await this.db.getLinkById(txCtx, ctx.params.id);
231 if (!existingLink) {
232 // To create a specific link, need admin authentication.
233 if (!ctx.authenticationId) {
234 this.logger.debug(_scope, 'cannot put without valid credentials', { ctx });
235 throw new ResponseError(Enum.ErrorResponse.Forbidden);
236 }
237
238 res.statusCode = 201; // Created
239 ctx.authToken = await Authenticator.generateToken();
240 this.db.insertLink(txCtx, ctx.params.id, ctx.sourceLink, ctx.authToken);
241 ctx.createdLink = `${this.selfBaseUrl}/${ctx.params.id}`;
242 this.logger.info(_scope, 'created new link', { ctx });
243 } else {
244 // Update link
245 await this.db.insertLink(txCtx, existingLink.id, ctx.sourceLink, existingLink.token);
246 ctx.createdLink = `${this.selfBaseUrl}/${existingLink.id}`;
247 this.logger.info(_scope, 'updated link', { ctx });
248 }
249 });
250 });
251
252 const content = this.rootContent(ctx);
253 return res.end(content);
254 }
255
256
257 /**
258 * Delete request for link, expire.
259 * @param {http.ServerResponse} res
260 * @param {object} ctx
261 */
262 async deleteById(res, ctx) {
263 const _scope = _fileScope('deleteById');
264 this.logger.debug(_scope, 'called', { ctx });
265
266 let link;
267 await this.db.context(async (dbCtx) => {
268 await this.db.transaction(dbCtx, async (txCtx) => {
269 link = await this.db.getLinkById(txCtx, ctx.params.id);
270 if (!link) {
271 this.logger.debug(_scope, 'no such id', { ctx });
272 throw new ResponseError(Enum.ErrorResponse.InvalidLinkId);
273 }
274 if (link.expires && link.expires < Date.now()) {
275 res.statusCode = 304; // Not Modified
276 return;
277 }
278 const when = Date.now() / 1000;
279 await this.db.expireLink(txCtx, ctx.params.id, when);
280 this.logger.info(_scope, 'expired link', { ctx, when });
281 res.statusCode = 204; // No Content
282 });
283 });
284 res.end();
285 }
286
287
288 /**
289 * Render link information.
290 * @param {*} ctx
291 * @param {*} details
292 */
293 infoContent(ctx, details) {
294 switch (ctx.responseType) {
295 case Enum.ContentType.ApplicationJson:
296 return JSON.stringify(details);
297
298 case Enum.ContentType.TextHTML:
299 default:
300 return Template.infoHTML(ctx, details, this.pageTitle);
301 }
302 }
303
304
305 /**
306 * Get request for link information.
307 * @param {http.ServerResponse} res
308 * @param {object} ctx
309 */
310 async getByIdInfo(res, ctx) {
311 const _scope = _fileScope('getByIdInfo');
312 this.logger.debug(_scope, 'called', { ctx });
313
314 let link;
315 await this.db.context(async (dbCtx) => {
316 link = await this.db.getLinkById(dbCtx, ctx.params.id);
317 if (!link) {
318 this.logger.debug(_scope, 'no such link', { ctx });
319 throw new ResponseError(Enum.ErrorResponse.InvalidLinkId);
320 }
321 this.logger.info(_scope, 'link found', { ctx, link });
322 });
323
324 res.end(this.infoContent(ctx, link));
325 }
326
327
328 /** Render report content.
329 * @param {object} ctx
330 * @param {object[]} links
331 */
332 reportContent(ctx, links) {
333 switch (ctx.responseType) {
334 case Enum.ContentType.ApplicationJson:
335 return JSON.stringify(links);
336
337 case Enum.ContentType.TextHTML:
338 default:
339 return Template.reportHTML(ctx, links, this.pageTitle);
340 }
341 }
342
343
344 /**
345 * Get request for link information.
346 * @param {http.ServerResponse} res
347 * @param {object} ctx
348 */
349 async getAdminReport(res, ctx) {
350 const _scope = _fileScope('getAdminReport');
351 this.logger.debug(_scope, 'called', { ctx });
352
353 let links;
354 await this.db.context(async (dbCtx) => {
355 links = await this.db.getAllLinks(dbCtx);
356 this.logger.info(_scope, 'found links', { ctx, links });
357 });
358
359 res.end(this.reportContent(ctx, links));
360 }
361
362 }
363
364 module.exports = Manager;