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