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
,
15 staticDirectory: '/static/',
19 constructor(logger
, db
, options
= {}) {
20 common
.setOptions(this, defaultOptions
, options
);
23 this.makeSlugGenerator
= makeSlugGenerator
;
28 * Render root content. Shared by GET and POST.
30 * @param {string} ctx.authToken
31 * @param {string} ctx.createdLink
32 * @param {string} ctx.message
33 * @param {string} ctx.sourceLink
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
,
45 case Enum
.ContentType
.TextHTML:
47 return Template
.rootHTML(ctx
, this.pageTitle
);
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
59 async
getRoot(req
, res
, ctx
) {
60 const _scope
= _fileScope('getRoot');
61 this.logger
.debug(_scope
, 'called', { ctx
});
63 const content
= this.rootContent(ctx
);
65 /* Since this GET response will never change while server is running,
66 * set headers to make it cacheable.
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
);
74 res
.setHeader(Enum
.Header
.LastModified
, this.startTime
);
75 res
.setHeader(Enum
.Header
.ETag
, this.getRootETag
);
77 if (common
.isClientCached(req
, this.startTimeMs
, this.getRootETag
)) {
78 this.logger
.debug(_scope
, 'client cached response', { ctx
});
83 this.logger
.debug(_scope
, 'response', { ctx
});
89 * The heart of the littler.
90 * TODO: other styles of generators
93 async
_getNewIdentifier(dbCtx
, url
) {
94 const idGen
= this.makeSlugGenerator(url
);
97 ({ done
, value: id
} = await idGen
.next());
99 throw new SlugGeneratorExhausted();
101 } while (await
this.db
.getLinkById(dbCtx
, id
));
107 * Validates the ctx.sourceLink is acceptable to add.
108 * @param {object} ctx
109 * @param {string} ctx.sourceLink
110 * @param {string=} ctx.authenticationId
112 _validateContextURL(ctx
) {
113 const _scope
= _fileScope('_validateContextURL');
115 // Admin is allowed to create locally served entries.
116 if (ctx
.sourceLink
&& ctx
.sourceLink
.startsWith(this.staticDirectory
)
117 && ctx
.authenticationId
) {
120 // Otherwise, url must be complete.
122 new URL(ctx
.sourceLink
);
124 this.logger
.debug(_scope
, 'invalid url', { error: e
, ctx
});
125 throw new ResponseError(Enum
.ErrorResponse
.InvalidURLParameter
);
131 * Get request for link, redirect.
132 * @param {http.ServerResponse} res
133 * @param {object} ctx
135 async
getById(res
, ctx
) {
136 const _scope
= _fileScope('getById');
137 this.logger
.debug(_scope
, 'called', { ctx
});
140 await
this.db
.context(async (dbCtx
) => {
141 link
= await
this.db
.accessLink(dbCtx
, ctx
.params
.id
);
144 this.logger
.info(_scope
, 'no such link', { ctx
});
145 throw new ResponseError(Enum
.ErrorResponse
.InvalidLinkId
);
147 this.logger
.debug(_scope
, 'found link', { link
, ctx
});
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
);
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.
160 const fileName
= link
.url
.slice(this.staticDirectory
.length
);
161 this.logger
.info(_scope
, 'static link', { link
, ctx
});
162 throw new ServeStaticFile(fileName
);
165 this.logger
.info(_scope
, 'redirect link', { link
, ctx
});
167 res
.statusCode
= 307; // Temporary Redirect
168 res
.setHeader('Location', link
.url
);
169 res
.end('Redirecting to ' + link
.url
);
174 * POST request for root, create new link.
175 * @param {http.ServerResponse} res
176 * @param {object} ctx
178 async
postRoot(res
, ctx
) {
179 const _scope
= _fileScope('postRoot');
180 this.logger
.debug(_scope
, 'called', { ctx
});
183 ctx
.sourceLink
= ctx
.parsedBody
.url
;
184 if (!ctx
.sourceLink
) {
185 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
, 'missing required parameter');
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
);
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
});
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
});
207 content
= this.rootContent(ctx
);
208 return res
.end(content
);
215 * Put request for link, create with id, or update url.
216 * @param {http.ServerResponse} res
217 * @param {object} ctx
219 async
putById(res
, ctx
) {
220 const _scope
= _fileScope('putById');
221 this.logger
.debug(_scope
, 'called', { ctx
});
223 ctx
.sourceLink
= ctx
.parsedBody
.url
;
224 if (!ctx
.sourceLink
) {
225 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
, 'missing required parameter');
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
);
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
);
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
});
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
});
252 const content
= this.rootContent(ctx
);
253 return res
.end(content
);
258 * Delete request for link, expire.
259 * @param {http.ServerResponse} res
260 * @param {object} ctx
262 async
deleteById(res
, ctx
) {
263 const _scope
= _fileScope('deleteById');
264 this.logger
.debug(_scope
, 'called', { ctx
});
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
);
271 this.logger
.debug(_scope
, 'no such id', { ctx
});
272 throw new ResponseError(Enum
.ErrorResponse
.InvalidLinkId
);
274 if (link
.expires
&& link
.expires
< Date
.now()) {
275 res
.statusCode
= 304; // Not Modified
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
289 * Render link information.
293 infoContent(ctx
, details
) {
294 switch (ctx
.responseType
) {
295 case Enum
.ContentType
.ApplicationJson:
296 return JSON
.stringify(details
);
298 case Enum
.ContentType
.TextHTML:
300 return Template
.infoHTML(ctx
, details
, this.pageTitle
);
306 * Get request for link information.
307 * @param {http.ServerResponse} res
308 * @param {object} ctx
310 async
getByIdInfo(res
, ctx
) {
311 const _scope
= _fileScope('getByIdInfo');
312 this.logger
.debug(_scope
, 'called', { ctx
});
315 await
this.db
.context(async (dbCtx
) => {
316 link
= await
this.db
.getLinkById(dbCtx
, ctx
.params
.id
);
318 this.logger
.debug(_scope
, 'no such link', { ctx
});
319 throw new ResponseError(Enum
.ErrorResponse
.InvalidLinkId
);
321 this.logger
.info(_scope
, 'link found', { ctx
, link
});
324 res
.end(this.infoContent(ctx
, link
));
328 /** Render report content.
329 * @param {object} ctx
330 * @param {object[]} links
332 reportContent(ctx
, links
) {
333 switch (ctx
.responseType
) {
334 case Enum
.ContentType
.ApplicationJson:
335 return JSON
.stringify(links
);
337 case Enum
.ContentType
.TextHTML:
339 return Template
.reportHTML(ctx
, links
, this.pageTitle
);
345 * Get request for link information.
346 * @param {http.ServerResponse} res
347 * @param {object} ctx
349 async
getAdminReport(res
, ctx
) {
350 const _scope
= _fileScope('getAdminReport');
351 this.logger
.debug(_scope
, 'called', { ctx
});
354 await
this.db
.context(async (dbCtx
) => {
355 links
= await
this.db
.getAllLinks(dbCtx
);
356 this.logger
.info(_scope
, 'found links', { ctx
, links
});
359 res
.end(this.reportContent(ctx
, links
));
364 module
.exports
= Manager
;