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');
10 const _fileScope
= common
.fileScope(__filename
);
12 const defaultOptions
= {
13 pageTitle: require('../package.json').name
,
14 logoUrl: '/static/muRL.png',
16 staticDirectory: '/static/',
20 constructor(logger
, db
, options
= {}) {
21 common
.setOptions(this, defaultOptions
, options
);
24 this.makeSlugGenerator
= makeSlugGenerator
;
29 * Render root content. Shared by GET and POST.
31 * @param {string} ctx.authToken
32 * @param {string} ctx.createdLink
33 * @param {string} ctx.message
34 * @param {string} ctx.sourceLink
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
,
46 case Enum
.ContentType
.TextHTML:
48 return Template
.rootHTML(ctx
, this.pageTitle
, this.logoUrl
);
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
60 async
getRoot(req
, res
, ctx
) {
61 const _scope
= _fileScope('getRoot');
62 this.logger
.debug(_scope
, 'called', { ctx
});
64 const content
= this.rootContent(ctx
);
66 /* Since this GET response will never change while server is running,
67 * set headers to make it cacheable.
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
);
75 res
.setHeader(Enum
.Header
.LastModified
, this.startTime
);
76 res
.setHeader(Enum
.Header
.ETag
, this.getRootETag
);
78 if (common
.isClientCached(req
, this.startTimeMs
, this.getRootETag
)) {
79 this.logger
.debug(_scope
, 'client cached response', { ctx
});
84 this.logger
.debug(_scope
, 'response', { ctx
});
90 * The heart of the littler.
91 * TODO: other styles of generators
94 async
_getNewIdentifier(dbCtx
, url
) {
95 const idGen
= this.makeSlugGenerator(url
);
98 ({ done
, value: id
} = await idGen
.next());
100 throw new SlugGeneratorExhausted();
102 } while (await
this.db
.getLinkById(dbCtx
, id
));
108 * Validates the ctx.sourceLink is acceptable to add.
109 * @param {object} ctx
110 * @param {string} ctx.sourceLink
111 * @param {string=} ctx.authenticationId
113 _validateContextURL(ctx
) {
114 const _scope
= _fileScope('_validateContextURL');
116 // Admin is allowed to create locally served entries.
117 if (ctx
.sourceLink
&& ctx
.sourceLink
.startsWith(this.staticDirectory
)
118 && ctx
.authenticationId
) {
121 // Otherwise, url must be complete.
123 new URL(ctx
.sourceLink
);
125 this.logger
.debug(_scope
, 'invalid url', { error: e
, ctx
});
126 throw new ResponseError(Enum
.ErrorResponse
.InvalidURLParameter
);
132 * Get request for link, redirect.
133 * @param {http.ServerResponse} res
134 * @param {object} ctx
136 async
getById(res
, ctx
) {
137 const _scope
= _fileScope('getById');
138 this.logger
.debug(_scope
, 'called', { ctx
});
141 await
this.db
.context(async (dbCtx
) => {
142 link
= await
this.db
.accessLink(dbCtx
, ctx
.params
.id
);
145 this.logger
.info(_scope
, 'no such link', { ctx
});
146 throw new ResponseError(Enum
.ErrorResponse
.InvalidLinkId
);
148 this.logger
.debug(_scope
, 'found link', { link
, ctx
});
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
);
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.
161 const fileName
= link
.url
.slice(this.staticDirectory
.length
);
162 this.logger
.info(_scope
, 'static link', { link
, ctx
});
163 throw new ServeStaticFile(fileName
);
166 this.logger
.info(_scope
, 'redirect link', { link
, ctx
});
168 res
.statusCode
= 307; // Temporary Redirect
169 res
.setHeader('Location', link
.url
);
170 res
.end('Redirecting to ' + link
.url
);
175 * POST request for root, create new link.
176 * @param {http.ServerResponse} res
177 * @param {object} ctx
179 async
postRoot(res
, ctx
) {
180 const _scope
= _fileScope('postRoot');
181 this.logger
.debug(_scope
, 'called', { ctx
});
184 ctx
.sourceLink
= ctx
.parsedBody
.url
;
185 if (!ctx
.sourceLink
) {
186 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
, 'missing required parameter');
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
);
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
});
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
});
208 content
= this.rootContent(ctx
);
209 return res
.end(content
);
216 * Put request for link, create with id, or update url.
217 * @param {http.ServerResponse} res
218 * @param {object} ctx
220 async
putById(res
, ctx
) {
221 const _scope
= _fileScope('putById');
222 this.logger
.debug(_scope
, 'called', { ctx
});
224 ctx
.sourceLink
= ctx
.parsedBody
.url
;
225 if (!ctx
.sourceLink
) {
226 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
, 'missing required parameter');
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
);
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
);
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
});
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
});
253 const content
= this.rootContent(ctx
);
254 return res
.end(content
);
259 * Delete request for link, expire.
260 * @param {http.ServerResponse} res
261 * @param {object} ctx
263 async
deleteById(res
, ctx
) {
264 const _scope
= _fileScope('deleteById');
265 this.logger
.debug(_scope
, 'called', { ctx
});
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
);
272 this.logger
.debug(_scope
, 'no such id', { ctx
});
273 throw new ResponseError(Enum
.ErrorResponse
.InvalidLinkId
);
275 if (link
.expires
&& link
.expires
< Date
.now()) {
276 res
.statusCode
= 304; // Not Modified
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
290 * Render link information.
294 infoContent(ctx
, details
) {
295 switch (ctx
.responseType
) {
296 case Enum
.ContentType
.ApplicationJson:
297 return JSON
.stringify(details
);
299 case Enum
.ContentType
.TextHTML:
301 return Template
.infoHTML(ctx
, details
, this.pageTitle
);
307 * Get request for link information.
308 * @param {http.ServerResponse} res
309 * @param {object} ctx
311 async
getByIdInfo(res
, ctx
) {
312 const _scope
= _fileScope('getByIdInfo');
313 this.logger
.debug(_scope
, 'called', { ctx
});
316 await
this.db
.context(async (dbCtx
) => {
317 link
= await
this.db
.getLinkById(dbCtx
, ctx
.params
.id
);
319 this.logger
.debug(_scope
, 'no such link', { ctx
});
320 throw new ResponseError(Enum
.ErrorResponse
.InvalidLinkId
);
322 this.logger
.info(_scope
, 'link found', { ctx
, link
});
325 res
.end(this.infoContent(ctx
, link
));
329 /** Render report content.
330 * @param {object} ctx
331 * @param {object[]} links
333 reportContent(ctx
, links
) {
334 switch (ctx
.responseType
) {
335 case Enum
.ContentType
.ApplicationJson:
336 return JSON
.stringify(links
);
338 case Enum
.ContentType
.TextHTML:
340 return Template
.reportHTML(ctx
, links
, this.pageTitle
);
346 * Get request for link information.
347 * @param {http.ServerResponse} res
348 * @param {object} ctx
350 async
getAdminReport(res
, ctx
) {
351 const _scope
= _fileScope('getAdminReport');
352 this.logger
.debug(_scope
, 'called', { ctx
});
355 await
this.db
.context(async (dbCtx
) => {
356 links
= await
this.db
.getAllLinks(dbCtx
);
357 this.logger
.info(_scope
, 'found links', { ctx
, links
});
360 res
.end(this.reportContent(ctx
, links
));
365 module
.exports
= Manager
;