1 /* eslint complexity: ["error", 20] */
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) {
40 if (!user) throw new StatusError(401); // Very suspect...
41 throw new StatusError(404);
45 function generateChatTree(chatActivity) {
46 // group things in nested maps
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
56 if (!yearMap[year][month]) {
57 yearMap[year][month] = {};
59 yearMap[year][month][day] = count;
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.
66 _.each(yearMap, function(monthMap, year) {
68 _.each(monthMap, function(dayMap, month) {
70 _.each(dayMap, function(count, day) {
71 dayArray.push({ day: day, count: count });
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?
77 monthArray = _.sortBy(monthArray, 'month').reverse();
78 yearArray.push({ year: year, months: monthArray });
80 yearArray = _.sortBy(yearArray, 'year').reverse();
81 //console.log(JSON.stringify(yearArray, null, 2));
87 identifyRoute('app-archive-main'),
88 preventClickjackingMiddleware,
89 uriContextResolverMiddleware,
90 function(req, res, next) {
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;
100 var roomUrl = '/api/v1/rooms/' + troupe.id;
102 return validateRoomForReadOnlyAccess(user, policy)
104 return [policy.canAdmin(), contextGenerator.generateTroupeContext(req)];
106 .spread(function(adminAccess, troupeContext) {
107 var templateContext = {
111 bootScriptName: 'router-archive-home',
112 cssFileName: 'styles/router-archive-home.css',
113 troupeName: req.uriContext.uri,
115 noindex: troupe.noindex,
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
126 res.render('archive-home-template', templateContext);
130 redirectErrorMiddleware
133 exports.linksList = [
134 identifyRoute('app-archive-links'),
135 preventClickjackingMiddleware,
136 uriContextResolverMiddleware,
137 function(req, res, next) {
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;
147 var roomUrl = '/api/v1/rooms/' + troupe.id;
148 var isPrivate = !securityDescriptorUtils.isPublic(troupe);
150 return validateRoomForReadOnlyAccess(user, policy)
154 chatHeapmapAggregator.getHeatmapForRoom(troupe.id),
155 contextGenerator.generateTroupeContext(req)
158 .spread(function(adminAccess, chatActivity, troupeContext) {
159 // no start, no end, no timezone for now
160 var templateContext = {
164 bootScriptName: 'router-archive-links',
165 cssFileName: 'styles/router-archive-links.css',
166 troupeName: req.uriContext.uri,
168 noindex: troupe.noindex,
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)
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);
187 redirectErrorMiddleware
190 exports.chatArchive = [
191 identifyRoute('app-archive-date'),
192 uriContextResolverMiddleware,
193 preventClickjackingMiddleware,
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;
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;
218 const hourMatches = hourRange.match(/^(\d\d?)-(\d\d?)$/);
221 throw new StatusError(404, 'Hour was unable to be parsed');
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');
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) {
236 clientEnv['basePath'],
242 `${fromHour}-${fromHour + 1}`
249 const startDateUTC = moment.utc({ year: yyyy, month: mm - 1, day: dd, hour: fromHour });
250 let endDateUTC = moment(startDateUTC).add(1, 'days');
252 endDateUTC = moment.utc({ year: yyyy, month: mm - 1, day: dd, hour: toHour });
255 const nextDateUTC = moment(startDateUTC).add(1, 'days');
256 const previousDateUTC = moment(startDateUTC).subtract(1, 'days');
258 const aroundId = fixMongoIdQueryParam(req.query.at);
260 const chatMessage = await chatService.findById(aroundId);
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);
272 'Archive searching for messages in troupe %s in date range %s <-> %s',
277 const chatMessages = await chatService.findChatMessagesForTroupeForDateRange(
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) {
287 clientEnv['basePath'],
299 // If `currentUserId` isn't specified, all chats are `unread: false` as expected
300 const strategy = new restSerializer.ChatStrategy({
304 const [troupeContext, serialized] = await Promise.all([
305 contextGenerator.generateTroupeContext(req),
306 restSerializer.serialize(chatMessages, strategy)
308 troupeContext.archive = {
309 startDate: startDateUTC,
311 nextDate: nextDateUTC,
312 previousDate: previousDateUTC,
315 troupeContext.permalinkChatId = aroundId;
317 let language = req.headers['accept-language'];
319 language = language.split(';')[0].split(',');
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');
332 if (ordinalDate.indexOf('' + numericDate) === 0) {
333 ordinalPart = ordinalDate.substring(('' + numericDate).length);
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');
349 if (nextDateUTC.endOf('day').isSameOrBefore(today)) {
350 nextDateLink = '/' + uri + '/archives/' + nextDateUTC.format('YYYY/MM/DD');
353 const roomUrl = '/api/v1/rooms/' + troupe.id;
355 const isPrivate = !securityDescriptorUtils.isPublic(troupe);
358 If the we are showing archive for a finished day, we'll include caching headers
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());
365 return res.render('chat-archive-template', {
369 bootScriptName: 'router-archive-chat',
370 cssFileName: 'styles/router-archive-chat.css',
371 githubLink: '/' + req.uriContext.uri,
373 troupeContext: troupeContext,
374 troupeName: req.uriContext.uri,
375 chats: burstCalculator(serialized),
376 noindex: troupe.noindex,
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,
392 hourRanges: Array.from({ length: 24 }, (x, i) => ({
395 selected: i === fromHour,
397 clientEnv['basePath'],
407 fonts: fonts.getFonts(),
408 hasCachedFonts: fonts.hasCachedFonts(req.cookies)
411 redirectErrorMiddleware