• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mozilla / blurts-server / #12248

pending completion
#12248

push

circleci

web-flow
* MNTOR-1042 (#2819)

feat: set up GA4 with GTM

282 of 1329 branches covered (21.22%)

Branch coverage included in aggregate %.

3 of 3 new or added lines in 1 file covered. (100.0%)

959 of 3534 relevant lines covered (27.14%)

2.2 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/src/app.js
1
/* This Source Code Form is subject to the terms of the Mozilla Public
2
 * License, v. 2.0. If a copy of the MPL was not distributed with this
3
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4

5
import crypto from 'node:crypto'
6

7
import express from 'express'
8
import session from 'express-session'
9
import connectRedis from 'connect-redis'
10
import helmet from 'helmet'
11
import accepts from 'accepts'
12
import redis from 'redis'
13
import cookieParser from 'cookie-parser'
14
import rateLimit from 'express-rate-limit'
15
import Sentry from '@sentry/node'
16
import '@sentry/tracing'
17

18
import AppConstants from './app-constants.js'
19
import { localStorage } from './utils/local-storage.js'
20
import { errorHandler } from './middleware/error.js'
21
import { doubleCsrfProtection } from './utils/csrf.js'
22
import { initFluentBundles, updateLocale, getMessageWithLocale, getMessage } from './utils/fluent.js'
23
import { loadBreachesIntoApp } from './utils/hibp.js'
24
import { RateLimitError } from './utils/error.js'
25
import { initEmail } from './utils/email.js'
26
import indexRouter from './routes/index.js'
27

28
const app = express()
×
29
const isDev = AppConstants.NODE_ENV === 'dev'
×
30

31
// init sentry
32
Sentry.init({
×
33
  dsn: AppConstants.SENTRY_DSN,
34
  environment: AppConstants.NODE_ENV,
35
  debug: isDev,
36
  beforeSend (event, hint) {
37
    if (!hint.originalException.locales || hint.originalException.locales[0] === 'en') return event // return if no localization or localization is in english
×
38

39
    // try to force an english translation for the error message if localized
40
    if (hint.originalException.fluentID) {
×
41
      event.exception.values[0].value = getMessageWithLocale(hint.originalException.fluentID, 'en') || getMessage(hint.originalException.fluentID)
×
42
    }
43

44
    return event
×
45
  }
46
})
47

48
// Determine from where to serve client code/assets:
49
// Build script is triggered for `npm start` and assets are served from /dist.
50
// Build script is NOT run for `npm run dev`, assets are served from /src, and nodemon restarts server without build (faster dev).
51
const staticPath =
52
  process.env.npm_lifecycle_event === 'start' ? '../dist' : './client'
×
53

54
await initFluentBundles()
×
55

56
async function getRedisStore () {
57
  const RedisStoreConstructor = connectRedis(session)
×
58
  if (['', 'redis-mock'].includes(AppConstants.REDIS_URL)) {
×
59
    const redisMock = await import('redis-mock') // for devs without local redis
×
60
    return new RedisStoreConstructor({
×
61
      client: redisMock.default.createClient()
62
    })
63
  }
64
  return new RedisStoreConstructor({
×
65
    client: redis.createClient({ url: AppConstants.REDIS_URL })
66
  })
67
}
68

69
// middleware
70
app.use(
×
71
  helmet({
72
    crossOriginEmbedderPolicy: false
73
  })
74
)
75

76
app.use(
×
77
  Sentry.Handlers.requestHandler({
78
    request: ['headers', 'method', 'url'], // omit cookies, data, query_string
79
    user: ['id'] // omit username, email
80
  })
81
)
82

83
const imgSrc = [
×
84
  "'self'"
85
]
86

87
if (AppConstants.FXA_ENABLED) {
×
88
  const fxaSrc = new URL(AppConstants.OAUTH_PROFILE_URI).origin
×
89
  imgSrc.push(fxaSrc)
×
90
}
91

92
// Support GA4 per https://developers.google.com/tag-platform/tag-manager/web/csp
93
imgSrc.push('www.googletagmanager.com')
×
94

95
// disable forced https to allow localhost on Safari
96
app.use((_req, res, _next) => {
×
97
  res.locals.nonce = crypto.randomBytes(16).toString('hex')
×
98
  helmet.contentSecurityPolicy({
×
99
    directives: {
100
      upgradeInsecureRequests: isDev ? null : [],
×
101
      scriptSrc: [
102
        "'self'",
103
        // Support GA4 per https://developers.google.com/tag-platform/tag-manager/web/csp
104
        `'nonce-${res.locals.nonce}'`
105
      ],
106
      imgSrc,
107
      connectSrc: [
108
        "'self'",
109
        // Support GA4 per https://developers.google.com/tag-platform/tag-manager/web/csp
110
        'https://*.google-analytics.com',
111
        'https://*.analytics.google.com',
112
        'https://*.googletagmanager.com'
113
      ]
114
    }
115
  })(_req, res, _next)
116
})
117

118
// fallback to default 'no-referrer' only when 'strict-origin-when-cross-origin' not available
119
app.use(
×
120
  helmet.referrerPolicy({
121
    policy: ['no-referrer', 'strict-origin-when-cross-origin']
122
  })
123
)
124

125
// When a text/html request is received, negotiate and store the requested language
126
// Using asyncLocalStorage avoids having to pass req context down through every function (e.g. getMessage())
127
app.use((req, res, next) => {
×
128
  if (!req.headers.accept?.startsWith('text/html')) return next()
×
129

130
  localStorage.run(new Map(), () => {
×
131
    req.locale = updateLocale(accepts(req).languages())
×
132
    localStorage.getStore().set('locale', req.locale)
×
133
    next()
×
134
  })
135
})
136

137
// MNTOR-1009, 1117:
138
// Because of proxy settings, request / cookies are not persisted between calls
139
// Setting the trust proxy to high and securing the cookie allowed the cookie to persist
140
// If cookie.secure is set as true, for nodejs behind proxy, "trust proxy" needs to be set
141
app.set('trust proxy', 1)
×
142

143
// session
144
const SESSION_DURATION_HOURS = AppConstants.SESSION_DURATION_HOURS || 48
×
145
app.use(
×
146
  session({
147
    cookie: {
148
      maxAge: SESSION_DURATION_HOURS * 60 * 60 * 1000, // 48 hours
149
      rolling: true,
150
      sameSite: 'lax',
151
      secure: !isDev
152
    },
153
    resave: false,
154
    saveUninitialized: true,
155
    secret: AppConstants.COOKIE_SECRET,
156
    store: await getRedisStore()
157
  })
158
)
159

160
// Load breaches into namespaced cache
161
try {
×
162
  await loadBreachesIntoApp(app)
×
163
} catch (error) {
164
  console.error('Error loading breaches into app.locals', error)
×
165
}
166

167
app.use(express.static(staticPath))
×
168
app.use(express.json())
×
169
app.use(cookieParser(AppConstants.COOKIE_SECRET))
×
170
app.use(doubleCsrfProtection)
×
171

172
const apiLimiter = rateLimit({
×
173
  windowMs: 15 * 60 * 1000, // 15 minutes
174
  max: 100, // Limit each IP to 100 requests per `window` (here, per 15 minutes)
175
  standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
176
  legacyHeaders: false // Disable the `X-RateLimit-*` headers
177
})
178

179
app.use('/api', apiLimiter)
×
180

181
// routing
182
app.use('/', indexRouter)
×
183

184
// sentry error handler
185
app.use(Sentry.Handlers.errorHandler({
×
186
  shouldHandleError (error) {
187
    if (error instanceof RateLimitError) return true
×
188
  }
189
}))
190

191
// app error handler
192
app.use(errorHandler)
×
193

194
app.listen(AppConstants.PORT, async function () {
×
195
  console.info(`MONITOR V2: Server listening at ${this.address().port}`)
×
196
  console.info(`Static files served from ${staticPath}`)
×
197
  try {
×
198
    await initEmail()
×
199
    console.info('Email initialized')
×
200
  } catch (ex) {
201
    console.error('try-initialize-email-error', { ex })
×
202
  }
203
})
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc