Gitter migration: Setup redirects (rollout pt. 3)
[gitter.git] / server / handlers / app / archive.js
blob6f33e93c16ddd6abfa6a81fb00170fe796650161
1 /* eslint complexity: ["error", 20] */
2 'use strict';
4 var env = require('gitter-web-env');
5 var identifyRoute = env.middlewares.identifyRoute;
7 var Promise = require('bluebird');
8 var moment = require('moment');
9 var _ = require('lodash');
10 var StatusError = require('statuserror');
11 const asyncHandler = require('express-async-handler');
13 var chatService = require('gitter-web-chats');
14 var chatHeapmapAggregator = require('gitter-web-elasticsearch/lib/chat-heatmap-aggregator');
15 var restSerializer = require('../../serializers/rest-serializer');
16 var contextGenerator = require('../../web/context-generator');
17 var burstCalculator = require('../../utils/burst-calculator');
18 const generatePermalink = require('gitter-web-shared/chat/generate-permalink');
19 const urlJoin = require('url-join');
20 const clientEnv = require('gitter-client-env');
21 var debug = require('debug')('gitter:app:app-archive');
22 var fonts = require('../../web/fonts');
23 var securityDescriptorUtils = require('gitter-web-permissions/lib/security-descriptor-utils');
24 var getHeaderViewOptions = require('gitter-web-shared/templates/get-header-view-options');
25 var fixMongoIdQueryParam = require('../../web/fix-mongo-id-query-param');
27 var uriContextResolverMiddleware = require('../uri-context/uri-context-resolver-middleware');
28 var redirectErrorMiddleware = require('../uri-context/redirect-error-middleware');
29 var timezoneMiddleware = require('../../web/middlewares/timezone');
30 var preventClickjackingMiddleware = require('../../web/middlewares/prevent-clickjacking');
32 var ONE_DAY_SECONDS = 60 * 60 * 24; // 1 day
33 var ONE_DAY_MILLISECONDS = ONE_DAY_SECONDS * 1000;
34 var ONE_YEAR_SECONDS = 60 * 60 * 24 * 365; // 1 year
35 var ONE_YEAR_MILLISECONDS = ONE_YEAR_SECONDS * 1000;
37 var validateRoomForReadOnlyAccess = Promise.method(function(user, policy) {
38   return policy.canRead().then(function(access) {
39     if (access) return;
40     if (!user) throw new StatusError(401); // Very suspect...
41     throw new StatusError(404);
42   });
43 });
45 function generateChatTree(chatActivity) {
46   // group things in nested maps
47   var yearMap = {};
48   _.each(chatActivity, function(count, unixTime) {
49     var date = moment(unixTime, 'X');
50     var year = date.year();
51     var month = date.format('MM'); // 01-12
52     var day = date.format('DD'); // 01-31
53     if (!yearMap[year]) {
54       yearMap[year] = {};
55     }
56     if (!yearMap[year][month]) {
57       yearMap[year][month] = {};
58     }
59     yearMap[year][month][day] = count;
60   });
61   //console.log(JSON.stringify(yearMap, null, 2));
63   // change the nested maps into sorted nested arrays of objects
64   // O(𝑛³) code uphead. Good times.
65   var yearArray = [];
66   _.each(yearMap, function(monthMap, year) {
67     var monthArray = [];
68     _.each(monthMap, function(dayMap, month) {
69       var dayArray = [];
70       _.each(dayMap, function(count, day) {
71         dayArray.push({ day: day, count: count });
72       });
73       dayArray = _.sortBy(dayArray, 'day'); // not reversed
74       var monthName = moment.months()[parseInt(month, 10) - 1];
75       monthArray.push({ month: month, monthName: monthName, days: dayArray }); // monthName?
76     });
77     monthArray = _.sortBy(monthArray, 'month').reverse();
78     yearArray.push({ year: year, months: monthArray });
79   });
80   yearArray = _.sortBy(yearArray, 'year').reverse();
81   //console.log(JSON.stringify(yearArray, null, 2));
83   return yearArray;
86 exports.datesList = [
87   identifyRoute('app-archive-main'),
88   preventClickjackingMiddleware,
89   uriContextResolverMiddleware,
90   function(req, res, next) {
91     var user = req.user;
92     var troupe = req.uriContext.troupe;
93     var policy = req.uriContext.policy;
95     // This is where we want non-logged-in users to return
96     if (!user && req.session) {
97       req.session.returnTo = '/' + troupe.uri;
98     }
100     var roomUrl = '/api/v1/rooms/' + troupe.id;
102     return validateRoomForReadOnlyAccess(user, policy)
103       .then(function() {
104         return [policy.canAdmin(), contextGenerator.generateTroupeContext(req)];
105       })
106       .spread(function(adminAccess, troupeContext) {
107         var templateContext = {
108           layout: 'archive',
109           user: user,
110           archives: true,
111           bootScriptName: 'router-archive-home',
112           cssFileName: 'styles/router-archive-home.css',
113           troupeName: req.uriContext.uri,
114           isHomePage: true,
115           noindex: troupe.noindex,
116           roomUrl: roomUrl,
117           accessToken: req.accessToken,
118           public: securityDescriptorUtils.isPublic(troupe),
119           headerView: getHeaderViewOptions(troupeContext.troupe),
120           fonts: fonts.getFonts(),
121           hasCachedFonts: fonts.hasCachedFonts(req.cookies),
122           isAdmin: adminAccess, // Used by archive.hbs
123           troupeContext: troupeContext
124         };
126         res.render('archive-home-template', templateContext);
127       })
128       .catch(next);
129   },
130   redirectErrorMiddleware
133 exports.linksList = [
134   identifyRoute('app-archive-links'),
135   preventClickjackingMiddleware,
136   uriContextResolverMiddleware,
137   function(req, res, next) {
138     var user = req.user;
139     var troupe = req.uriContext.troupe;
140     var policy = req.uriContext.policy;
142     // This is where we want non-logged-in users to return
143     if (!user && req.session) {
144       req.session.returnTo = '/' + troupe.uri;
145     }
147     var roomUrl = '/api/v1/rooms/' + troupe.id;
148     var isPrivate = !securityDescriptorUtils.isPublic(troupe);
150     return validateRoomForReadOnlyAccess(user, policy)
151       .then(function() {
152         return [
153           policy.canAdmin(),
154           chatHeapmapAggregator.getHeatmapForRoom(troupe.id),
155           contextGenerator.generateTroupeContext(req)
156         ];
157       })
158       .spread(function(adminAccess, chatActivity, troupeContext) {
159         // no start, no end, no timezone for now
160         var templateContext = {
161           layout: 'archive',
162           user: user,
163           archives: true,
164           bootScriptName: 'router-archive-links',
165           cssFileName: 'styles/router-archive-links.css',
166           troupeName: req.uriContext.uri,
167           isHomePage: true,
168           noindex: troupe.noindex,
169           roomUrl: roomUrl,
170           accessToken: req.accessToken,
171           public: securityDescriptorUtils.isPublic(troupe),
172           headerView: getHeaderViewOptions(troupeContext.troupe),
173           isPrivate: isPrivate,
174           fonts: fonts.getFonts(),
175           hasCachedFonts: fonts.hasCachedFonts(req.cookies),
176           troupeContext: troupeContext,
177           isAdmin: adminAccess,
178           chatTree: generateChatTree(chatActivity)
179         };
181         res.setHeader('Cache-Control', 'public, max-age=' + ONE_DAY_SECONDS);
182         res.setHeader('Expires', new Date(Date.now() + ONE_DAY_MILLISECONDS).toUTCString());
183         res.render('archive-links-template', templateContext);
184       })
185       .catch(next);
186   },
187   redirectErrorMiddleware
190 exports.chatArchive = [
191   identifyRoute('app-archive-date'),
192   uriContextResolverMiddleware,
193   preventClickjackingMiddleware,
194   timezoneMiddleware,
195   // eslint-disable-next-line max-statements
196   asyncHandler(async (req, res /*, next*/) => {
197     const user = req.user;
198     const troupe = req.uriContext.troupe;
199     const policy = req.uriContext.policy;
201     await validateRoomForReadOnlyAccess(user, policy);
202     const troupeId = troupe.id;
204     // This is where we want non-logged-in users to return
205     if (!user && req.session) {
206       req.session.returnTo = '/' + troupe.uri;
207     }
209     const yyyy = parseInt(req.params.yyyy, 10);
210     const mm = parseInt(req.params.mm, 10);
211     const dd = parseInt(req.params.dd, 10);
213     const hourRange = req.params.hourRange;
215     let fromHour = 0;
216     let toHour = 0;
217     if (hourRange) {
218       const hourMatches = hourRange.match(/^(\d\d?)-(\d\d?)$/);
220       if (!hourMatches) {
221         throw new StatusError(404, 'Hour was unable to be parsed');
222       }
224       fromHour = parseInt(hourMatches[1], 10);
225       toHour = parseInt(hourMatches[2], 10);
227       if (Number.isNaN(fromHour) || fromHour < 0 || fromHour > 23) {
228         throw new StatusError(404, 'From hour can only be in range 0-23');
229       }
231       // Currently we force the range to always be 1 hour
232       // If the format isn't correct, redirect to the correct hour range
233       if (toHour !== fromHour + 1) {
234         res.redirect(
235           urlJoin(
236             clientEnv['basePath'],
237             troupe.uri,
238             'archives',
239             req.params.yyyy,
240             req.params.mm,
241             req.params.dd,
242             `${fromHour}-${fromHour + 1}`
243           )
244         );
245         return;
246       }
247     }
249     const startDateUTC = moment.utc({ year: yyyy, month: mm - 1, day: dd, hour: fromHour });
250     let endDateUTC = moment(startDateUTC).add(1, 'days');
251     if (hourRange) {
252       endDateUTC = moment.utc({ year: yyyy, month: mm - 1, day: dd, hour: toHour });
253     }
255     const nextDateUTC = moment(startDateUTC).add(1, 'days');
256     const previousDateUTC = moment(startDateUTC).subtract(1, 'days');
258     const aroundId = fixMongoIdQueryParam(req.query.at);
259     if (aroundId) {
260       const chatMessage = await chatService.findById(aroundId);
261       if (chatMessage) {
262         const sentDateTime = moment(chatMessage.sent);
263         const permalink = generatePermalink(troupe.uri, aroundId, sentDateTime, true);
264         if (urlJoin(clientEnv['basePath'], req.url) !== permalink) {
265           res.redirect(permalink);
266           return;
267         }
268       }
269     }
271     debug(
272       'Archive searching for messages in troupe %s in date range %s <-> %s',
273       troupeId,
274       startDateUTC,
275       endDateUTC
276     );
277     const chatMessages = await chatService.findChatMessagesForTroupeForDateRange(
278       troupeId,
279       startDateUTC,
280       endDateUTC
281     );
283     // If there are too many messages to display, then redirect to only show the first hour chunk
284     if (hourRange === undefined && chatMessages.length >= chatService.ARCHIVE_MESSAGE_LIMIT) {
285       res.redirect(
286         urlJoin(
287           clientEnv['basePath'],
288           troupe.uri,
289           'archives',
290           req.params.yyyy,
291           req.params.mm,
292           req.params.dd,
293           '0-1'
294         )
295       );
296       return;
297     }
299     // If `currentUserId` isn't specified, all chats are `unread: false` as expected
300     const strategy = new restSerializer.ChatStrategy({
301       troupeId: troupeId
302     });
304     const [troupeContext, serialized] = await Promise.all([
305       contextGenerator.generateTroupeContext(req),
306       restSerializer.serialize(chatMessages, strategy)
307     ]);
308     troupeContext.archive = {
309       startDate: startDateUTC,
310       endDate: endDateUTC,
311       nextDate: nextDateUTC,
312       previousDate: previousDateUTC,
313       messages: serialized
314     };
315     troupeContext.permalinkChatId = aroundId;
317     let language = req.headers['accept-language'];
318     if (language) {
319       language = language.split(';')[0].split(',');
320     } else {
321       language = 'en-uk';
322     }
324     const uri = req.uriContext.uri;
326     const startDateLocale = moment(startDateUTC).locale(language);
328     const ordinalDate = startDateLocale.format('Do');
329     const numericDate = startDateLocale.format('D');
331     let ordinalPart;
332     if (ordinalDate.indexOf('' + numericDate) === 0) {
333       ordinalPart = ordinalDate.substring(('' + numericDate).length);
334     } else {
335       ordinalPart = '';
336     }
338     const today = moment().endOf('day');
339     const dayNameFormatted = numericDate;
340     const dayOrdinalFormatted = ordinalPart;
341     const monthYearFormatted = startDateLocale.format('MMM YYYY');
343     let previousDateLink;
344     if (previousDateUTC.isAfter(moment([2013, 11, 1]))) {
345       previousDateLink = '/' + uri + '/archives/' + previousDateUTC.format('YYYY/MM/DD');
346     }
348     let nextDateLink;
349     if (nextDateUTC.endOf('day').isSameOrBefore(today)) {
350       nextDateLink = '/' + uri + '/archives/' + nextDateUTC.format('YYYY/MM/DD');
351     }
353     const roomUrl = '/api/v1/rooms/' + troupe.id;
355     const isPrivate = !securityDescriptorUtils.isPublic(troupe);
357     /*
358     If the we are showing archive for a finished day, we'll include caching headers
359     */
360     if (today >= endDateUTC) {
361       res.setHeader('Cache-Control', 'public, max-age=' + ONE_YEAR_SECONDS);
362       res.setHeader('Expires', new Date(Date.now() + ONE_YEAR_MILLISECONDS).toUTCString());
363     }
365     return res.render('chat-archive-template', {
366       layout: 'archive',
367       archives: true,
368       archiveChats: true,
369       bootScriptName: 'router-archive-chat',
370       cssFileName: 'styles/router-archive-chat.css',
371       githubLink: '/' + req.uriContext.uri,
372       user: user,
373       troupeContext: troupeContext,
374       troupeName: req.uriContext.uri,
375       chats: burstCalculator(serialized),
376       noindex: troupe.noindex,
377       roomUrl: roomUrl,
378       accessToken: req.accessToken,
379       headerView: getHeaderViewOptions(troupeContext.troupe),
380       isPrivate: isPrivate,
381       elementUrl: clientEnv.elementUrl,
383       /* For prerendered archive-navigation-view */
384       dayName: dayNameFormatted,
385       dayOrdinal: dayOrdinalFormatted,
386       previousDateLink: previousDateLink,
387       nextDateLink: nextDateLink,
388       monthYearFormatted: monthYearFormatted,
389       showHourPaginationControls: hourRange !== undefined,
390       fromHour,
391       toHour,
392       hourRanges: Array.from({ length: 24 }, (x, i) => ({
393         start: i,
394         end: i + 1,
395         selected: i === fromHour,
396         link: urlJoin(
397           clientEnv['basePath'],
398           troupe.uri,
399           'archives',
400           req.params.yyyy,
401           req.params.mm,
402           req.params.dd,
403           `${i}-${i + 1}`
404         )
405       })),
407       fonts: fonts.getFonts(),
408       hasCachedFonts: fonts.hasCachedFonts(req.cookies)
409     });
410   }),
411   redirectErrorMiddleware