Leon4gr45 commited on
Commit
936b397
·
verified ·
1 Parent(s): b2a00c5

Upload folder using huggingface_hub (part 2)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. assets/js/dashboard.tsx +112 -0
  2. assets/js/dashboard/util/filter-text.tsx +78 -0
  3. assets/js/dashboard/util/filters.js +311 -0
  4. assets/js/dashboard/util/filters.test.ts +82 -0
  5. assets/js/dashboard/util/goals.ts +34 -0
  6. assets/js/dashboard/util/money.ts +23 -0
  7. assets/js/dashboard/util/number-formatter.test.ts +33 -0
  8. assets/js/dashboard/util/number-formatter.ts +84 -0
  9. assets/js/dashboard/util/realtime-update-timer.js +8 -0
  10. assets/js/dashboard/util/seconds-since-last-load.js +15 -0
  11. assets/js/dashboard/util/storage.js +37 -0
  12. assets/js/dashboard/util/tooltip.tsx +109 -0
  13. assets/js/dashboard/util/url-search-params-v1.ts +121 -0
  14. assets/js/dashboard/util/url-search-params-v2.test.ts +204 -0
  15. assets/js/dashboard/util/url-search-params-v2.ts +83 -0
  16. assets/js/dashboard/util/url-search-params.test.ts +331 -0
  17. assets/js/dashboard/util/url-search-params.ts +376 -0
  18. assets/js/dashboard/util/url.test.ts +105 -0
  19. assets/js/dashboard/util/url.ts +86 -0
  20. assets/js/embed.content.js +16 -0
  21. assets/js/embed.host.js +24 -0
  22. assets/js/liveview/combo-box.js +98 -0
  23. assets/js/liveview/dropdown.js +20 -0
  24. assets/js/liveview/live_socket.js +78 -0
  25. assets/js/liveview/phx_events.js +20 -0
  26. assets/js/polyfills/closest.js +14 -0
  27. assets/js/types/globals.d.ts +1 -0
  28. assets/js/types/query-api.d.ts +216 -0
  29. assets/package-lock.json +0 -0
  30. assets/package.json +79 -0
  31. assets/test-utils/app-context-providers.tsx +117 -0
  32. assets/test-utils/extend-expect.ts +1 -0
  33. assets/test-utils/index.ts +23 -0
  34. assets/test-utils/jsdom-mocks.ts +5 -0
  35. assets/test-utils/mock-api.ts +92 -0
  36. assets/test-utils/reset-state.ts +11 -0
  37. assets/tsconfig.json +14 -0
  38. config/.env.dev +38 -0
  39. config/.env.e2e_test +39 -0
  40. config/.env.load +37 -0
  41. config/.env.test +31 -0
  42. config/ce.exs +22 -0
  43. config/ce_dev.exs +11 -0
  44. config/ce_test.exs +3 -0
  45. config/config.exs +89 -0
  46. config/dev.exs +41 -0
  47. config/e2e_test.exs +20 -0
  48. config/load.exs +17 -0
  49. config/prod.exs +7 -0
  50. config/runtime.exs +1102 -0
assets/js/dashboard.tsx ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { ReactNode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import 'url-search-params-polyfill'
4
+
5
+ import { RouterProvider } from 'react-router-dom'
6
+ import { createAppRouter } from './dashboard/router'
7
+ import ErrorBoundary from './dashboard/error/error-boundary'
8
+ import * as api from './dashboard/api'
9
+ import * as timer from './dashboard/util/realtime-update-timer'
10
+ import { maybeDoFERedirect } from './dashboard/util/url-search-params'
11
+ import SiteContextProvider, {
12
+ parseSiteFromDataset
13
+ } from './dashboard/site-context'
14
+ import UserContextProvider, { Role } from './dashboard/user-context'
15
+ import ThemeContextProvider from './dashboard/theme-context'
16
+ import {
17
+ GoBackToDashboard,
18
+ GoToSites,
19
+ SomethingWentWrongMessage
20
+ } from './dashboard/error/something-went-wrong'
21
+ import {
22
+ getLimitedToSegment,
23
+ parseLimitedToSegmentId,
24
+ parsePreloadedSegments,
25
+ SegmentsContextProvider
26
+ } from './dashboard/filtering/segments-context'
27
+
28
+ timer.start()
29
+
30
+ const container = document.getElementById('stats-react-container')
31
+
32
+ if (container && container.dataset) {
33
+ let app: ReactNode
34
+
35
+ try {
36
+ const site = parseSiteFromDataset(container.dataset)
37
+
38
+ const sharedLinkAuth = container.dataset.sharedLinkAuth
39
+
40
+ if (sharedLinkAuth) {
41
+ api.setSharedLinkAuth(sharedLinkAuth)
42
+ }
43
+
44
+ const limitedToSegmentId = parseLimitedToSegmentId(container.dataset)
45
+ const preloadedSegments = parsePreloadedSegments(container.dataset)
46
+ const limitedToSegment = getLimitedToSegment(
47
+ limitedToSegmentId,
48
+ preloadedSegments
49
+ )
50
+
51
+ try {
52
+ maybeDoFERedirect(window.location, window.history, limitedToSegment)
53
+ } catch (e) {
54
+ console.error('Error redirecting in a backwards compatible way', e)
55
+ }
56
+
57
+ const router = createAppRouter(site)
58
+
59
+ app = (
60
+ <ErrorBoundary
61
+ renderFallbackComponent={({ error }) => (
62
+ <SomethingWentWrongMessage
63
+ error={error}
64
+ callToAction={<GoBackToDashboard site={site} />}
65
+ />
66
+ )}
67
+ >
68
+ <ThemeContextProvider>
69
+ <SiteContextProvider site={site}>
70
+ <UserContextProvider
71
+ user={
72
+ container.dataset.loggedIn === 'true'
73
+ ? {
74
+ loggedIn: true,
75
+ id: parseInt(container.dataset.currentUserId!, 10),
76
+ role: container.dataset.currentUserRole as Role,
77
+ team: {
78
+ identifier: container.dataset.teamIdentifier ?? null,
79
+ hasConsolidatedView:
80
+ container.dataset.consolidatedViewAvailable === 'true'
81
+ }
82
+ }
83
+ : {
84
+ loggedIn: false,
85
+ id: null,
86
+ role: container.dataset.currentUserRole as Role,
87
+ team: {
88
+ identifier: null,
89
+ hasConsolidatedView: false
90
+ }
91
+ }
92
+ }
93
+ >
94
+ <SegmentsContextProvider
95
+ limitedToSegment={limitedToSegment}
96
+ preloadedSegments={preloadedSegments}
97
+ >
98
+ <RouterProvider router={router} />
99
+ </SegmentsContextProvider>
100
+ </UserContextProvider>
101
+ </SiteContextProvider>
102
+ </ThemeContextProvider>
103
+ </ErrorBoundary>
104
+ )
105
+ } catch (err) {
106
+ console.error('Error loading dashboard', err)
107
+ app = <SomethingWentWrongMessage error={err} callToAction={<GoToSites />} />
108
+ }
109
+
110
+ const root = createRoot(container)
111
+ root.render(app)
112
+ }
assets/js/dashboard/util/filter-text.tsx ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { ReactNode, isValidElement, Fragment } from 'react'
2
+ import { DashboardState, Filter } from '../dashboard-state'
3
+ import {
4
+ EVENT_PROPS_PREFIX,
5
+ FILTER_OPERATIONS_DISPLAY_NAMES,
6
+ formattedFilters,
7
+ getLabel,
8
+ getPropertyKeyFromFilterKey
9
+ } from './filters'
10
+
11
+ export function styledFilterText(
12
+ dashboardState: Pick<DashboardState, 'labels'>,
13
+ [operation, filterKey, clauses]: Filter
14
+ ) {
15
+ if (filterKey.startsWith(EVENT_PROPS_PREFIX)) {
16
+ const propKey = getPropertyKeyFromFilterKey(filterKey)
17
+ return (
18
+ <>
19
+ Property <b>{propKey}</b> {FILTER_OPERATIONS_DISPLAY_NAMES[operation]}{' '}
20
+ {formatClauses(clauses)}
21
+ </>
22
+ )
23
+ }
24
+
25
+ const formattedFilter = (
26
+ formattedFilters as Record<string, string | undefined>
27
+ )[filterKey]
28
+ const clausesLabels = clauses.map((value) =>
29
+ getLabel(dashboardState.labels, filterKey, value)
30
+ )
31
+
32
+ if (!formattedFilter) {
33
+ throw new Error(`Unknown filter: ${filterKey}`)
34
+ }
35
+
36
+ return (
37
+ <>
38
+ {capitalize(formattedFilter)} {FILTER_OPERATIONS_DISPLAY_NAMES[operation]}{' '}
39
+ {formatClauses(clausesLabels)}
40
+ </>
41
+ )
42
+ }
43
+
44
+ export function plainFilterText(
45
+ dashboardState: Pick<DashboardState, 'labels'>,
46
+ filter: Filter
47
+ ) {
48
+ return reactNodeToString(styledFilterText(dashboardState, filter))
49
+ }
50
+
51
+ function formatClauses(labels: Array<string | number>): ReactNode[] {
52
+ return labels.map((label, index) => (
53
+ <Fragment key={index}>
54
+ {index > 0 && ' or '}
55
+ <b>{label}</b>
56
+ </Fragment>
57
+ ))
58
+ }
59
+
60
+ function capitalize(str: string): string {
61
+ return str[0].toUpperCase() + str.slice(1)
62
+ }
63
+
64
+ function reactNodeToString(reactNode: ReactNode): string {
65
+ let string = ''
66
+ if (typeof reactNode === 'string') {
67
+ string = reactNode
68
+ } else if (typeof reactNode === 'number') {
69
+ string = reactNode.toString()
70
+ } else if (reactNode instanceof Array) {
71
+ reactNode.forEach(function (child) {
72
+ string += reactNodeToString(child)
73
+ })
74
+ } else if (isValidElement(reactNode)) {
75
+ string += reactNodeToString(reactNode.props.children)
76
+ }
77
+ return string
78
+ }
assets/js/dashboard/util/filters.js ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { formatSegmentIdAsLabelKey } from '../filtering/segments'
2
+
3
+ export const FILTER_MODAL_TO_FILTER_GROUP = {
4
+ page: ['page', 'entry_page', 'exit_page'],
5
+ source: ['source', 'channel', 'referrer'],
6
+ location: ['country', 'region', 'city'],
7
+ screen: ['screen'],
8
+ browser: ['browser', 'browser_version'],
9
+ os: ['os', 'os_version'],
10
+ utm: ['utm_medium', 'utm_source', 'utm_campaign', 'utm_term', 'utm_content'],
11
+ goal: ['goal'],
12
+ props: ['props'],
13
+ hostname: ['hostname'],
14
+ segment: ['segment']
15
+ }
16
+
17
+ export function getAvailableFilterModals(site) {
18
+ const { props, ...rest } = FILTER_MODAL_TO_FILTER_GROUP
19
+ return {
20
+ ...rest,
21
+ ...(site.propsAvailable && { props })
22
+ }
23
+ }
24
+
25
+ export const FILTER_GROUP_TO_MODAL_TYPE = Object.fromEntries(
26
+ Object.entries(FILTER_MODAL_TO_FILTER_GROUP).flatMap(
27
+ ([modalName, filterGroups]) =>
28
+ filterGroups.map((filterGroup) => [filterGroup, modalName])
29
+ )
30
+ )
31
+
32
+ export const EVENT_PROPS_PREFIX = 'props:'
33
+
34
+ export const FILTER_OPERATIONS = {
35
+ is: 'is',
36
+ isNot: 'is_not',
37
+ contains: 'contains',
38
+ contains_not: 'contains_not',
39
+ has_not_done: 'has_not_done'
40
+ }
41
+
42
+ export const FILTER_OPERATIONS_DISPLAY_NAMES = {
43
+ [FILTER_OPERATIONS.is]: 'is',
44
+ [FILTER_OPERATIONS.isNot]: 'is not',
45
+ [FILTER_OPERATIONS.contains]: 'contains',
46
+ [FILTER_OPERATIONS.contains_not]: 'does not contain',
47
+ // :NOTE: Goal filters are displayed as "is not" in the UI, but in the backend they are wrapped with has_not_done.
48
+ // It is currently unclear if we'll do the same for other event filters in the future.
49
+ [FILTER_OPERATIONS.has_not_done]: 'is not'
50
+ }
51
+
52
+ export function supportsIsNot(filterName) {
53
+ return !['goal', 'prop_key'].includes(filterName)
54
+ }
55
+
56
+ export function supportsContains(filterName) {
57
+ return !['screen']
58
+ .concat(FILTER_MODAL_TO_FILTER_GROUP['location'])
59
+ .includes(filterName)
60
+ }
61
+
62
+ export function supportsHasDoneNot(filterName) {
63
+ return filterName === 'goal'
64
+ }
65
+
66
+ export function isFreeChoiceFilterOperation(operation) {
67
+ return [FILTER_OPERATIONS.contains, FILTER_OPERATIONS.contains_not].includes(
68
+ operation
69
+ )
70
+ }
71
+
72
+ export function getLabel(labels, filterKey, value) {
73
+ if (['country', 'region', 'city'].includes(filterKey)) {
74
+ return labels[value]
75
+ }
76
+
77
+ if (filterKey === 'segment') {
78
+ return labels[formatSegmentIdAsLabelKey(value)]
79
+ }
80
+
81
+ return value
82
+ }
83
+
84
+ export function getPropertyKeyFromFilterKey(filterKey) {
85
+ return filterKey.slice(EVENT_PROPS_PREFIX.length)
86
+ }
87
+
88
+ export function getFiltersByKeyPrefix(dashboardState, prefix) {
89
+ return dashboardState.filters.filter(hasDimensionPrefix(prefix))
90
+ }
91
+
92
+ const hasDimensionPrefix =
93
+ (prefix) =>
94
+ ([_operation, dimension, _clauses]) =>
95
+ dimension.startsWith(prefix)
96
+
97
+ export function omitFiltersByKeyPrefix(dashboardState, prefix) {
98
+ return dashboardState.filters.filter(
99
+ ([_operation, filterKey, _clauses]) => !filterKey.startsWith(prefix)
100
+ )
101
+ }
102
+
103
+ export function replaceFilterByPrefix(dashboardState, prefix, filter) {
104
+ return omitFiltersByKeyPrefix(dashboardState, prefix).concat([filter])
105
+ }
106
+
107
+ export function isFilteringOnFixedValue(
108
+ dashboardState,
109
+ filterKey,
110
+ expectedValue
111
+ ) {
112
+ const filters = dashboardState.filters.filter(
113
+ ([_operation, key]) => filterKey == key
114
+ )
115
+ if (filters.length == 1) {
116
+ const [operation, _filterKey, clauses] = filters[0]
117
+ return (
118
+ operation === FILTER_OPERATIONS.is &&
119
+ clauses.length === 1 &&
120
+ (!expectedValue || clauses[0] == expectedValue)
121
+ )
122
+ }
123
+ return false
124
+ }
125
+
126
+ export function hasPageFilter(dashboardState) {
127
+ return dashboardState.resolvedFilters.some(hasDimensionPrefix('page'))
128
+ }
129
+
130
+ export function hasConversionGoalFilter(dashboardState) {
131
+ const resolvedGoalFilters = dashboardState.resolvedFilters.filter(
132
+ hasDimensionPrefix('goal')
133
+ )
134
+
135
+ return resolvedGoalFilters.some(([operation, _filterKey, _clauses]) => {
136
+ return operation !== FILTER_OPERATIONS.has_not_done
137
+ })
138
+ }
139
+
140
+ export function isRealTimeDashboard(dashboardState) {
141
+ return dashboardState?.period === 'realtime'
142
+ }
143
+
144
+ // Note: Currently only a single goal filter can be applied at a time.
145
+ export function getGoalFilter(dashboardState) {
146
+ return getFiltersByKeyPrefix(dashboardState, 'goal')[0] || null
147
+ }
148
+
149
+ export function formatFilterGroup(filterGroup) {
150
+ if (filterGroup === 'utm') {
151
+ return 'UTM tags'
152
+ } else if (filterGroup === 'location') {
153
+ return 'Location'
154
+ } else if (filterGroup === 'props') {
155
+ return 'Property'
156
+ } else {
157
+ return formattedFilters[filterGroup]
158
+ }
159
+ }
160
+
161
+ export function cleanLabels(filters, labels, mergedFilterKey, mergedLabels) {
162
+ const filteredBy = Object.fromEntries(
163
+ filters
164
+ .flatMap(([_operation, filterKey, clauses]) => {
165
+ if (filterKey === 'segment') {
166
+ return clauses.map(formatSegmentIdAsLabelKey)
167
+ }
168
+ if (['country', 'region', 'city'].includes(filterKey)) {
169
+ return clauses
170
+ }
171
+ return []
172
+ })
173
+ .map((value) => [value, true])
174
+ )
175
+
176
+ let result = { ...labels }
177
+ for (const value in labels) {
178
+ if (!filteredBy[value]) {
179
+ delete result[value]
180
+ }
181
+ }
182
+
183
+ if (
184
+ mergedFilterKey &&
185
+ ['country', 'region', 'city', 'segment'].includes(mergedFilterKey)
186
+ ) {
187
+ result = {
188
+ ...result,
189
+ ...mergedLabels
190
+ }
191
+ }
192
+
193
+ return result
194
+ }
195
+
196
+ const NO_PREFIX_KEYS = new Set(['segment'])
197
+ const EVENT_FILTER_KEYS = new Set(['name', 'page', 'goal', 'hostname'])
198
+ const EVENT_PREFIX = 'event:'
199
+ const VISIT_PREFIX = 'visit:'
200
+
201
+ export function hasEventFilters(dashboardState) {
202
+ return dashboardState.resolvedFilters.some(
203
+ ([_operation, filterKey, _clauses]) => isEventFilterKey(filterKey)
204
+ )
205
+ }
206
+
207
+ function isEventFilterKey(filterKey) {
208
+ return (
209
+ EVENT_FILTER_KEYS.has(filterKey) || filterKey.startsWith(EVENT_PROPS_PREFIX)
210
+ )
211
+ }
212
+
213
+ function remapFilterKey(filterKey) {
214
+ if (NO_PREFIX_KEYS.has(filterKey)) {
215
+ return filterKey
216
+ }
217
+ if (isEventFilterKey(filterKey)) {
218
+ return `${EVENT_PREFIX}${filterKey}`
219
+ }
220
+ return `${VISIT_PREFIX}${filterKey}`
221
+ }
222
+
223
+ function remapApiFilterKey(apiFilterKey) {
224
+ const isNoPrefixKey = NO_PREFIX_KEYS.has(apiFilterKey)
225
+
226
+ if (isNoPrefixKey) {
227
+ return apiFilterKey
228
+ }
229
+
230
+ const isEventKey = apiFilterKey.startsWith(EVENT_PREFIX)
231
+ const isVisitKey = apiFilterKey.startsWith(VISIT_PREFIX)
232
+
233
+ if (isEventKey) {
234
+ return apiFilterKey.substring(EVENT_PREFIX.length)
235
+ }
236
+ if (isVisitKey) {
237
+ return apiFilterKey.substring(VISIT_PREFIX.length)
238
+ }
239
+
240
+ return apiFilterKey // maybe throw?
241
+ }
242
+
243
+ export function remapToApiFilters(filters) {
244
+ return filters.map(remapToApiFilter)
245
+ }
246
+
247
+ export function remapFromApiFilters(apiFilters) {
248
+ return apiFilters.map((apiFilter) => {
249
+ const [operation, ...rest] = apiFilter
250
+ if (operation === 'has_not_done') {
251
+ const [[_, apiFilterKey, clauses]] = rest
252
+ return [
253
+ FILTER_OPERATIONS.has_not_done,
254
+ remapApiFilterKey(apiFilterKey),
255
+ clauses
256
+ ]
257
+ }
258
+ const [apiFilterKey, clauses] = rest
259
+ return [operation, remapApiFilterKey(apiFilterKey), clauses]
260
+ })
261
+ }
262
+
263
+ export function serializeApiFilters(filters) {
264
+ return JSON.stringify(remapToApiFilters(filters))
265
+ }
266
+
267
+ function remapToApiFilter([operation, filterKey, clauses, ...modifiers]) {
268
+ const apiFilterKey = remapFilterKey(filterKey)
269
+ if (apiFilterKey === 'segment') {
270
+ return [operation, apiFilterKey, clauses.map((v) => parseInt(v, 10))]
271
+ }
272
+ if (operation === FILTER_OPERATIONS.has_not_done) {
273
+ // :NOTE: Frontend does not support advanced query building that's used in the backend.
274
+ // As such we emulate the backend behavior for has_not_done goal filters
275
+ return ['has_not_done', ['is', apiFilterKey, clauses, ...modifiers]]
276
+ } else {
277
+ return [operation, apiFilterKey, clauses, ...modifiers]
278
+ }
279
+ }
280
+
281
+ export function getFilterGroup([_operation, filterKey, _clauses]) {
282
+ return filterKey.startsWith(EVENT_PROPS_PREFIX) ? 'props' : filterKey
283
+ }
284
+
285
+ export const formattedFilters = {
286
+ goal: 'Goal',
287
+ props: 'Property',
288
+ prop_key: 'Property',
289
+ prop_value: 'Value',
290
+ source: 'Source',
291
+ channel: 'Channel',
292
+ utm_medium: 'UTM medium',
293
+ utm_source: 'UTM source',
294
+ utm_campaign: 'UTM campaign',
295
+ utm_content: 'UTM content',
296
+ utm_term: 'UTM term',
297
+ referrer: 'Referrer URL',
298
+ screen: 'Screen size',
299
+ browser: 'Browser',
300
+ browser_version: 'Browser version',
301
+ os: 'Operating system',
302
+ os_version: 'Operating system version',
303
+ country: 'Country',
304
+ region: 'Region',
305
+ city: 'City',
306
+ page: 'Page',
307
+ hostname: 'Hostname',
308
+ entry_page: 'Entry page',
309
+ exit_page: 'Exit page',
310
+ segment: 'Segment'
311
+ }
assets/js/dashboard/util/filters.test.ts ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getAvailableFilterModals, serializeApiFilters } from './filters'
2
+
3
+ describe(`${getAvailableFilterModals.name}`, () => {
4
+ it('gives limited object when props are not available', () => {
5
+ expect(
6
+ getAvailableFilterModals({
7
+ propsAvailable: false
8
+ })
9
+ ).toEqual({
10
+ browser: ['browser', 'browser_version'],
11
+ goal: ['goal'],
12
+ hostname: ['hostname'],
13
+ location: ['country', 'region', 'city'],
14
+ os: ['os', 'os_version'],
15
+ page: ['page', 'entry_page', 'exit_page'],
16
+ screen: ['screen'],
17
+ source: ['source', 'channel', 'referrer'],
18
+ utm: [
19
+ 'utm_medium',
20
+ 'utm_source',
21
+ 'utm_campaign',
22
+ 'utm_term',
23
+ 'utm_content'
24
+ ],
25
+ segment: ['segment']
26
+ })
27
+ })
28
+
29
+ it('gives full object when props and segments are available', () => {
30
+ expect(
31
+ getAvailableFilterModals({
32
+ propsAvailable: true
33
+ })
34
+ ).toEqual({
35
+ browser: ['browser', 'browser_version'],
36
+ goal: ['goal'],
37
+ hostname: ['hostname'],
38
+ location: ['country', 'region', 'city'],
39
+ os: ['os', 'os_version'],
40
+ page: ['page', 'entry_page', 'exit_page'],
41
+ screen: ['screen'],
42
+ source: ['source', 'channel', 'referrer'],
43
+ utm: [
44
+ 'utm_medium',
45
+ 'utm_source',
46
+ 'utm_campaign',
47
+ 'utm_term',
48
+ 'utm_content'
49
+ ],
50
+ props: ['props'],
51
+ segment: ['segment']
52
+ })
53
+ })
54
+ })
55
+
56
+ describe(`${serializeApiFilters.name}`, () => {
57
+ it('should prefix filter keys with event: or visit: when appropriate', () => {
58
+ const filters = [
59
+ ['is', 'page', ['/docs', '/blog']],
60
+ ['contains', 'goal', ['Signup']],
61
+ ['contains_not', 'browser', ['chrom'], { case_sensitive: false }],
62
+ ['is', 'country', ['US']],
63
+ ['is_not', 'utm_source', ['google']]
64
+ ]
65
+ expect(serializeApiFilters(filters)).toEqual(
66
+ JSON.stringify([
67
+ ['is', 'event:page', ['/docs', '/blog']],
68
+ ['contains', 'event:goal', ['Signup']],
69
+ ['contains_not', 'visit:browser', ['chrom'], { case_sensitive: false }],
70
+ ['is', 'visit:country', ['US']],
71
+ ['is_not', 'visit:utm_source', ['google']]
72
+ ])
73
+ )
74
+ })
75
+
76
+ it('wraps has_not_done goal filters in API format', () => {
77
+ const filters = [['has_not_done', 'goal', ['Signup']]]
78
+ expect(serializeApiFilters(filters)).toEqual(
79
+ JSON.stringify([['has_not_done', ['is', 'event:goal', ['Signup']]]])
80
+ )
81
+ })
82
+ })
assets/js/dashboard/util/goals.ts ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Filter } from '../dashboard-state'
2
+ import { FILTER_OPERATIONS } from './filters'
3
+
4
+ export const isPageViewGoal = (goalName: string) => {
5
+ goalName.startsWith('Visit ')
6
+ }
7
+
8
+ export const SPECIAL_GOALS = {
9
+ '404': { title: '404 Pages', prop: 'path' },
10
+ 'Outbound Link: Click': { title: 'Outbound Links', prop: 'url' },
11
+ 'Cloaked Link: Click': { title: 'Cloaked Links', prop: 'url' },
12
+ 'File Download': { title: 'File Downloads', prop: 'url' },
13
+ 'Form: Submission': { title: 'Form Actions', prop: 'path' },
14
+ 'WP Search Queries': {
15
+ title: 'WordPress Search Queries',
16
+ prop: 'search_query'
17
+ },
18
+ 'WP Form Completions': { title: 'WordPress Form Completions', prop: 'path' }
19
+ }
20
+
21
+ export function isSpecialGoal(
22
+ goalName: string | number
23
+ ): goalName is keyof typeof SPECIAL_GOALS {
24
+ return goalName in SPECIAL_GOALS
25
+ }
26
+
27
+ export function getSpecialGoal(goalFilter: Filter) {
28
+ const [operation, _filterKey, clauses] = goalFilter
29
+ if (operation === FILTER_OPERATIONS.is && clauses.length == 1) {
30
+ const goalName = clauses[0]
31
+ return isSpecialGoal(goalName) ? SPECIAL_GOALS[goalName] : null
32
+ }
33
+ return null
34
+ }
assets/js/dashboard/util/money.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { numberLongFormatter, numberShortFormatter } from './number-formatter'
2
+
3
+ type Money = { long: string; short: string }
4
+
5
+ export function formatMoneyShort(value: Money | number | null) {
6
+ if (typeof value == 'number') {
7
+ return numberShortFormatter(value)
8
+ } else if (value) {
9
+ return value.short
10
+ } else {
11
+ return '-'
12
+ }
13
+ }
14
+
15
+ export function formatMoneyLong(value: Money | number | null) {
16
+ if (typeof value == 'number') {
17
+ return numberLongFormatter(value)
18
+ } else if (value) {
19
+ return value.long
20
+ } else {
21
+ return '-'
22
+ }
23
+ }
assets/js/dashboard/util/number-formatter.test.ts ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { numberLongFormatter, numberShortFormatter } from './number-formatter'
2
+
3
+ describe('numberShortFormatter()', () => {
4
+ it('converts to short format', () => {
5
+ expect(numberShortFormatter(0)).toEqual('0')
6
+ expect(numberShortFormatter(-10)).toEqual('-10')
7
+ expect(numberShortFormatter(12)).toEqual('12')
8
+ expect(numberShortFormatter(123)).toEqual('123')
9
+ expect(numberShortFormatter(1234)).toEqual('1.2k')
10
+ expect(numberShortFormatter(12345)).toEqual('12.3k')
11
+ expect(numberShortFormatter(123456)).toEqual('123k')
12
+ expect(numberShortFormatter(1234567)).toEqual('1.2M')
13
+ expect(numberShortFormatter(12345678)).toEqual('12.3M')
14
+ expect(numberShortFormatter(123456789)).toEqual('123M')
15
+ expect(numberShortFormatter(1234567890)).toEqual('1.2B')
16
+ })
17
+ })
18
+
19
+ describe('numberLongFormatter()', () => {
20
+ it('converts to short format', () => {
21
+ expect(numberLongFormatter(0)).toEqual('0')
22
+ expect(numberLongFormatter(-10)).toEqual('-10')
23
+ expect(numberLongFormatter(12)).toEqual('12')
24
+ expect(numberLongFormatter(123)).toEqual('123')
25
+ expect(numberLongFormatter(1234)).toEqual('1,234')
26
+ expect(numberLongFormatter(12345)).toEqual('12,345')
27
+ expect(numberLongFormatter(123456)).toEqual('123,456')
28
+ expect(numberLongFormatter(1234567)).toEqual('1,234,567')
29
+ expect(numberLongFormatter(12345678)).toEqual('12,345,678')
30
+ expect(numberLongFormatter(123456789)).toEqual('123,456,789')
31
+ expect(numberLongFormatter(1234567890)).toEqual('1,234,567,890')
32
+ })
33
+ })
assets/js/dashboard/util/number-formatter.ts ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const THOUSAND = 1000
2
+ const HUNDRED_THOUSAND = 100000
3
+ const MILLION = 1000000
4
+ const HUNDRED_MILLION = 100000000
5
+ const BILLION = 1000000000
6
+ const HUNDRED_BILLION = 100000000000
7
+ const TRILLION = 1000000000000
8
+
9
+ const numberFormat = Intl.NumberFormat('en-US')
10
+
11
+ export function numberShortFormatter(num: number): string {
12
+ if (num >= THOUSAND && num < MILLION) {
13
+ const thousands = num / THOUSAND
14
+ if (thousands === Math.floor(thousands) || num >= HUNDRED_THOUSAND) {
15
+ return Math.floor(thousands) + 'k'
16
+ } else {
17
+ return Math.floor(thousands * 10) / 10 + 'k'
18
+ }
19
+ } else if (num >= MILLION && num < BILLION) {
20
+ const millions = num / MILLION
21
+ if (millions === Math.floor(millions) || num >= HUNDRED_MILLION) {
22
+ return Math.floor(millions) + 'M'
23
+ } else {
24
+ return Math.floor(millions * 10) / 10 + 'M'
25
+ }
26
+ } else if (num >= BILLION && num < TRILLION) {
27
+ const billions = num / BILLION
28
+ if (billions === Math.floor(billions) || num >= HUNDRED_BILLION) {
29
+ return Math.floor(billions) + 'B'
30
+ } else {
31
+ return Math.floor(billions * 10) / 10 + 'B'
32
+ }
33
+ } else {
34
+ return num.toString()
35
+ }
36
+ }
37
+
38
+ export function numberLongFormatter(num: number): string {
39
+ return numberFormat.format(num)
40
+ }
41
+
42
+ export function nullable<T>(
43
+ formatter: (num: T) => string
44
+ ): (num: T | null) => string {
45
+ return (num: T | null): string => {
46
+ if (num === null) {
47
+ return '-'
48
+ }
49
+ return formatter(num)
50
+ }
51
+ }
52
+
53
+ function pad(num: number, size: number): string {
54
+ return ('000' + num).slice(size * -1)
55
+ }
56
+
57
+ export function durationFormatter(duration: number): string {
58
+ const hours = Math.floor(duration / 60 / 60)
59
+ const minutes = Math.floor(duration / 60) % 60
60
+ const seconds = Math.floor(duration - minutes * 60 - hours * 60 * 60)
61
+ if (hours > 0) {
62
+ return `${hours}h ${minutes}m ${seconds}s`
63
+ } else if (minutes > 0) {
64
+ return `${minutes}m ${pad(seconds, 2)}s`
65
+ } else {
66
+ return `${seconds}s`
67
+ }
68
+ }
69
+
70
+ export function roundedNumberFormatter(number: number): string {
71
+ if (Math.abs(number) > 0 && Math.abs(number) < 0.1) {
72
+ return number.toFixed(2)
73
+ } else {
74
+ return number.toFixed(1).replace(/\.0$/, '')
75
+ }
76
+ }
77
+
78
+ export function percentageFormatter(number: number | null): string {
79
+ if (typeof number === 'number') {
80
+ return roundedNumberFormatter(number) + '%'
81
+ } else {
82
+ return '-'
83
+ }
84
+ }
assets/js/dashboard/util/realtime-update-timer.js ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ export const REALTIME_UPDATE_TIME_MS = 30_000
2
+ const tickEvent = new Event('tick')
3
+
4
+ export function start() {
5
+ setInterval(() => {
6
+ document.dispatchEvent(tickEvent)
7
+ }, REALTIME_UPDATE_TIME_MS)
8
+ }
assets/js/dashboard/util/seconds-since-last-load.js ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react'
2
+
3
+ // A function component that renders an integer value of how many
4
+ // seconds have passed from the last data load on the dashboard.
5
+ // Updates the value every second when the component is visible.
6
+ export function SecondsSinceLastLoad({ lastLoadTimestamp }) {
7
+ const [timeNow, setTimeNow] = useState(new Date())
8
+
9
+ useEffect(() => {
10
+ const interval = setInterval(() => setTimeNow(new Date()), 1000)
11
+ return () => clearInterval(interval)
12
+ }, [])
13
+
14
+ return Math.round(Math.abs(lastLoadTimestamp - timeNow) / 1000)
15
+ }
assets/js/dashboard/util/storage.js ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // This module checks if localStorage is available and uses it for persistent frontend storage
2
+ // if possible. Localstorage can be blocked by browsers when people block third-party cookies and
3
+ // the dashboard is running in embedded mode. In those cases, store stuff in a regular object instead.
4
+
5
+ const memStore = {}
6
+
7
+ // https://stackoverflow.com/a/16427747
8
+ function testLocalStorageAvailability() {
9
+ try {
10
+ const testItem = 'test'
11
+ localStorage.setItem(testItem, testItem)
12
+ localStorage.removeItem(testItem)
13
+ return true
14
+ } catch (_e) {
15
+ return false
16
+ }
17
+ }
18
+
19
+ const isLocalStorageAvailable = testLocalStorageAvailability()
20
+
21
+ export function setItem(key, value) {
22
+ if (isLocalStorageAvailable) {
23
+ window.localStorage.setItem(key, value)
24
+ } else {
25
+ memStore[key] = value
26
+ }
27
+ }
28
+
29
+ export function getItem(key) {
30
+ if (isLocalStorageAvailable) {
31
+ return window.localStorage.getItem(key)
32
+ } else {
33
+ return memStore[key]
34
+ }
35
+ }
36
+
37
+ export const getDomainScopedStorageKey = (key, domain) => `${key}__${domain}`
assets/js/dashboard/util/tooltip.tsx ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { CSSProperties, ReactNode, RefObject, useState } from 'react'
2
+ import { usePopper } from 'react-popper'
3
+ import classNames from 'classnames'
4
+ import { createPortal } from 'react-dom'
5
+
6
+ export function Tooltip({
7
+ children,
8
+ info,
9
+ className,
10
+ onClick,
11
+ boundary,
12
+ containerRef
13
+ }: {
14
+ info: ReactNode
15
+ children: ReactNode
16
+ className?: string
17
+ onClick?: () => void
18
+ /** if provided, the tooltip is confined to the particular element */
19
+ boundary?: HTMLElement | null
20
+ /** if defined, the tooltip is rendered in a portal to this element */
21
+ containerRef?: RefObject<HTMLElement>
22
+ }) {
23
+ const [visible, setVisible] = useState(false)
24
+ const [referenceElement, setReferenceElement] =
25
+ useState<HTMLDivElement | null>(null)
26
+ const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
27
+ null
28
+ )
29
+
30
+ const { styles, attributes } = usePopper(referenceElement, popperElement, {
31
+ placement: 'top',
32
+ modifiers: [
33
+ {
34
+ name: 'offset',
35
+ options: {
36
+ offset: [0, 6]
37
+ }
38
+ },
39
+ ...(boundary
40
+ ? [
41
+ {
42
+ name: 'preventOverflow',
43
+ options: {
44
+ boundary: boundary
45
+ }
46
+ }
47
+ ]
48
+ : [])
49
+ ]
50
+ })
51
+
52
+ return (
53
+ <div className={classNames('relative', className)}>
54
+ <div
55
+ ref={setReferenceElement}
56
+ onMouseEnter={() => setVisible(true)}
57
+ onMouseLeave={() => setVisible(false)}
58
+ onClick={onClick}
59
+ >
60
+ {children}
61
+ </div>
62
+ {info && visible && (
63
+ <TooltipMessage
64
+ containerRef={containerRef}
65
+ popperStyle={styles.popper}
66
+ popperAttributes={attributes.popper}
67
+ setPopperElement={setPopperElement}
68
+ >
69
+ {info}
70
+ </TooltipMessage>
71
+ )}
72
+ </div>
73
+ )
74
+ }
75
+
76
+ function TooltipMessage({
77
+ containerRef,
78
+ popperStyle,
79
+ popperAttributes,
80
+ setPopperElement,
81
+ children
82
+ }: {
83
+ containerRef?: RefObject<HTMLElement>
84
+ popperStyle: CSSProperties
85
+ popperAttributes?: Record<string, string>
86
+ setPopperElement: (element: HTMLDivElement) => void
87
+ children: ReactNode
88
+ }) {
89
+ const messageElement = (
90
+ <div
91
+ ref={setPopperElement}
92
+ style={popperStyle}
93
+ {...popperAttributes}
94
+ className="pointer-events-none z-[99] [body:has(.modal.is-open)_&]:z-[1000] px-2 py-1 rounded-sm text-sm text-gray-100 font-medium bg-gray-800 dark:bg-gray-700"
95
+ role="tooltip"
96
+ >
97
+ {children}
98
+ </div>
99
+ )
100
+ if (containerRef) {
101
+ if (containerRef.current) {
102
+ return createPortal(messageElement, containerRef.current)
103
+ } else {
104
+ return null
105
+ }
106
+ }
107
+
108
+ return messageElement
109
+ }
assets/js/dashboard/util/url-search-params-v1.ts ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { DashboardState, Filter } from '../dashboard-state'
2
+ import { EVENT_PROPS_PREFIX, FILTER_OPERATIONS } from './filters'
3
+
4
+ // As of March 2023, Safari does not support negative lookbehind regexes. In case it throws an error, falls back to plain | matching. This means
5
+ // escaping pipe characters in filters does not currently work in Safari
6
+ let NON_ESCAPED_PIPE_REGEX: string | RegExp
7
+ try {
8
+ NON_ESCAPED_PIPE_REGEX = new RegExp('(?<!\\\\)\\|', 'g')
9
+ } catch (_e) {
10
+ NON_ESCAPED_PIPE_REGEX = '|'
11
+ }
12
+
13
+ const ESCAPED_PIPE = '\\|'
14
+ const OPERATION_PREFIX = {
15
+ [FILTER_OPERATIONS.isNot]: '!',
16
+ [FILTER_OPERATIONS.contains]: '~',
17
+ [FILTER_OPERATIONS.is]: ''
18
+ }
19
+
20
+ const LEGACY_URL_PARAMETERS = {
21
+ goal: null,
22
+ source: null,
23
+ utm_medium: null,
24
+ utm_source: null,
25
+ utm_campaign: null,
26
+ utm_content: null,
27
+ utm_term: null,
28
+ referrer: null,
29
+ screen: null,
30
+ browser: null,
31
+ browser_version: null,
32
+ os: null,
33
+ os_version: null,
34
+ country: 'country_labels',
35
+ region: 'region_labels',
36
+ city: 'city_labels',
37
+ page: null,
38
+ hostname: null,
39
+ entry_page: null,
40
+ exit_page: null
41
+ }
42
+
43
+ function isV1(searchParams: URLSearchParams): boolean {
44
+ for (const k of searchParams.keys()) {
45
+ if (k === 'props' || LEGACY_URL_PARAMETERS.hasOwnProperty(k)) {
46
+ return true
47
+ }
48
+ }
49
+ return false
50
+ }
51
+
52
+ function parseSearch(searchString: string): Record<string, unknown> {
53
+ const searchParams = new URLSearchParams(searchString)
54
+ const updatedSearchRecordEntries = []
55
+ const filters: Filter[] = []
56
+ let labels: DashboardState['labels'] = {}
57
+
58
+ for (const [key, value] of searchParams.entries()) {
59
+ if (LEGACY_URL_PARAMETERS.hasOwnProperty(key)) {
60
+ if (typeof value !== 'string') {
61
+ continue
62
+ }
63
+ const filter = parseLegacyFilter(key, value) as Filter
64
+ filters.push(filter)
65
+ const labelsKey: string | null | undefined =
66
+ LEGACY_URL_PARAMETERS[key as keyof typeof LEGACY_URL_PARAMETERS]
67
+ const labelsParamValue = labelsKey ? searchParams.get(labelsKey) : null
68
+ if (labelsParamValue) {
69
+ const clauses = filter[2]
70
+ const labelsValues = labelsParamValue
71
+ .split('|')
72
+ .filter((label) => !!label)
73
+ const newLabels = Object.fromEntries(
74
+ clauses.map((clause, index) => [clause, labelsValues[index]])
75
+ )
76
+
77
+ labels = Object.assign(labels, newLabels)
78
+ }
79
+ } else {
80
+ updatedSearchRecordEntries.push([key, value])
81
+ }
82
+ }
83
+
84
+ const propsParamValue = searchParams.get('props')
85
+ if (typeof propsParamValue === 'string') {
86
+ filters.push(...(parseLegacyPropsFilter(propsParamValue) as Filter[]))
87
+ }
88
+ updatedSearchRecordEntries.push(['filters', filters], ['labels', labels])
89
+ return Object.fromEntries(updatedSearchRecordEntries)
90
+ }
91
+
92
+ function parseLegacyFilter(filterKey: string, rawValue: string): null | Filter {
93
+ const operation =
94
+ Object.keys(OPERATION_PREFIX).find(
95
+ (operation) => OPERATION_PREFIX[operation] === rawValue[0]
96
+ ) || FILTER_OPERATIONS.is
97
+
98
+ const value =
99
+ operation === FILTER_OPERATIONS.is ? rawValue : rawValue.substring(1)
100
+
101
+ const clauses = value
102
+ .split(NON_ESCAPED_PIPE_REGEX)
103
+ .filter((clause) => !!clause)
104
+ // @ts-expect-error API supposedly not present in compilation target, but works anyway
105
+ .map((val) => val.replaceAll(ESCAPED_PIPE, '|'))
106
+
107
+ return [operation, filterKey, clauses]
108
+ }
109
+
110
+ function parseLegacyPropsFilter(rawValue: string) {
111
+ return Object.entries(JSON.parse(rawValue)).flatMap(([key, propVal]) =>
112
+ typeof propVal === 'string'
113
+ ? [parseLegacyFilter(`${EVENT_PROPS_PREFIX}${key}`, propVal)]
114
+ : []
115
+ )
116
+ }
117
+
118
+ export const v1 = {
119
+ isV1,
120
+ parseSearch
121
+ }
assets/js/dashboard/util/url-search-params-v2.test.ts ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import JsonURL from '@jsonurl/jsonurl'
2
+ import { v2 } from './url-search-params-v2'
3
+
4
+ const {
5
+ stringifySearchEntry,
6
+ stringifySearch,
7
+ parseSearch,
8
+ parseSearchFragment
9
+ } = v2
10
+
11
+ describe('using json URL parsing with URLSearchParams intermediate', () => {
12
+ beforeEach(() => {
13
+ // Silence logs in tests
14
+ jest.spyOn(console, 'error').mockImplementation(jest.fn())
15
+ })
16
+ it.each([['#'], ['&'], ['=']])('throws on special symbol %p', (s) => {
17
+ const searchString = `?param=${encodeURIComponent(s)}`
18
+ expect(() =>
19
+ JsonURL.parse(new URLSearchParams(searchString).get('param')!)
20
+ ).toThrow()
21
+ })
22
+ })
23
+
24
+ describe(`${stringifySearchEntry.name}`, () => {
25
+ it.each<[[string, unknown], [string, string | undefined]]>([
26
+ [
27
+ ['any-key', {}],
28
+ ['any-key', undefined]
29
+ ],
30
+ [
31
+ ['any-key', []],
32
+ ['any-key', undefined]
33
+ ],
34
+ [
35
+ ['any-key', null],
36
+ ['any-key', undefined]
37
+ ],
38
+ [
39
+ ['period', 'realtime'],
40
+ ['period', 'realtime']
41
+ ],
42
+ [
43
+ ['page', 10],
44
+ ['page', '10']
45
+ ],
46
+ [
47
+ ['labels', { US: 'United States', 3448439: 'São Paulo' }],
48
+ ['labels', '(3448439:S%C3%A3o+Paulo,US:United+States)']
49
+ ],
50
+ [
51
+ ['filters', [['is', 'props:foo:bar', ['one', 'two']]]],
52
+ ['filters', "((is,'props:foo:bar',(one,two)))"]
53
+ ]
54
+ ])('when input is %p, returns %p', (input, expected) => {
55
+ const result = stringifySearchEntry(input)
56
+ expect(result).toEqual(expected)
57
+ })
58
+ })
59
+
60
+ describe(`${parseSearchFragment.name}`, () => {
61
+ it.each([
62
+ ['', null],
63
+ ['("foo":)', null],
64
+ ['(invalid', null],
65
+ ['null', null],
66
+
67
+ ['123', 123],
68
+ ['string', 'string'],
69
+ ['item=#', 'item=#'],
70
+ ['item%3D%23', 'item=#'],
71
+
72
+ ['(any:(number:1))', { any: { number: 1 } }],
73
+ ['(any:(number:1.001))', { any: { number: 1.001 } }],
74
+ ["(any:(string:'1.001'))", { any: { string: '1.001' } }],
75
+
76
+ // Non-JSON strings that should return as string
77
+ ['undefined', 'undefined'],
78
+ ['not_json', 'not_json'],
79
+ ['plainstring', 'plainstring'],
80
+ ['a|b', 'a|b'],
81
+ ['foo bar#', 'foo bar#']
82
+ ])(
83
+ 'when searchStringFragment is %p, returns %p',
84
+ (searchStringFragment, expected) => {
85
+ const result = parseSearchFragment(searchStringFragment)
86
+ expect(result).toEqual(expected)
87
+ }
88
+ )
89
+ })
90
+
91
+ describe(`${parseSearch.name}`, () => {
92
+ it.each([
93
+ ['', {}],
94
+ ['?', {}],
95
+ [
96
+ '?arr=(1,2)',
97
+ {
98
+ arr: [1, 2]
99
+ }
100
+ ],
101
+ ['?key1=value1&key2=', { key1: 'value1', key2: null }],
102
+ ['?key1=value1&key2=value2', { key1: 'value1', key2: 'value2' }],
103
+ [
104
+ '?key1=(foo:bar)&filters=((is,screen,(Mobile,Desktop)))',
105
+ {
106
+ key1: { foo: 'bar' },
107
+ filters: [['is', 'screen', ['Mobile', 'Desktop']]]
108
+ }
109
+ ],
110
+ [
111
+ '?filters=((is,country,(US)))&labels=(US:United%2BStates)',
112
+ {
113
+ filters: [['is', 'country', ['US']]],
114
+ labels: {
115
+ US: 'United States'
116
+ }
117
+ }
118
+ ]
119
+ ])('when searchString is %p, returns %p', (searchString, expected) => {
120
+ const result = parseSearch(searchString)
121
+ expect(result).toEqual(expected)
122
+ })
123
+ })
124
+
125
+ describe(`${stringifySearch.name} and ${parseSearch.name} are inverses of each other`, () => {
126
+ it.each([
127
+ ["?filters=((is,'props:browser_language',(en-US)))"],
128
+ [
129
+ '?filters=((contains,utm_term,(_)),(is,screen,(Desktop,Tablet)),(is,page,(/open-source-website-analytics)))&period=custom&keybindHint=A&comparison=previous_period&match_day_of_week=false&from=2024-08-08&to=2024-08-10'
130
+ ],
131
+ [
132
+ "?filters=((is,'props:browser_language',(en-US)),(is,country,(US)),(is,os,(iOS)),(is,os_version,('17.3')),(is,page,('/:dashboard/settings/general')))&labels=(US:United%2BStates)"
133
+ ],
134
+ [
135
+ '?filters=((is,utm_source,(hackernewsletter)),(is,utm_campaign,(profile)))&period=day&keybindHint=D'
136
+ ]
137
+ ])(
138
+ `input %p is returned for ${parseSearch.name}(${parseSearch.name}(input))`,
139
+ (searchString) => {
140
+ const searchRecord = parseSearch(searchString)
141
+ const reStringifiedSearch = stringifySearch(searchRecord)
142
+ expect(reStringifiedSearch).toEqual(searchString)
143
+ }
144
+ )
145
+
146
+ it.each([
147
+ // Corresponding test cases for objects parsed from realistic URLs
148
+
149
+ [
150
+ {
151
+ filters: [['is', 'props:browser_language', ['en-US']]]
152
+ },
153
+ "?filters=((is,'props:browser_language',(en-US)))"
154
+ ],
155
+ [
156
+ {
157
+ filters: [
158
+ ['contains', 'utm_term', ['_']],
159
+ ['is', 'screen', ['Desktop', 'Tablet']],
160
+ ['is', 'page', ['/open-source/analytics/encoded-hash%23']]
161
+ ],
162
+ period: 'custom',
163
+ keybindHint: 'A',
164
+ comparison: 'previous_period',
165
+ match_day_of_week: false,
166
+ from: '2024-08-08',
167
+ to: '2024-08-10'
168
+ },
169
+ '?filters=((contains,utm_term,(_)),(is,screen,(Desktop,Tablet)),(is,page,(%252Fopen-source%252Fanalytics%252Fencoded-hash%252523)))&period=custom&keybindHint=A&comparison=previous_period&match_day_of_week=false&from=2024-08-08&to=2024-08-10'
170
+ ],
171
+ [
172
+ {
173
+ filters: [
174
+ ['is', 'props:browser_language', ['en-US']],
175
+ ['is', 'country', ['US']],
176
+ ['is', 'os', ['iOS']],
177
+ ['is', 'os_version', ['17.3']],
178
+ ['is', 'page', ['/:dashboard/settings/general']]
179
+ ],
180
+ labels: { US: 'United States' }
181
+ },
182
+ "?filters=((is,'props:browser_language',(en-US)),(is,country,(US)),(is,os,(iOS)),(is,os_version,('17.3')),(is,page,('/:dashboard/settings/general')))&labels=(US:United%2BStates)"
183
+ ],
184
+ [
185
+ {
186
+ filters: [
187
+ ['is', 'utm_source', ['hackernewsletter']],
188
+ ['is', 'utm_campaign', ['profile']]
189
+ ],
190
+ period: 'day',
191
+ keybindHint: 'D'
192
+ },
193
+ '?filters=((is,utm_source,(hackernewsletter)),(is,utm_campaign,(profile)))&period=day&keybindHint=D'
194
+ ]
195
+ ])(
196
+ `for input %p, ${stringifySearch.name}(input) returns %p and ${parseSearch.name}(${stringifySearch.name}(input)) returns the original input`,
197
+ (searchRecord, expected) => {
198
+ const searchString = stringifySearch(searchRecord)
199
+ const parsedSearchRecord = parseSearch(searchString)
200
+ expect(parsedSearchRecord).toEqual(searchRecord)
201
+ expect(searchString).toEqual(expected)
202
+ }
203
+ )
204
+ })
assets/js/dashboard/util/url-search-params-v2.ts ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import JsonURL from '@jsonurl/jsonurl'
2
+ import {
3
+ encodeURIComponentPermissive,
4
+ isSearchEntryDefined
5
+ } from './url-search-params'
6
+
7
+ const permittedCharactersInURLParamKeyValue = ',:/'
8
+
9
+ function isV2(urlSearchParams: URLSearchParams): boolean {
10
+ return !!urlSearchParams.get('filters')
11
+ }
12
+
13
+ function encodeSearchParamEntry([k, v]: [string, string]): string {
14
+ return [k, v]
15
+ .map((s) =>
16
+ encodeURIComponentPermissive(s, permittedCharactersInURLParamKeyValue)
17
+ )
18
+ .join('=')
19
+ }
20
+
21
+ function stringifySearch(searchRecord: Record<string, unknown>): '' | string {
22
+ const definedSearchEntries = Object.entries(searchRecord || {})
23
+ .map(stringifySearchEntry)
24
+ .filter(isSearchEntryDefined)
25
+
26
+ const encodedSearchEntries = definedSearchEntries.map(encodeSearchParamEntry)
27
+
28
+ return encodedSearchEntries.length ? `?${encodedSearchEntries.join('&')}` : ''
29
+ }
30
+
31
+ function stringifySearchEntry([key, value]: [string, unknown]): [
32
+ string,
33
+ undefined | string
34
+ ] {
35
+ const isEmptyObjectOrArray =
36
+ typeof value === 'object' &&
37
+ value !== null &&
38
+ Object.entries(value).length === 0
39
+ if (value === undefined || value === null || isEmptyObjectOrArray) {
40
+ return [key, undefined]
41
+ }
42
+
43
+ return [key, JsonURL.stringify(value)]
44
+ }
45
+
46
+ function parseSearchFragment(searchStringFragment: string): null | unknown {
47
+ if (searchStringFragment === '') {
48
+ return null
49
+ }
50
+ // tricky: the search string fragment is already decoded due to URLSearchParams intermediate (see tests),
51
+ // and these symbols are unparseable
52
+ const fragmentWithReEncodedSymbols = searchStringFragment
53
+ /* @ts-expect-error API supposedly not present in compilation target */
54
+ .replaceAll('=', encodeURIComponent('='))
55
+ .replaceAll('#', encodeURIComponent('#'))
56
+ .replaceAll('|', encodeURIComponent('|'))
57
+ .replaceAll(' ', encodeURIComponent(' '))
58
+
59
+ try {
60
+ return JsonURL.parse(fragmentWithReEncodedSymbols)
61
+ } catch (error) {
62
+ console.error(
63
+ `Failed to parse URL fragment ${fragmentWithReEncodedSymbols}`,
64
+ error
65
+ )
66
+ return null
67
+ }
68
+ }
69
+
70
+ function parseSearch(searchString: string): Record<string, unknown> {
71
+ const urlSearchParams = new URLSearchParams(searchString)
72
+ const searchRecord: Record<string, unknown> = {}
73
+ urlSearchParams.forEach((v, k) => (searchRecord[k] = parseSearchFragment(v)))
74
+ return searchRecord
75
+ }
76
+
77
+ export const v2 = {
78
+ isV2,
79
+ parseSearch,
80
+ parseSearchFragment,
81
+ stringifySearch,
82
+ stringifySearchEntry
83
+ }
assets/js/dashboard/util/url-search-params.test.ts ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Filter } from '../dashboard-state'
2
+ import {
3
+ encodeURIComponentPermissive,
4
+ getSearchWithEnforcedSegment,
5
+ isSearchEntryDefined,
6
+ maybeGetLatestReadableSearch,
7
+ maybeReloadForApiVersion,
8
+ parseFilter,
9
+ parseLabelsEntry,
10
+ parseSearch,
11
+ parseSimpleSearchEntry,
12
+ serializeFilter,
13
+ serializeLabelsEntry,
14
+ serializeSimpleSearchEntry,
15
+ stringifySearch
16
+ } from './url-search-params'
17
+
18
+ describe(`${encodeURIComponentPermissive.name}`, () => {
19
+ it.each<[string, string]>([
20
+ ['10.00.00/1', '10.00.00/1'],
21
+ ['#hashtag', '%23hashtag'],
22
+ ['100$ coupon', '100%24%20coupon'],
23
+ ['Visit /any/page', 'Visit%20/any/page'],
24
+ ['A,B,C', 'A,B,C'],
25
+ ['props:colon/forward/slash/signs', 'props:colon/forward/slash/signs'],
26
+ ['https://example.com/path', 'https://example.com/path']
27
+ ])(
28
+ 'when input is %p, returns %s and decodes back to input',
29
+ (input, expected) => {
30
+ const result = encodeURIComponentPermissive(input, ',:/')
31
+ expect(result).toBe(expected)
32
+ expect(decodeURIComponent(result)).toBe(input)
33
+ }
34
+ )
35
+ })
36
+
37
+ describe(`${isSearchEntryDefined.name}`, () => {
38
+ it.each<[[string, string | undefined], boolean]>([
39
+ [['key', undefined], false],
40
+ [['key', 'value'], true],
41
+ [['key', ''], true],
42
+ [['anotherKey', 'undefined'], true]
43
+ ])('when entry is %p, returns %s', (entry, expected) => {
44
+ const result = isSearchEntryDefined(entry)
45
+ expect(result).toBe(expected)
46
+ })
47
+ })
48
+
49
+ describe(`${serializeLabelsEntry.name} and ${parseLabelsEntry.name}(...) are opposite of each other`, () => {
50
+ test.each<[[string, string], string]>([
51
+ [['US', 'United States'], 'US,United%20States'],
52
+ [['FR-IDF', 'Île-de-France'], 'FR-IDF,%C3%8Ele-de-France'],
53
+ [['1254661', 'Thāne'], '1254661,Th%C4%81ne']
54
+ ])(
55
+ 'entry %p serializes to %p, parses back to original',
56
+ (entry, expected) => {
57
+ const serialized = serializeLabelsEntry(entry)
58
+ expect(serialized).toEqual(expected)
59
+ expect(parseLabelsEntry(serialized)).toEqual(entry)
60
+ }
61
+ )
62
+ })
63
+
64
+ describe(`${serializeFilter.name} and ${parseFilter.name}(...) are opposite of each other`, () => {
65
+ test.each<[Filter, string]>([
66
+ [
67
+ ['contains', 'entry_page', ['/forecast/:city', ',"\'']],
68
+ "contains,entry_page,/forecast/:city,%2C%22'"
69
+ ],
70
+ [
71
+ ['is', 'props:complex/prop-with-comma-etc,$#%', ['(none)']],
72
+ 'is,props:complex/prop-with-comma-etc%2C%24%23%25,(none)'
73
+ ]
74
+ ])(
75
+ 'filter %p serializes to %p, parses back to original',
76
+ (filter, expected) => {
77
+ const serialized = serializeFilter(filter)
78
+ expect(serialized).toEqual(expected)
79
+ expect(parseFilter(serialized)).toEqual(filter)
80
+ }
81
+ )
82
+ })
83
+
84
+ describe(`${serializeSimpleSearchEntry.name} and ${parseSimpleSearchEntry.name}`, () => {
85
+ test.each<
86
+ [
87
+ [string, unknown],
88
+ [string, string | boolean | undefined],
89
+ [string, string | boolean] | null
90
+ ]
91
+ >([
92
+ [['undefined-param', undefined], ['undefined-param', undefined], null],
93
+ [['null-param', null], ['null-param', undefined], null],
94
+ [['array-param', ['any-value']], ['array-param', undefined], null],
95
+ [['obj-param', { 'any-key': 'any-value' }], ['obj-param', undefined], null],
96
+ [
97
+ ['date-obj', new Date('2024-01-01T10:00:00.000Z')],
98
+ ['date-obj', undefined],
99
+ null
100
+ ],
101
+ [
102
+ ['page-nr', 5],
103
+ ['page-nr', '5'],
104
+ ['page-nr', '5']
105
+ ],
106
+ [
107
+ ['string-param-resembling-boolean', 'true'],
108
+ ['string-param-resembling-boolean', 'true'],
109
+ ['string-param-resembling-boolean', true]
110
+ ],
111
+ [
112
+ ['match-day-of-week', false],
113
+ ['match-day-of-week', 'false'],
114
+ ['match-day-of-week', false]
115
+ ],
116
+ [
117
+ ['with-imported-data', true],
118
+ ['with-imported-data', 'true'],
119
+ ['with-imported-data', true]
120
+ ],
121
+ [
122
+ ['date-string', '2024-12-10'],
123
+ ['date-string', '2024-12-10'],
124
+ ['date-string', '2024-12-10']
125
+ ]
126
+ ])(
127
+ 'entry %p serializes to %p, parses to %p',
128
+ (entry, expectedSerialized, expectedParsedEntry) => {
129
+ const serialized = serializeSimpleSearchEntry(entry)
130
+ expect(serialized).toEqual(expectedSerialized)
131
+ expect(
132
+ serialized[1] === undefined
133
+ ? null
134
+ : parseSimpleSearchEntry(serialized[1])
135
+ ).toEqual(expectedParsedEntry === null ? null : expectedParsedEntry[1])
136
+ }
137
+ )
138
+ })
139
+
140
+ describe(`${parseSearch.name}`, () => {
141
+ it.each([
142
+ ['?', {}, ''],
143
+ ['?=&&', {}, ''],
144
+ ['?=undefined', {}, ''],
145
+ ['?foo=', { foo: '' }, '?foo='],
146
+ ['??foo', { '?foo': '' }, '?%3Ffoo='],
147
+ [
148
+ '?f=is,visit:page,/any/page&f',
149
+ { filters: [['is', 'visit:page', ['/any/page']]] },
150
+ '?f=is,visit:page,/any/page'
151
+ ]
152
+ ])(
153
+ 'for search string %s, returns search record %p, which in turn stringifies to %s',
154
+ (searchString, expectedSearchRecord, expectedRestringifiedResult) => {
155
+ expect(parseSearch(searchString)).toEqual(expectedSearchRecord)
156
+ expect(stringifySearch(expectedSearchRecord)).toEqual(
157
+ expectedRestringifiedResult
158
+ )
159
+ }
160
+ )
161
+ })
162
+
163
+ describe(`${stringifySearch.name}`, () => {
164
+ it.each([
165
+ [{}, ''],
166
+ [
167
+ {
168
+ filters: [['is', 'props:browser_language', ['en-US']]]
169
+ },
170
+ '?f=is,props:browser_language,en-US'
171
+ ],
172
+ [
173
+ {
174
+ filters: [
175
+ ['contains', 'utm_term', ['_']],
176
+ ['is', 'screen', ['Desktop', 'Tablet']],
177
+ [
178
+ 'is',
179
+ 'page',
180
+ ['/open-source/analytics/encoded-hash%23', '/unencoded-hash#']
181
+ ]
182
+ ],
183
+ period: 'custom',
184
+ keybindHint: 'A',
185
+ comparison: 'previous_period',
186
+ match_day_of_week: false,
187
+ from: '2024-08-08',
188
+ to: '2024-08-10'
189
+ },
190
+ '?f=contains,utm_term,_&f=is,screen,Desktop,Tablet&f=is,page,/open-source/analytics/encoded-hash%2523,/unencoded-hash%23&period=custom&keybindHint=A&comparison=previous_period&match_day_of_week=false&from=2024-08-08&to=2024-08-10'
191
+ ],
192
+ [
193
+ {
194
+ filters: [
195
+ ['is', 'props:browser_language', ['en-US']],
196
+ ['is', 'country', ['US']],
197
+ ['is', 'os', ['iOS']],
198
+ ['is', 'os_version', ['17.3', '16.0']],
199
+ ['is', 'page', ['/:dashboard/settings/general']]
200
+ ],
201
+ labels: { US: 'United States' }
202
+ },
203
+ '?f=is,props:browser_language,en-US&f=is,country,US&f=is,os,iOS&f=is,os_version,17.3,16.0&f=is,page,/:dashboard/settings/general&l=US,United%20States'
204
+ ]
205
+ ])('works as expected', (searchRecord, expectedSearchString) => {
206
+ expect(stringifySearch(searchRecord)).toEqual(expectedSearchString)
207
+ expect(parseSearch(expectedSearchString)).toEqual(searchRecord)
208
+ })
209
+ })
210
+
211
+ describe(`${maybeGetLatestReadableSearch.name}`, () => {
212
+ it.each([
213
+ [''],
214
+ ['?auth=_Y6YOjUl2beUJF_XzG1hk&theme=light&background=%23ee00ee'],
215
+ ['?keybindHint=Escape&with_imported=true'],
216
+ ['?f=is,page,/blog/:category/:article-name&date=2024-10-10&period=day'],
217
+ ['?f=is,country,US&l=US,United%20States']
218
+ ])('for modern search string %p returns null', (search) => {
219
+ expect(maybeGetLatestReadableSearch(search)).toBeNull()
220
+ })
221
+
222
+ it('returns updated search string for jsonurl style filters (v2), and running the updated value through the function again returns null (no redirect loop)', () => {
223
+ const search =
224
+ '?filters=((is,exit_page,(/plausible.io)),(is,source,(Brave)),(is,city,(993800)))&labels=(993800:Johannesburg)'
225
+ const expectedUpdatedSearch =
226
+ '?f=is,exit_page,/plausible.io&f=is,source,Brave&f=is,city,993800&l=993800,Johannesburg&r=v2'
227
+ expect(maybeGetLatestReadableSearch(search)).toEqual(expectedUpdatedSearch)
228
+ expect(maybeGetLatestReadableSearch(expectedUpdatedSearch)).toBeNull()
229
+ })
230
+
231
+ it.each([
232
+ ['?page=/docs', '?f=is,page,/docs&r=v1'],
233
+ ['?page=%C3%AA&embed=true', '?f=is,page,%C3%AA&embed=true&r=v1'],
234
+ [
235
+ '?page=/|/foo&goal=~Signup&source=!Facebook|Instagram',
236
+ '?f=is,page,/,/foo&f=contains,goal,Signup&f=is_not,source,Facebook,Instagram&r=v1'
237
+ ]
238
+ ])(
239
+ 'returns updated search string v1 style filter %s, and running the updated value through the function again returns null (no redirect loop)',
240
+ (searchString, expectedSearchString) => {
241
+ expect(maybeGetLatestReadableSearch(searchString)).toEqual(
242
+ expectedSearchString
243
+ )
244
+ expect(maybeGetLatestReadableSearch(expectedSearchString)).toBeNull()
245
+ }
246
+ )
247
+ })
248
+
249
+ describe(`${getSearchWithEnforcedSegment.name}`, () => {
250
+ it('adds enforced segment appropriately, and running the updated value through the function again returns the same value', () => {
251
+ const segment = { id: 100, name: 'Eastern Europe' }
252
+ const search = '?auth=foo&embed=true'
253
+ const expectedUpdatedSearch =
254
+ '?f=is,segment,100&l=segment-100,Eastern%20Europe&auth=foo&embed=true'
255
+ expect(getSearchWithEnforcedSegment(search, segment)).toEqual(
256
+ expectedUpdatedSearch
257
+ )
258
+ expect(
259
+ getSearchWithEnforcedSegment(expectedUpdatedSearch, segment)
260
+ ).toEqual(expectedUpdatedSearch)
261
+ })
262
+ })
263
+
264
+ describe(`${maybeReloadForApiVersion.name}`, () => {
265
+ const dashboardPathname = '/example.com'
266
+
267
+ beforeEach(() => {
268
+ jest.spyOn(console, 'warn').mockImplementation(() => {})
269
+ })
270
+
271
+ afterEach(() => {
272
+ jest.restoreAllMocks()
273
+ })
274
+
275
+ type MockWindowLocation = Location & { replace: jest.Mock }
276
+
277
+ function makeLocation(search: string): MockWindowLocation {
278
+ return {
279
+ pathname: dashboardPathname,
280
+ search,
281
+ hash: '',
282
+ replace: jest.fn()
283
+ } as unknown as MockWindowLocation
284
+ }
285
+
286
+ function makeHeaders(version: string | null): Headers {
287
+ const headers = new Headers()
288
+ if (version !== null) headers.set('x-api-version', version)
289
+ return headers
290
+ }
291
+
292
+ it('reloads when effective API version is greater than expected', () => {
293
+ const location = makeLocation('')
294
+ maybeReloadForApiVersion(location, makeHeaders('1'))
295
+ expect(location.replace).toHaveBeenCalledWith(
296
+ `${dashboardPathname}?api_version_reloaded=1`
297
+ )
298
+ })
299
+
300
+ it('does not reload when effective API version equals expected', () => {
301
+ const location = makeLocation('')
302
+ maybeReloadForApiVersion(location, makeHeaders('0'))
303
+ expect(location.replace).not.toHaveBeenCalled()
304
+ })
305
+
306
+ it('does not reload when effective API version is less than expected (FE loaded from newer node, cluster not fully updated)', () => {
307
+ const location = makeLocation('')
308
+ maybeReloadForApiVersion(location, makeHeaders('-1'))
309
+ expect(location.replace).not.toHaveBeenCalled()
310
+ })
311
+
312
+ it('does not reload when x-api-version header is absent', () => {
313
+ const location = makeLocation('')
314
+ maybeReloadForApiVersion(location, makeHeaders(null))
315
+ expect(location.replace).not.toHaveBeenCalled()
316
+ })
317
+
318
+ it('does not reload when already reloaded for this version', () => {
319
+ const location = makeLocation('?api_version_reloaded=1')
320
+ maybeReloadForApiVersion(location, makeHeaders('1'))
321
+ expect(location.replace).not.toHaveBeenCalled()
322
+ })
323
+
324
+ it('reloads again if a newer version is detected after a previous reload', () => {
325
+ const location = makeLocation('?api_version_reloaded=1')
326
+ maybeReloadForApiVersion(location, makeHeaders('2'))
327
+ expect(location.replace).toHaveBeenCalledWith(
328
+ `${dashboardPathname}?api_version_reloaded=2`
329
+ )
330
+ })
331
+ })
assets/js/dashboard/util/url-search-params.ts ADDED
@@ -0,0 +1,376 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ getSearchToSetSegmentFilter,
3
+ SavedSegment
4
+ } from '../filtering/segments'
5
+ import { Filter, FilterClauseLabels } from '../dashboard-state'
6
+ import { v1 } from './url-search-params-v1'
7
+ import { v2 } from './url-search-params-v2'
8
+
9
+ /**
10
+ * These characters are not URL encoded to have more readable URLs.
11
+ * Browsers seem to handle this just fine.
12
+ * `?f=is,page,/my/page/:some_param` vs `?f=is,page,%2Fmy%2Fpage%2F%3Asome_param``
13
+ */
14
+ const NOT_URL_ENCODED_CHARACTERS = ':/'
15
+
16
+ export const FILTER_URL_PARAM_NAME = 'f'
17
+
18
+ const LABEL_URL_PARAM_NAME = 'l'
19
+
20
+ const REDIRECTED_SEARCH_PARAM_NAME = 'r'
21
+
22
+ const API_VERSION_RELOAD_PARAM_NAME = 'api_version_reloaded'
23
+
24
+ const EXPECTED_API_VERSION = parseInt(
25
+ document
26
+ .querySelector('meta[name="x-api-version"]')
27
+ ?.getAttribute('content') ?? '0',
28
+ 10
29
+ )
30
+
31
+ /**
32
+ * Navigates to the current URL with `api_version_reloaded=<currentApiVersion>`
33
+ * appended, using `location.replace` so the pre-reload entry is not kept in
34
+ * browser history.
35
+ *
36
+ * Returns early without navigating if:
37
+ *
38
+ * - the x-plausible-version response header is not present
39
+ * - the expected version matches the actual version
40
+ * - the version is already present in search params
41
+ *
42
+ * The latter prevents an infinite reload loop when the versions are
43
+ * permanently out of sync.
44
+ *
45
+ * BE: lib/plausible_web/plugs/internal_stats_api_version.ex
46
+ */
47
+ export function maybeReloadForApiVersion(
48
+ windowLocation: Location,
49
+ responseHeaders: Headers
50
+ ) {
51
+ const currentApiVersion = getCurrentApiVersion(responseHeaders)
52
+ const params = new URLSearchParams(windowLocation.search)
53
+
54
+ if (
55
+ currentApiVersion === null ||
56
+ currentApiVersion <= EXPECTED_API_VERSION ||
57
+ params.get(API_VERSION_RELOAD_PARAM_NAME) === currentApiVersion.toString()
58
+ ) {
59
+ return
60
+ }
61
+
62
+ console.warn('API version mismatch detected, reloading...')
63
+
64
+ const newSearch = searchWithApiVersionReload(
65
+ windowLocation.search,
66
+ currentApiVersion.toString()
67
+ )
68
+ windowLocation.replace(
69
+ `${windowLocation.pathname}${newSearch}${windowLocation.hash}`
70
+ )
71
+ }
72
+
73
+ function getCurrentApiVersion(responseHeaders: Headers): number | null {
74
+ const versionString = responseHeaders?.get('x-api-version')
75
+ return versionString ? parseInt(versionString, 10) : null
76
+ }
77
+
78
+ function searchWithApiVersionReload(search: string, value: string): string {
79
+ return stringifySearch({
80
+ ...parseSearch(search),
81
+ [API_VERSION_RELOAD_PARAM_NAME]: value
82
+ })
83
+ }
84
+
85
+ /**
86
+ * This function is able to serialize for URL simple params @see serializeSimpleSearchEntry as well
87
+ * two complex params, labels and filters.
88
+ */
89
+ export function stringifySearch(
90
+ searchRecord: Record<string, null | undefined | number | string | unknown>
91
+ ): '' | string {
92
+ const { filters, labels, ...rest } = searchRecord ?? {}
93
+ const definedSearchEntries = Object.entries(rest)
94
+ .map(serializeSimpleSearchEntry)
95
+ .filter(isSearchEntryDefined)
96
+ .map(([k, v]) => `${k}=${v}`)
97
+
98
+ if (!Array.isArray(filters) || !filters.length) {
99
+ return definedSearchEntries.length
100
+ ? `?${definedSearchEntries.join('&')}`
101
+ : ''
102
+ }
103
+
104
+ const serializedFilters = Array.isArray(filters)
105
+ ? filters.map((f) => `${FILTER_URL_PARAM_NAME}=${serializeFilter(f)}`)
106
+ : []
107
+
108
+ const serializedLabels = Object.entries(labels ?? {}).map(
109
+ (entry) => `${LABEL_URL_PARAM_NAME}=${serializeLabelsEntry(entry)}`
110
+ )
111
+
112
+ return `?${serializedFilters.concat(serializedLabels).concat(definedSearchEntries).join('&')}`
113
+ }
114
+
115
+ export function normalizeSearchString(searchString: string): string {
116
+ return searchString.startsWith('?') ? searchString.slice(1) : searchString
117
+ }
118
+
119
+ export function parseSearch(searchString: string): Record<string, unknown> {
120
+ const searchRecord: Record<string, string | boolean> = {}
121
+ const filters: Filter[] = []
122
+ const labels: FilterClauseLabels = {}
123
+
124
+ const normalizedSearchString = normalizeSearchString(searchString)
125
+
126
+ if (!normalizedSearchString.length) {
127
+ return searchRecord
128
+ }
129
+
130
+ const meaningfulParams = normalizedSearchString
131
+ .split('&')
132
+ .filter((i) => i.length > 0)
133
+
134
+ for (const param of meaningfulParams) {
135
+ const [key, rawValue = ''] = param.split('=')
136
+ switch (key) {
137
+ case FILTER_URL_PARAM_NAME: {
138
+ const filter = parseFilter(rawValue)
139
+ if (filter.length === 3 && filter[2].length) {
140
+ filters.push(filter)
141
+ }
142
+ break
143
+ }
144
+ case LABEL_URL_PARAM_NAME: {
145
+ const [labelKey, labelValue] = parseLabelsEntry(rawValue)
146
+ if (labelKey.length && labelValue.length) {
147
+ labels[labelKey] = labelValue
148
+ }
149
+ break
150
+ }
151
+ case '': {
152
+ break
153
+ }
154
+ default: {
155
+ const parsedValue = parseSimpleSearchEntry(rawValue)
156
+ if (parsedValue !== null) {
157
+ searchRecord[decodeURIComponent(key)] = parsedValue
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ return {
164
+ ...searchRecord,
165
+ ...(filters.length && { filters }),
166
+ ...(Object.keys(labels).length && { labels })
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Serializes and flattens @see FilterClauseLabels entries.
172
+ * Examples:
173
+ * ["US","United States"] -> "US,United%20States"
174
+ * ["US-CA","California"] -> "US-CA,California"
175
+ * ["5391959","San Francisco"] -> "5391959,San%20Francisco"
176
+ */
177
+ export function serializeLabelsEntry([labelKey, labelValue]: [string, string]) {
178
+ return `${encodeURIComponentPermissive(labelKey, NOT_URL_ENCODED_CHARACTERS)},${encodeURIComponentPermissive(labelValue, NOT_URL_ENCODED_CHARACTERS)}`
179
+ }
180
+
181
+ /**
182
+ * Parses the output of @see serializeLabelsEntry back to labels object entry.
183
+ */
184
+ export function parseLabelsEntry(
185
+ labelKeyValueString: string
186
+ ): [string, string] {
187
+ const [key, value] = labelKeyValueString.split(',')
188
+ return [decodeURIComponent(key), decodeURIComponent(value)]
189
+ }
190
+
191
+ /**
192
+ * Serializes and flattens filters array item.
193
+ * Examples:
194
+ * ["is", "entry_page", ["/blog", "/news"]] -> "is,entry_page,/blog,/news"
195
+ */
196
+ export function serializeFilter([operator, dimension, clauses]: Filter) {
197
+ const serializedFilter = [
198
+ encodeURIComponentPermissive(operator, NOT_URL_ENCODED_CHARACTERS),
199
+ encodeURIComponentPermissive(dimension, NOT_URL_ENCODED_CHARACTERS),
200
+ ...clauses.map((clause) =>
201
+ encodeURIComponentPermissive(
202
+ clause.toString(),
203
+ NOT_URL_ENCODED_CHARACTERS
204
+ )
205
+ )
206
+ ].join(',')
207
+ return serializedFilter
208
+ }
209
+
210
+ /**
211
+ * Parses the output of @see serializeFilter back to filters array item.
212
+ */
213
+ export function parseFilter(filterString: string): Filter {
214
+ const [operator, dimension, ...unparsedClauses] = filterString.split(',')
215
+ return [
216
+ decodeURIComponent(operator),
217
+ decodeURIComponent(dimension),
218
+ unparsedClauses.map(decodeURIComponent)
219
+ ]
220
+ }
221
+
222
+ /**
223
+ * Encodes for URL simple search param values.
224
+ * Encodes numbers and number-like strings as indistinguishable strings. Parse treats them as strings.
225
+ * Encodes booleans and strings "true" and "false" as indistinguishable strings. Parse treats these as booleans.
226
+ * Unifies unhandleable complex search entries like undefined, null, objects and arrays as undefined.
227
+ * Complex URL params must be handled separately.
228
+ */
229
+ export function serializeSimpleSearchEntry([key, value]: [string, unknown]): [
230
+ string,
231
+ undefined | string
232
+ ] {
233
+ if (value === undefined || value === null || typeof value === 'object') {
234
+ return [key, undefined]
235
+ }
236
+ return [
237
+ encodeURIComponentPermissive(key, ',:/'),
238
+ encodeURIComponentPermissive(value.toString(), ',:/')
239
+ ]
240
+ }
241
+
242
+ /**
243
+ * Parses output of @see serializeSimpleSearchEntry.
244
+ */
245
+ export function parseSimpleSearchEntry(
246
+ searchParamValue: string
247
+ ): null | string | boolean {
248
+ if (searchParamValue === 'true') {
249
+ return true
250
+ }
251
+ if (searchParamValue === 'false') {
252
+ return false
253
+ }
254
+ return decodeURIComponent(searchParamValue)
255
+ }
256
+
257
+ export function encodeURIComponentPermissive(
258
+ input: string,
259
+ permittedCharacters: string
260
+ ): string {
261
+ return Array.from(permittedCharacters)
262
+ .map((character) => [encodeURIComponent(character), character])
263
+ .reduce(
264
+ (acc, [encodedCharacter, character]) =>
265
+ /* @ts-expect-error API supposedly not present in compilation target, but works in major browsers */
266
+ acc.replaceAll(encodedCharacter, character),
267
+ encodeURIComponent(input)
268
+ )
269
+ }
270
+
271
+ export function isSearchEntryDefined(
272
+ entry: [string, undefined | string]
273
+ ): entry is [string, string] {
274
+ return entry[1] !== undefined
275
+ }
276
+
277
+ function isAlreadyRedirected(searchParams: URLSearchParams) {
278
+ return ['v1', 'v2'].includes(searchParams.get(REDIRECTED_SEARCH_PARAM_NAME)!)
279
+ }
280
+
281
+ /**
282
+ Dashboard state is kept on the URL for people to be able to link to what that they see.
283
+ Because dashboard state is a complex object, in the interest of readable URLs, custom serialization and parsing is in place.
284
+
285
+ Versions
286
+ * v1: @see v1
287
+ A custom encoding schema was used for filters, (e.g. "?page=/blog").
288
+ This was not flexible enough and diverged from how we represented filters in the code.
289
+
290
+ * v2: @see v2
291
+ jsonurl library was used to serialize the state.
292
+ The links from this solution didn't always auto-sense across all platforms (e.g. Twitter), cutting off too soon and leading users to broken dashboards.
293
+
294
+ * current version: this module.
295
+ Custom encoding.
296
+
297
+ The purpose of this function is to redirect users from one of the previous versions to the current version,
298
+ so previous dashboard links still work.
299
+ */
300
+ export function maybeGetLatestReadableSearch(
301
+ searchString: string
302
+ ): null | string {
303
+ const searchParams = new URLSearchParams(searchString)
304
+ if (isAlreadyRedirected(searchParams)) {
305
+ return null
306
+ }
307
+ const isCurrentVersion = searchParams.get(FILTER_URL_PARAM_NAME)
308
+ if (isCurrentVersion) {
309
+ return null
310
+ }
311
+
312
+ const isV2 = v2.isV2(searchParams)
313
+ const isV1 = v1.isV1(searchParams)
314
+
315
+ if (isV2) {
316
+ return stringifySearch({
317
+ ...v2.parseSearch(searchString),
318
+ [REDIRECTED_SEARCH_PARAM_NAME]: 'v2'
319
+ })
320
+ }
321
+
322
+ if (isV1) {
323
+ return stringifySearch({
324
+ ...v1.parseSearch(searchString),
325
+ [REDIRECTED_SEARCH_PARAM_NAME]: 'v1'
326
+ })
327
+ }
328
+
329
+ return null
330
+ }
331
+
332
+ /**
333
+ * It's possible to set a particular segment to be always applied on the data on dashboards accessed with a shared link.
334
+ * This function ensures that the particular segment filter is set to the URL string on initial page load.
335
+ * Other functions ensure that it can't be removed.
336
+ */
337
+ export function getSearchWithEnforcedSegment(
338
+ searchString: string,
339
+ enforcedSegment: Pick<SavedSegment, 'id' | 'name'>
340
+ ): string {
341
+ const searchRecord = parseSearch(searchString)
342
+ return stringifySearch(
343
+ getSearchToSetSegmentFilter(enforcedSegment)(searchRecord)
344
+ )
345
+ }
346
+
347
+ /** Called once before React app mounts. If legacy url search params are present, does a redirect to new format. */
348
+ export function maybeDoFERedirect(
349
+ windowLocation: Location,
350
+ windowHistory: History,
351
+ enforcedSegment: Pick<SavedSegment, 'id' | 'name'> | null
352
+ ) {
353
+ const originalSearchString = windowLocation.search
354
+
355
+ let updatedSearchString = maybeGetLatestReadableSearch(originalSearchString)
356
+
357
+ if (enforcedSegment) {
358
+ updatedSearchString = getSearchWithEnforcedSegment(
359
+ updatedSearchString ?? originalSearchString,
360
+ enforcedSegment
361
+ )
362
+ }
363
+
364
+ if (
365
+ updatedSearchString === null ||
366
+ updatedSearchString === originalSearchString
367
+ ) {
368
+ return
369
+ }
370
+
371
+ windowHistory.pushState(
372
+ {},
373
+ '',
374
+ `${windowLocation.pathname}${updatedSearchString}`
375
+ )
376
+ }
assets/js/dashboard/util/url.test.ts ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { apiPath, externalLinkForPage, isValidHttpUrl, trimURL } from './url'
2
+ import { siteContextDefaultValue } from '../site-context'
3
+
4
+ describe('apiPath', () => {
5
+ it.each([
6
+ ['example.com', undefined, '/api/stats/example.com/'],
7
+ ['example.com', '', '/api/stats/example.com/'],
8
+ ['example.com', '/test', '/api/stats/example.com/test/'],
9
+ [
10
+ 'example.com/path/is-really/deep',
11
+ '',
12
+ '/api/stats/example.com%2Fpath%2Fis-really%2Fdeep/'
13
+ ]
14
+ ])(
15
+ 'when site.domain is %p and path is %s, should return %s',
16
+ (domain, path, expected) => {
17
+ const result = apiPath({ domain }, path)
18
+ expect(result).toBe(expected)
19
+ }
20
+ )
21
+ })
22
+
23
+ describe('externalLinkForPage', () => {
24
+ it.each([
25
+ ['example.com', '/about', 'https://example.com/about'],
26
+ ['sub.example.com', '/contact', 'https://sub.example.com/contact'],
27
+ [
28
+ 'example.com',
29
+ '/search?q=test#section',
30
+ 'https://example.com/search?q=test#section'
31
+ ],
32
+ ['example.com', '/', 'https://example.com/']
33
+ ])(
34
+ 'when domain is %s and page is %s, it should return %s',
35
+ (domain, page, expected) => {
36
+ const site = { ...siteContextDefaultValue, domain: domain }
37
+ const result = externalLinkForPage(site, page)
38
+ expect(result).toBe(expected)
39
+ }
40
+ )
41
+
42
+ it('returns null for consolidated view', () => {
43
+ const consolidatedView = {
44
+ ...siteContextDefaultValue,
45
+ isConsolidatedView: true
46
+ }
47
+ expect(externalLinkForPage(consolidatedView, '/some-page')).toBe(null)
48
+ })
49
+ })
50
+
51
+ describe('isValidHttpUrl', () => {
52
+ it.each([
53
+ // Valid HTTP and HTTPS URLs
54
+ ['http://example.com', true],
55
+ ['https://example.com', true],
56
+ ['http://www.example.com', true],
57
+ ['https://sub.domain.com', true],
58
+ ['https://example.com/path?query=1#fragment', true],
59
+
60
+ // Invalid URLs (invalid protocol)
61
+ ['ftp://example.com', false],
62
+ ['mailto:someone@example.com', false],
63
+ ['file:///C:/path/to/file', false],
64
+ ['data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==', false],
65
+
66
+ // Invalid URLs (malformed or non-URL strings)
67
+ ['//example.com', false],
68
+ ['example.com', false],
69
+ ['just-a-string', false],
70
+ ['', false],
71
+ ['https//:example.com', false],
72
+
73
+ // Edge cases
74
+ ['http:/example.com', true],
75
+ ['http://localhost', true],
76
+ ['https://127.0.0.1', true],
77
+ ['https://[::1]', true], // IPv6 URL
78
+ ['http://user:pass@127.0.0.1', true],
79
+ ['https://example.com:8080', true]
80
+ ])('for input %s returns %s', (input, expected) => {
81
+ const result = isValidHttpUrl(input)
82
+ expect(result).toBe(expected)
83
+ })
84
+ })
85
+
86
+ describe('trimURL', () => {
87
+ it.each([
88
+ // Test cases where URL length is less than or equal to maxLength
89
+ ['https://example.com', 20, 'https://example.com'],
90
+ ['http://example.com', 50, 'http://example.com'],
91
+
92
+ // Test cases where host itself is too long
93
+ [
94
+ 'https://a-very-long-domain-name.com',
95
+ 20,
96
+ 'https://a-very-long-dom...domain-name.com'
97
+ ]
98
+ ])(
99
+ 'when url is %s and maxLength is %d, should return %s',
100
+ (url, maxLength, expected) => {
101
+ const result = trimURL(url, maxLength)
102
+ expect(result).toBe(expected)
103
+ }
104
+ )
105
+ })
assets/js/dashboard/util/url.ts ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { PlausibleSite } from '../site-context'
2
+
3
+ export function apiPath(
4
+ site: Pick<PlausibleSite, 'domain'>,
5
+ path = ''
6
+ ): string {
7
+ return `/api/stats/${encodeURIComponent(site.domain)}${path}/`
8
+ }
9
+
10
+ export function externalLinkForPage(
11
+ site: PlausibleSite,
12
+ page: string,
13
+ hostname?: string
14
+ ): string | null {
15
+ if (hostname) {
16
+ return `https://${hostname}${page}`
17
+ }
18
+ if (site.isConsolidatedView) {
19
+ return null
20
+ }
21
+ try {
22
+ const domainURL = new URL(`https://${site.domain}`)
23
+ return `https://${domainURL.host}${page}`
24
+ } catch (_error) {
25
+ return null
26
+ }
27
+ }
28
+
29
+ export function isValidHttpUrl(input: string): boolean {
30
+ let url
31
+
32
+ try {
33
+ url = new URL(input)
34
+ } catch (_) {
35
+ return false
36
+ }
37
+
38
+ return url.protocol === 'http:' || url.protocol === 'https:'
39
+ }
40
+
41
+ export function trimURL(url: string, maxLength: number): string {
42
+ if (url.length <= maxLength) {
43
+ return url
44
+ }
45
+
46
+ const ellipsis = '...'
47
+
48
+ if (isValidHttpUrl(url)) {
49
+ const [protocol, restURL] = url.split('://')
50
+ const parts = restURL.split('/')
51
+
52
+ const host = parts.shift() || ''
53
+ if (host.length > maxLength - 5) {
54
+ return `${protocol}://${host.substr(0, maxLength - 5)}${ellipsis}${restURL.slice(-maxLength + 5)}`
55
+ }
56
+
57
+ let remainingLength = maxLength - host.length - 5
58
+ let trimmedURL = `${protocol}://${host}`
59
+
60
+ for (const part of parts) {
61
+ if (part.length <= remainingLength) {
62
+ trimmedURL += '/' + part
63
+ remainingLength -= part.length + 1
64
+ } else {
65
+ const startTrim = Math.floor((remainingLength - 3) / 2)
66
+ const endTrim = Math.ceil((remainingLength - 3) / 2)
67
+ trimmedURL += `/${part.substr(0, startTrim)}...${part.slice(-endTrim)}`
68
+ break
69
+ }
70
+ }
71
+
72
+ return trimmedURL
73
+ } else {
74
+ const leftSideLength = Math.floor(maxLength / 2)
75
+ const rightSideLength = maxLength - leftSideLength
76
+
77
+ const leftSide = url.slice(0, leftSideLength)
78
+ const rightSide = url.slice(-rightSideLength)
79
+
80
+ return leftSide + ellipsis + rightSide
81
+ }
82
+ }
83
+
84
+ export function maybeEncodeRouteParam(param: string) {
85
+ return param.includes('/') ? encodeURIComponent(param) : param
86
+ }
assets/js/embed.content.js ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import 'iframe-resizer/js/iframeResizer.contentWindow'
2
+
3
+ window.iFrameResizer = {
4
+ onMessage: function (msg) {
5
+ if (msg.type === 'load-custom-styles') {
6
+ addCustomStyles(msg.opts)
7
+ }
8
+ }
9
+ }
10
+
11
+ function addCustomStyles(opts) {
12
+ var style = document.createElement('style')
13
+ style.innerHTML = opts.styles
14
+
15
+ document.head.appendChild(style)
16
+ }
assets/js/embed.host.js ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import iframeResize from 'iframe-resizer/js/iframeResizer'
2
+
3
+ var iframes = iframeResize(
4
+ {
5
+ heightCalculationMethod: 'taggedElement',
6
+ onInit: onInit,
7
+ checkOrigin: false
8
+ },
9
+ '[plausible-embed]'
10
+ )
11
+
12
+ function onInit() {
13
+ var iframe = iframes[0]
14
+ var styles = iframe.getAttribute('styles')
15
+
16
+ if (styles) {
17
+ iframe.iFrameResizer.sendMessage({
18
+ type: 'load-custom-styles',
19
+ opts: {
20
+ styles: styles
21
+ }
22
+ })
23
+ }
24
+ }
assets/js/liveview/combo-box.js ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Courtesy of Benjamin von Polheim:
2
+ // https://blog.devgenius.io/build-a-performat-autocomplete-using-phoenix-liveview-and-alpine-js-8bcbbed17ba7
3
+
4
+ export default (id) => ({
5
+ isOpen: false,
6
+ id: id,
7
+ focus: null,
8
+ selectionInProgress: false,
9
+ firstFocusRegistered: false,
10
+ setFocus(f) {
11
+ this.focus = f
12
+ },
13
+ initFocus() {
14
+ if (this.focus === null) {
15
+ this.setFocus(this.leastFocusableIndex())
16
+ if (!this.firstFocusRegistered) {
17
+ document.getElementById(this.id).select()
18
+ this.firstFocusRegistered = true
19
+ }
20
+ }
21
+ },
22
+ trackSubmitValueChange() {
23
+ this.selectionInProgress = false
24
+ },
25
+ open() {
26
+ if (!this.isOpen) {
27
+ this.initFocus()
28
+ this.isOpen = true
29
+ }
30
+ },
31
+ suggestionsCount() {
32
+ return this.$refs.suggestions?.querySelectorAll('li').length
33
+ },
34
+ hasCreatableOption() {
35
+ return this.$refs.suggestions
36
+ ?.querySelector('li')
37
+ .classList.contains('creatable')
38
+ },
39
+ leastFocusableIndex() {
40
+ if (this.suggestionsCount() === 0) {
41
+ return 0
42
+ }
43
+ return this.hasCreatableOption() ? 0 : 1
44
+ },
45
+ maxFocusableIndex() {
46
+ return this.hasCreatableOption()
47
+ ? this.suggestionsCount() - 1
48
+ : this.suggestionsCount()
49
+ },
50
+ nextFocusableIndex() {
51
+ const currentFocus = this.focus
52
+ return currentFocus + 1 > this.maxFocusableIndex()
53
+ ? this.leastFocusableIndex()
54
+ : currentFocus + 1
55
+ },
56
+ prevFocusableIndex() {
57
+ const currentFocus = this.focus
58
+ return currentFocus - 1 >= this.leastFocusableIndex()
59
+ ? currentFocus - 1
60
+ : this.maxFocusableIndex()
61
+ },
62
+ close(e) {
63
+ // Pressing Escape should not propagate to window,
64
+ // so we'll only close the suggestions pop-up
65
+ if (this.isOpen && e.key === 'Escape') {
66
+ e.stopPropagation()
67
+ }
68
+ this.isOpen = false
69
+ },
70
+ select() {
71
+ this.$refs[`dropdown-${this.id}-option-${this.focus}`]?.click()
72
+ this.close()
73
+ document.getElementById(this.id).blur()
74
+ },
75
+ scrollTo(idx) {
76
+ this.$refs[`dropdown-${this.id}-option-${idx}`]?.scrollIntoView({
77
+ block: 'nearest',
78
+ behavior: 'smooth',
79
+ inline: 'start'
80
+ })
81
+ },
82
+ focusNext() {
83
+ const nextIndex = this.nextFocusableIndex()
84
+
85
+ this.open()
86
+
87
+ this.setFocus(nextIndex)
88
+ this.scrollTo(nextIndex)
89
+ },
90
+ focusPrev() {
91
+ const prevIndex = this.prevFocusableIndex()
92
+
93
+ this.open()
94
+
95
+ this.setFocus(prevIndex)
96
+ this.scrollTo(prevIndex)
97
+ }
98
+ })
assets/js/liveview/dropdown.js ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // From https://alpinejs.dev/component/dropdown
2
+
3
+ export default () => ({
4
+ open: false,
5
+ toggle() {
6
+ if (this.open) {
7
+ return this.close()
8
+ }
9
+
10
+ this.$refs.button.focus()
11
+ this.open = true
12
+ },
13
+
14
+ close(focusAfter) {
15
+ if (!this.open) return
16
+
17
+ this.open = false
18
+ focusAfter?.focus()
19
+ }
20
+ })
assets/js/liveview/live_socket.js ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ The modules below this comment block are resolved from '../deps' folder,
3
+ which does not exist when running the lint command in Github CI
4
+ */
5
+
6
+ /* eslint-disable import/no-unresolved */
7
+ import 'phoenix_html'
8
+ import { Socket } from 'phoenix'
9
+ import { LiveSocket } from 'phoenix_live_view'
10
+ import { Modal, Dropdown } from 'prima'
11
+ import topbar from 'topbar'
12
+ /* eslint-enable import/no-unresolved */
13
+
14
+ import Alpine from 'alpinejs'
15
+
16
+ let csrfToken = document.querySelector("meta[name='csrf-token']")
17
+ let websocketUrl = document.querySelector("meta[name='websocket-url']")
18
+ if (csrfToken && websocketUrl) {
19
+ let Hooks = { Modal, Dropdown }
20
+ Hooks.Metrics = {
21
+ mounted() {
22
+ this.handleEvent('send-metrics', ({ event_name }) => {
23
+ window.plausible(event_name)
24
+ this.pushEvent('send-metrics-after', { event_name })
25
+ })
26
+ }
27
+ }
28
+ let Uploaders = {}
29
+ Uploaders.S3 = function (entries, onViewError) {
30
+ entries.forEach((entry) => {
31
+ let xhr = new XMLHttpRequest()
32
+ onViewError(() => xhr.abort())
33
+ xhr.onload = () =>
34
+ xhr.status === 200 ? entry.progress(100) : entry.error()
35
+ xhr.onerror = () => entry.error()
36
+ xhr.upload.addEventListener('progress', (event) => {
37
+ if (event.lengthComputable) {
38
+ let percent = Math.round((event.loaded / event.total) * 100)
39
+ if (percent < 100) {
40
+ entry.progress(percent)
41
+ }
42
+ }
43
+ })
44
+ let url = entry.meta.url
45
+ xhr.open('PUT', url, true)
46
+ xhr.send(entry.file)
47
+ })
48
+ }
49
+ let token = csrfToken.getAttribute('content')
50
+ let url = websocketUrl.getAttribute('content')
51
+ let liveUrl = url === '' ? '/live' : new URL('/live', url).href
52
+ let liveSocket = new LiveSocket(liveUrl, Socket, {
53
+ heartbeatIntervalMs: 10000,
54
+ params: { _csrf_token: token },
55
+ hooks: Hooks,
56
+ uploaders: Uploaders,
57
+ dom: {
58
+ // for alpinejs integration
59
+ onBeforeElUpdated(from, to) {
60
+ if (from._x_dataStack) {
61
+ Alpine.clone(from, to)
62
+ }
63
+ }
64
+ }
65
+ })
66
+
67
+ topbar.config({
68
+ barColors: { 0: '#303f9f' },
69
+ shadowColor: 'rgba(0, 0, 0, .3)',
70
+ barThickness: 4
71
+ })
72
+ window.addEventListener('phx:page-loading-start', (_info) => topbar.show())
73
+ window.addEventListener('phx:page-loading-stop', (_info) => topbar.hide())
74
+ window.addEventListener('scroll-to-top', () => window.scrollTo(0, 0))
75
+
76
+ liveSocket.connect()
77
+ window.liveSocket = liveSocket
78
+ }
assets/js/liveview/phx_events.js ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ window.addEventListener(`phx:update-value`, (e) => {
2
+ let el = document.getElementById(e.detail.id)
3
+ el.value = e.detail.value
4
+ if (e.detail.fire) {
5
+ el.dispatchEvent(new Event('input', { bubbles: true }))
6
+ }
7
+ })
8
+
9
+ window.addEventListener(`phx:js-exec`, ({ detail }) => {
10
+ document.querySelectorAll(detail.to).forEach((el) => {
11
+ window.liveSocket.execJS(el, el.getAttribute(detail.attr))
12
+ })
13
+ })
14
+
15
+ window.addEventListener(`phx:notify-selection-change`, (event) => {
16
+ let el = document.getElementById(event.detail.id)
17
+ el.dispatchEvent(
18
+ new CustomEvent('selection-change', { detail: event.detail })
19
+ )
20
+ })
assets/js/polyfills/closest.js ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ if (window.Element && !Element.prototype.closest) {
2
+ Element.prototype.closest = function (s) {
3
+ var matches = (this.document || this.ownerDocument).querySelectorAll(s),
4
+ i,
5
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
6
+ el = this
7
+ do {
8
+ i = matches.length
9
+ // eslint-disable-next-line no-empty
10
+ while (--i >= 0 && matches.item(i) !== el) {}
11
+ } while (i < 0 && (el = el.parentElement))
12
+ return el
13
+ }
14
+ }
assets/js/types/globals.d.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ declare const BUILD_EXTRA: boolean
assets/js/types/query-api.d.ts ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Autogenerated, recreate with `npm run --prefix assets generate-types` */
2
+
3
+ export type Metric =
4
+ | "visitors"
5
+ | "visits"
6
+ | "pageviews"
7
+ | "views_per_visit"
8
+ | "bounce_rate"
9
+ | "visit_duration"
10
+ | "events"
11
+ | "percentage"
12
+ | "conversion_rate"
13
+ | "group_conversion_rate"
14
+ | "time_on_page"
15
+ | "total_revenue"
16
+ | "average_revenue"
17
+ | "scroll_depth";
18
+ export type DateRangeShorthand =
19
+ | "all"
20
+ | "day"
21
+ | "24h"
22
+ | "7d"
23
+ | "28d"
24
+ | "30d"
25
+ | "91d"
26
+ | "month"
27
+ | "6mo"
28
+ | "12mo"
29
+ | "year"
30
+ | string;
31
+ /**
32
+ * @minItems 2
33
+ * @maxItems 2
34
+ */
35
+ export type DateTimeRange = [string, string];
36
+ /**
37
+ * @minItems 2
38
+ * @maxItems 2
39
+ */
40
+ export type DateRange = [string, string];
41
+ export type Dimensions = SimpleFilterDimensions | CustomPropertyFilterDimensions | GoalDimension | TimeDimensions;
42
+ export type SimpleFilterDimensions =
43
+ | "event:name"
44
+ | "event:page"
45
+ | "event:hostname"
46
+ | "visit:source"
47
+ | "visit:channel"
48
+ | "visit:referrer"
49
+ | "visit:utm_medium"
50
+ | "visit:utm_source"
51
+ | "visit:utm_campaign"
52
+ | "visit:utm_content"
53
+ | "visit:utm_term"
54
+ | "visit:screen"
55
+ | "visit:device"
56
+ | "visit:browser"
57
+ | "visit:browser_version"
58
+ | "visit:os"
59
+ | "visit:os_version"
60
+ | "visit:country"
61
+ | "visit:region"
62
+ | "visit:city"
63
+ | "visit:country_name"
64
+ | "visit:region_name"
65
+ | "visit:city_name"
66
+ | "visit:entry_page"
67
+ | "visit:exit_page"
68
+ | "visit:entry_page_hostname"
69
+ | "visit:exit_page_hostname";
70
+ export type CustomPropertyFilterDimensions = `event:props:${string}`;
71
+ export type GoalDimension = "event:goal";
72
+ export type TimeDimensions = "time" | "time:month" | "time:week" | "time:day" | "time:hour";
73
+ export type FilterTree = FilterEntry | FilterAndOr | FilterNot | FilterHasDone;
74
+ export type FilterEntry = FilterWithoutGoals | FilterWithIs | FilterWithContains | FilterWithPattern;
75
+ /**
76
+ * @minItems 3
77
+ * @maxItems 4
78
+ */
79
+ export type FilterWithoutGoals =
80
+ | [FilterOperationWithoutGoals, SimpleFilterDimensions | CustomPropertyFilterDimensions, Clauses]
81
+ | [
82
+ FilterOperationWithoutGoals,
83
+ SimpleFilterDimensions | CustomPropertyFilterDimensions,
84
+ Clauses,
85
+ {
86
+ case_sensitive?: boolean;
87
+ }
88
+ ];
89
+ /**
90
+ * filter operation
91
+ */
92
+ export type FilterOperationWithoutGoals = "is_not" | "contains_not";
93
+ /**
94
+ * @minItems 1
95
+ */
96
+ export type Clauses = [string | number, ...(string | number)[]];
97
+ /**
98
+ * @minItems 3
99
+ * @maxItems 4
100
+ */
101
+ export type FilterWithIs =
102
+ | ["is", GoalDimension | SimpleFilterDimensions | CustomPropertyFilterDimensions | "segment", Clauses]
103
+ | [
104
+ "is",
105
+ GoalDimension | SimpleFilterDimensions | CustomPropertyFilterDimensions | "segment",
106
+ Clauses,
107
+ {
108
+ case_sensitive?: boolean;
109
+ }
110
+ ];
111
+ /**
112
+ * @minItems 3
113
+ * @maxItems 4
114
+ */
115
+ export type FilterWithContains =
116
+ | ["contains", GoalDimension | SimpleFilterDimensions | CustomPropertyFilterDimensions, Clauses]
117
+ | [
118
+ "contains",
119
+ GoalDimension | SimpleFilterDimensions | CustomPropertyFilterDimensions,
120
+ Clauses,
121
+ {
122
+ case_sensitive?: boolean;
123
+ }
124
+ ];
125
+ /**
126
+ * @minItems 3
127
+ * @maxItems 3
128
+ */
129
+ export type FilterWithPattern = [
130
+ FilterOperationRegex,
131
+ SimpleFilterDimensions | CustomPropertyFilterDimensions,
132
+ Clauses
133
+ ];
134
+ /**
135
+ * filter operation
136
+ */
137
+ export type FilterOperationRegex = "matches" | "matches_not";
138
+ /**
139
+ * @minItems 2
140
+ * @maxItems 2
141
+ */
142
+ export type FilterAndOr = ["and" | "or", [FilterTree, ...FilterTree[]]];
143
+ /**
144
+ * @minItems 2
145
+ * @maxItems 2
146
+ */
147
+ export type FilterNot = ["not", FilterTree];
148
+ /**
149
+ * @minItems 2
150
+ * @maxItems 2
151
+ */
152
+ export type FilterHasDone = ["has_done" | "has_not_done", FilterTree];
153
+ /**
154
+ * @minItems 2
155
+ * @maxItems 2
156
+ */
157
+ export type OrderByEntry = [
158
+ Metric | SimpleFilterDimensions | CustomPropertyFilterDimensions | TimeDimensions,
159
+ SortDirection
160
+ ];
161
+ /**
162
+ * Sorting order
163
+ */
164
+ export type SortDirection = "asc" | "desc";
165
+
166
+ export interface QueryApiSchema {
167
+ /**
168
+ * Domain of site to query
169
+ */
170
+ site_id: string;
171
+ /**
172
+ * List of metrics to query
173
+ *
174
+ * @minItems 1
175
+ */
176
+ metrics: [Metric, ...Metric[]];
177
+ /**
178
+ * Date range to query
179
+ */
180
+ date_range: DateRangeShorthand | DateTimeRange | DateRange;
181
+ /**
182
+ * What to group the results by. Same as `property` in Plausible API v1
183
+ */
184
+ dimensions?: Dimensions[];
185
+ /**
186
+ * How to drill into your data
187
+ */
188
+ filters?: FilterTree[];
189
+ /**
190
+ * How to order query results
191
+ */
192
+ order_by?: OrderByEntry[];
193
+ include?: {
194
+ time_labels?: boolean;
195
+ imports?: boolean;
196
+ /**
197
+ * If set, returns the total number of result rows rows before pagination under `meta.total_rows`
198
+ */
199
+ total_rows?: boolean;
200
+ /**
201
+ * If set and using `day`, `month` or `year` date_ranges, the query will be trimmed to the current date
202
+ */
203
+ trim_relative_date_range?: boolean;
204
+ };
205
+ pagination?: Pagination;
206
+ }
207
+ export interface Pagination {
208
+ /**
209
+ * Number of rows to limit result to.
210
+ */
211
+ limit?: number;
212
+ /**
213
+ * Pagination offset.
214
+ */
215
+ offset?: number;
216
+ }
assets/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
assets/package.json ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "repository": {},
3
+ "version": "1.4.0",
4
+ "license": "AGPL-3.0-or-later",
5
+ "scripts": {
6
+ "test": "TZ=UTC jest",
7
+ "format": "prettier --write \"**/*.{js,css,ts,tsx}\"",
8
+ "check-format": "prettier --check \"**/*.{js,css,ts,tsx}\"",
9
+ "eslint": "eslint js/**",
10
+ "stylelint": "stylelint css/**",
11
+ "lint": "npm run eslint && npm run stylelint",
12
+ "typecheck": "tsc --noEmit --pretty",
13
+ "generate-types": "json2ts ../priv/json-schemas/query-api-schema.json ../assets/js/types/query-api.d.ts --bannerComment '/* Autogenerated, recreate with `npm run --prefix assets generate-types` */'"
14
+ },
15
+ "dependencies": {
16
+ "@headlessui/react": "^1.7.19",
17
+ "@heroicons/react": "^2.2.0",
18
+ "@jsonurl/jsonurl": "^1.1.7",
19
+ "@juggle/resize-observer": "^3.3.1",
20
+ "@popperjs/core": "^2.11.6",
21
+ "@tailwindcss/forms": "^0.5.10",
22
+ "@tailwindcss/typography": "^0.4.1",
23
+ "@tanstack/react-query": "^5.51.1",
24
+ "abortcontroller-polyfill": "^1.7.3",
25
+ "alpinejs": "^3.13.1",
26
+ "chart.js": "^3.3.2",
27
+ "chartjs-plugin-datalabels": "^2.2.0",
28
+ "classnames": "^2.3.1",
29
+ "d3": "^7.9.0",
30
+ "dayjs": "^1.11.7",
31
+ "fast-deep-equal": "^3.1.3",
32
+ "iframe-resizer": "^4.3.2",
33
+ "react": "^18.3.1",
34
+ "react-dom": "^18.3.1",
35
+ "react-flatpickr": "3.10.5",
36
+ "react-flip-move": "^3.0.4",
37
+ "react-intersection-observer": "^9.5.2",
38
+ "react-popper": "^2.3.0",
39
+ "react-router-dom": "^6.25.1",
40
+ "react-transition-group": "^4.4.2",
41
+ "topbar": "^3.0.0",
42
+ "topojson-client": "^3.1.0",
43
+ "url-search-params-polyfill": "^8.2.5",
44
+ "visionscarto-world-atlas": "^1.0.0"
45
+ },
46
+ "devDependencies": {
47
+ "@eslint/js": "^9.23.0",
48
+ "@testing-library/dom": "^10.4.0",
49
+ "@testing-library/jest-dom": "^6.4.8",
50
+ "@testing-library/react": "^16.0.0",
51
+ "@testing-library/user-event": "^14.5.2",
52
+ "@types/d3": "^7.4.3",
53
+ "@types/jest": "^29.5.12",
54
+ "@types/react": "^18.3.3",
55
+ "@types/react-dom": "^18.3.0",
56
+ "@types/react-flatpickr": "^3.8.11",
57
+ "@types/topojson-client": "^3.1.4",
58
+ "eslint": "^9.23.0",
59
+ "eslint-config-prettier": "^10.1.1",
60
+ "eslint-import-resolver-typescript": "^4.3.1",
61
+ "eslint-plugin-import": "^2.31.0",
62
+ "eslint-plugin-jest": "^28.11.0",
63
+ "eslint-plugin-jsx-a11y": "^6.10.2",
64
+ "eslint-plugin-react": "^7.37.5",
65
+ "eslint-plugin-react-hooks": "^5.2.0",
66
+ "globals": "^16.0.0",
67
+ "jest": "^29.7.0",
68
+ "jest-environment-jsdom": "^29.7.0",
69
+ "jsdom-testing-mocks": "^1.13.1",
70
+ "json-schema-to-typescript": "^15.0.2",
71
+ "prettier": "^3.3.3",
72
+ "stylelint": "^16.17.0",
73
+ "stylelint-config-standard": "^36.0.1",
74
+ "ts-jest": "^29.2.4",
75
+ "typescript": "^5.5.4",
76
+ "typescript-eslint": "^8.29.0"
77
+ },
78
+ "name": "assets"
79
+ }
assets/test-utils/app-context-providers.tsx ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { ReactNode } from 'react'
2
+ import SiteContextProvider, {
3
+ PlausibleSite
4
+ } from '../js/dashboard/site-context'
5
+ import UserContextProvider, {
6
+ Role,
7
+ UserContextValue
8
+ } from '../js/dashboard/user-context'
9
+ import { MemoryRouter, MemoryRouterProps } from 'react-router-dom'
10
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
11
+ import DashboardStateContextProvider from '../js/dashboard/dashboard-state-context'
12
+ import { getRouterBasepath } from '../js/dashboard/router'
13
+ import { RoutelessModalsContextProvider } from '../js/dashboard/navigation/routeless-modals-context'
14
+ import { SegmentsContextProvider } from '../js/dashboard/filtering/segments-context'
15
+ import { SavedSegment, SavedSegments } from '../js/dashboard/filtering/segments'
16
+ import { GraphIntervalProvider } from '../js/dashboard/stats/graph/graph-interval-context'
17
+ import { ImportsIncludedProvider } from '../js/dashboard/stats/graph/imports-included-context'
18
+ import { CurrentVisitorsProvider } from '../js/dashboard/current-visitors-context'
19
+
20
+ type TestContextProvidersProps = {
21
+ children: ReactNode
22
+ routerProps?: Pick<MemoryRouterProps, 'initialEntries'>
23
+ siteOptions?: Partial<PlausibleSite>
24
+ user?: UserContextValue
25
+ preloaded?: { segments?: SavedSegments }
26
+ limitedToSegment?: SavedSegment | null
27
+ }
28
+
29
+ export const DEFAULT_SITE: PlausibleSite = {
30
+ domain: 'plausible.io/unit',
31
+ offset: 0,
32
+ hasGoals: false,
33
+ hasProps: false,
34
+ funnelsAvailable: false,
35
+ explorationAvailable: false,
36
+ explorationJourneyEndEvent: '',
37
+ explorationMaxJourneySteps: 0,
38
+ propsAvailable: false,
39
+ siteSegmentsAvailable: false,
40
+ siteAnnotationsAvailable: false,
41
+ conversionsOptedOut: false,
42
+ funnelsOptedOut: false,
43
+ propsOptedOut: false,
44
+ revenueGoals: [],
45
+ funnels: [],
46
+ statsBegin: '',
47
+ nativeStatsBegin: '',
48
+ embedded: false,
49
+ background: '',
50
+ isDbip: false,
51
+ flags: {},
52
+ shared: false,
53
+ isConsolidatedView: false
54
+ }
55
+
56
+ export const TestContextProviders = ({
57
+ children,
58
+ routerProps,
59
+ siteOptions,
60
+ preloaded,
61
+ limitedToSegment,
62
+ user
63
+ }: TestContextProvidersProps) => {
64
+ const site = { ...DEFAULT_SITE, ...siteOptions }
65
+
66
+ const queryClient = new QueryClient({
67
+ defaultOptions: {
68
+ queries: {
69
+ refetchOnWindowFocus: false
70
+ }
71
+ }
72
+ })
73
+
74
+ const defaultInitialEntries = [getRouterBasepath(site)]
75
+
76
+ return (
77
+ // <ThemeContextProvider> not interactive component, default value is suitable
78
+ <SiteContextProvider site={site}>
79
+ <UserContextProvider
80
+ user={
81
+ user ?? {
82
+ role: Role.editor,
83
+ loggedIn: true,
84
+ id: 1,
85
+ team: { identifier: null, hasConsolidatedView: false }
86
+ }
87
+ }
88
+ >
89
+ <SegmentsContextProvider
90
+ limitedToSegment={limitedToSegment ?? null}
91
+ preloadedSegments={preloaded?.segments ?? []}
92
+ >
93
+ <MemoryRouter
94
+ basename={getRouterBasepath(site)}
95
+ initialEntries={defaultInitialEntries}
96
+ {...routerProps}
97
+ >
98
+ <QueryClientProvider client={queryClient}>
99
+ <RoutelessModalsContextProvider>
100
+ <DashboardStateContextProvider>
101
+ <CurrentVisitorsProvider>
102
+ <GraphIntervalProvider>
103
+ <ImportsIncludedProvider>
104
+ {children}
105
+ </ImportsIncludedProvider>
106
+ </GraphIntervalProvider>
107
+ </CurrentVisitorsProvider>
108
+ </DashboardStateContextProvider>
109
+ </RoutelessModalsContextProvider>
110
+ </QueryClientProvider>
111
+ </MemoryRouter>
112
+ </SegmentsContextProvider>
113
+ </UserContextProvider>
114
+ </SiteContextProvider>
115
+ // </ThemeContextProvider>
116
+ )
117
+ }
assets/test-utils/extend-expect.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ import '@testing-library/jest-dom'
assets/test-utils/index.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { render, RenderOptions } from '@testing-library/react'
2
+ import { ReactNode } from 'react'
3
+
4
+ /**
5
+ * Makes the fake document in unit tests aware of some tailwind class definitions.
6
+ * Needed for the matcher option ({ hidden: false }) to function at least partially.
7
+ */
8
+ const registerPartialTailwindStyle = () => {
9
+ const tailwindStyle = `.invisible { visibility: hidden; }`
10
+
11
+ const style = document.createElement('style')
12
+ style.innerHTML = tailwindStyle
13
+ document.head.appendChild(style)
14
+ }
15
+
16
+ const customRender = (ui: ReactNode, options: RenderOptions) => {
17
+ const output = render(ui, options)
18
+ registerPartialTailwindStyle()
19
+ return output
20
+ }
21
+
22
+ export * from '@testing-library/react'
23
+ export { customRender as render }
assets/test-utils/jsdom-mocks.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import { configMocks } from 'jsdom-testing-mocks'
2
+ import { act } from '@testing-library/react'
3
+
4
+ // as per jsdom-testing-mocks docs, this is needed to avoid having to wrap everything in act calls
5
+ configMocks({ act })
assets/test-utils/mock-api.ts ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export class MockAPI {
2
+ private mocked: Map<string, jest.Mock>
3
+ private fetch: jest.Mock
4
+ private originalFetch: null | unknown = null
5
+
6
+ constructor() {
7
+ this.mocked = new Map()
8
+ this.fetch = jest.fn()
9
+ }
10
+
11
+ private setHandler(
12
+ method: string,
13
+ urlWithoutQueryString: string,
14
+ handler: jest.Mock
15
+ ) {
16
+ this.mocked.set(
17
+ [method.toLowerCase(), urlWithoutQueryString].join(' '),
18
+ handler
19
+ )
20
+ }
21
+
22
+ // sets get handler
23
+ public get(
24
+ urlWithoutQueryString: string,
25
+ responseHandler: typeof fetch | Record<string, unknown> | number | null
26
+ ): jest.Mock {
27
+ return this.register('get', urlWithoutQueryString, responseHandler)
28
+ }
29
+
30
+ // sets post handler
31
+ public post(
32
+ urlWithoutQueryString: string,
33
+ responseHandler: typeof fetch | Record<string, unknown>
34
+ ): jest.Mock {
35
+ return this.register('post', urlWithoutQueryString, responseHandler)
36
+ }
37
+
38
+ private register(
39
+ method: string,
40
+ urlWithoutQueryString: string,
41
+ responseHandler: typeof fetch | Record<string, unknown> | number | null
42
+ ): jest.Mock {
43
+ const handler: typeof fetch =
44
+ typeof responseHandler === 'function'
45
+ ? responseHandler
46
+ : () =>
47
+ new Promise((resolve) =>
48
+ resolve({
49
+ status: 200,
50
+ ok: true,
51
+ json: async () => responseHandler
52
+ } as Response)
53
+ )
54
+ const jestWrappedHandler = jest.fn(handler)
55
+ this.setHandler(method, urlWithoutQueryString, jestWrappedHandler)
56
+ return jestWrappedHandler
57
+ }
58
+
59
+ private getHandler(method: string, urlWithoutQueryString: string) {
60
+ return this.mocked.get([method, urlWithoutQueryString].join(' '))
61
+ }
62
+
63
+ public clear() {
64
+ this.mocked = new Map()
65
+ this.fetch.mockClear()
66
+ }
67
+
68
+ public start() {
69
+ this.originalFetch = global.fetch
70
+ const mockFetch: typeof global.fetch = async (input, init) => {
71
+ if (typeof input !== 'string') {
72
+ throw new Error(`Unmocked request ${input.toString()}`)
73
+ }
74
+ const method = (init?.method ?? 'get').toLowerCase()
75
+ const urlWithoutQueryString = input.split('?')[0]
76
+ const handler = this.getHandler(method, urlWithoutQueryString)
77
+ if (!handler) {
78
+ throw new Error(
79
+ `Unmocked request ${method.toString()} ${input.toString()}`
80
+ )
81
+ }
82
+ return handler(input, init)
83
+ }
84
+
85
+ global.fetch = this.fetch.mockImplementation(mockFetch)
86
+ return this
87
+ }
88
+
89
+ public stop() {
90
+ global.fetch = this.originalFetch as typeof global.fetch
91
+ }
92
+ }
assets/test-utils/reset-state.ts ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @returns clears the state that the app stores,
3
+ * to avoid individual tests impacting each other
4
+ */
5
+ function clearStoredAppState() {
6
+ localStorage.clear()
7
+ }
8
+
9
+ beforeEach(() => {
10
+ clearStoredAppState()
11
+ })
assets/tsconfig.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "noEmit": true,
4
+ "jsx": "react",
5
+ "target": "es2017",
6
+ "module": "commonjs",
7
+ "allowJs": true,
8
+ "resolveJsonModule": true,
9
+ "esModuleInterop": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "strict": true,
12
+ "skipLibCheck": true
13
+ }
14
+ }
config/.env.dev ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ BASE_URL=http://localhost:8000
2
+ SECURE_COOKIE=false
3
+ DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/plausible_dev
4
+ CLICKHOUSE_DATABASE_URL=http://127.0.0.1:8123/plausible_events_db
5
+ CLICKHOUSE_MAX_BUFFER_SIZE_BYTES=1000000
6
+ SECRET_KEY_BASE=/njrhntbycvastyvtk1zycwfm981vpo/0xrvwjjvemdakc/vsvbrevlwsc6u8rcg
7
+ TOTP_VAULT_KEY=Q3BD4nddbkVJIPXgHuo5NthGKSIH0yesRfG05J88HIo=
8
+ ENVIRONMENT=dev
9
+ MAILER_ADAPTER=Bamboo.LocalAdapter
10
+ LOG_LEVEL=debug
11
+ SELFHOST=false
12
+ DISABLE_CRON=true
13
+ ADMIN_USER_IDS=1
14
+ SHOW_CITIES=true
15
+ PADDLE_VENDOR_AUTH_CODE=895e20d4efaec0575bb857f44b183217b332d9592e76e69b8a
16
+ PADDLE_VENDOR_ID=3942
17
+ SSO_VERIFICATION_NAMESERVERS=0.0.0.0:5354
18
+
19
+ GOOGLE_CLIENT_ID=875387135161-l8tp53dpt7fdhdg9m1pc3vl42si95rh0.apps.googleusercontent.com
20
+ GOOGLE_CLIENT_SECRET=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
21
+
22
+ PROMEX_DISABLED=false
23
+ SITE_DEFAULT_INGEST_THRESHOLD=1000000
24
+
25
+ S3_DISABLED=false
26
+ S3_ACCESS_KEY_ID=minioadmin
27
+ S3_SECRET_ACCESS_KEY=minioadmin
28
+ S3_REGION=us-east-1
29
+ S3_ENDPOINT=http://localhost:10000
30
+ S3_EXPORTS_BUCKET=dev-exports
31
+ S3_IMPORTS_BUCKET=dev-imports
32
+
33
+ HELP_SCOUT_APP_ID=fake_app_id
34
+ HELP_SCOUT_APP_SECRET=fake_app_secret
35
+ HELP_SCOUT_SIGNATURE_KEY=fake_signature_key
36
+ HELP_SCOUT_VAULT_KEY=ym9ZQg0KPNGCH3C2eD5y6KpL0tFzUqAhwxQO6uEv/ZM=
37
+
38
+ VERIFICATION_ENABLED=true
config/.env.e2e_test ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ BASE_URL=http://localhost:8111
2
+ HTTP_PORT=8111
3
+ SECURE_COOKIE=false
4
+ DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/plausible_e2e
5
+ CLICKHOUSE_DATABASE_URL=http://127.0.0.1:8123/plausible_e2e
6
+ CLICKHOUSE_MAX_BUFFER_SIZE_BYTES=1000000
7
+ SECRET_KEY_BASE=/njrhntbycvastyvtk1zycwfm981vpo/0xrvwjjvemdakc/vsvbrevlwsc6u8rcg
8
+ TOTP_VAULT_KEY=Q3BD4nddbkVJIPXgHuo5NthGKSIH0yesRfG05J88HIo=
9
+ ENVIRONMENT=dev
10
+ MAILER_ADAPTER=Bamboo.LocalAdapter
11
+ LOG_LEVEL=error
12
+ SELFHOST=false
13
+ DISABLE_CRON=true
14
+ ADMIN_USER_IDS=1
15
+ SHOW_CITIES=true
16
+ PADDLE_VENDOR_AUTH_CODE=895e20d4efaec0575bb857f44b183217b332d9592e76e69b8a
17
+ PADDLE_VENDOR_ID=3942
18
+ SSO_VERIFICATION_NAMESERVERS=0.0.0.0:5354
19
+
20
+ GOOGLE_CLIENT_ID=875387135161-l8tp53dpt7fdhdg9m1pc3vl42si95rh0.apps.googleusercontent.com
21
+ GOOGLE_CLIENT_SECRET=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
22
+
23
+ PROMEX_DISABLED=false
24
+ SITE_DEFAULT_INGEST_THRESHOLD=1000000
25
+
26
+ S3_DISABLED=false
27
+ S3_ACCESS_KEY_ID=minioadmin
28
+ S3_SECRET_ACCESS_KEY=minioadmin
29
+ S3_REGION=us-east-1
30
+ S3_ENDPOINT=http://localhost:10000
31
+ S3_EXPORTS_BUCKET=dev-exports
32
+ S3_IMPORTS_BUCKET=dev-imports
33
+
34
+ HELP_SCOUT_APP_ID=fake_app_id
35
+ HELP_SCOUT_APP_SECRET=fake_app_secret
36
+ HELP_SCOUT_SIGNATURE_KEY=fake_signature_key
37
+ HELP_SCOUT_VAULT_KEY=ym9ZQg0KPNGCH3C2eD5y6KpL0tFzUqAhwxQO6uEv/ZM=
38
+
39
+ VERIFICATION_ENABLED=true
config/.env.load ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ BASE_URL=http://localhost:8000
2
+ SECURE_COOKIE=false
3
+ DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/plausible_dev
4
+ CLICKHOUSE_DATABASE_URL=http://127.0.0.1:8123/plausible_events_db
5
+ CLICKHOUSE_MAX_BUFFER_SIZE_BYTES=1000000
6
+ SECRET_KEY_BASE=/njrhntbycvastyvtk1zycwfm981vpo/0xrvwjjvemdakc/vsvbrevlwsc6u8rcg
7
+ TOTP_VAULT_KEY=Q3BD4nddbkVJIPXgHuo5NthGKSIH0yesRfG05J88HIo=
8
+ ENVIRONMENT=dev
9
+ MAILER_ADAPTER=Bamboo.LocalAdapter
10
+ LOG_LEVEL=debug
11
+ SELFHOST=false
12
+ DISABLE_CRON=true
13
+ ADMIN_USER_IDS=1
14
+ SHOW_CITIES=true
15
+ PADDLE_VENDOR_AUTH_CODE=895e20d4efaec0575bb857f44b183217b332d9592e76e69b8a
16
+ PADDLE_VENDOR_ID=3942
17
+
18
+ GOOGLE_CLIENT_ID=875387135161-l8tp53dpt7fdhdg9m1pc3vl42si95rh0.apps.googleusercontent.com
19
+ GOOGLE_CLIENT_SECRET=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
20
+
21
+ PROMEX_DISABLED=false
22
+ SITE_DEFAULT_INGEST_THRESHOLD=1000000
23
+
24
+ S3_DISABLED=false
25
+ S3_ACCESS_KEY_ID=minioadmin
26
+ S3_SECRET_ACCESS_KEY=minioadmin
27
+ S3_REGION=us-east-1
28
+ S3_ENDPOINT=http://localhost:10000
29
+ S3_EXPORTS_BUCKET=dev-exports
30
+ S3_IMPORTS_BUCKET=dev-imports
31
+
32
+ HELP_SCOUT_APP_ID=fake_app_id
33
+ HELP_SCOUT_APP_SECRET=fake_app_secret
34
+ HELP_SCOUT_SIGNATURE_KEY=fake_signature_key
35
+ HELP_SCOUT_VAULT_KEY=ym9ZQg0KPNGCH3C2eD5y6KpL0tFzUqAhwxQO6uEv/ZM=
36
+
37
+ VERIFICATION_ENABLED=true
config/.env.test ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/plausible_test
2
+ CLICKHOUSE_DATABASE_URL=http://127.0.0.1:8123/plausible_test
3
+ SECRET_KEY_BASE=/njrhntbycvastyvtk1zycwfm981vpo/0xrvwjjvemdakc/vsvbrevlwsc6u8rcg
4
+ TOTP_VAULT_KEY=1Jah1HEOnCEnmBE+4/OgbJRraJIppPmYCNbZoFJboZs=
5
+ BASE_URL=http://localhost:8000
6
+ CRON_ENABLED=false
7
+ LOG_LEVEL=warning
8
+ ENVIRONMENT=test
9
+ MAILER_ADAPTER=Bamboo.TestAdapter
10
+ ENABLE_EMAIL_VERIFICATION=true
11
+ SELFHOST=false
12
+ HCAPTCHA_SITEKEY=test
13
+ HCAPTCHA_SECRET=scottiger
14
+ IP_GEOLOCATION_DB=test/priv/GeoLite2-City-Test.mmdb
15
+ SITE_DEFAULT_INGEST_THRESHOLD=1000000
16
+ GOOGLE_CLIENT_ID=fake_client_id
17
+ GOOGLE_CLIENT_SECRET=fake_client_secret
18
+ HELP_SCOUT_APP_ID=fake_app_id
19
+ HELP_SCOUT_APP_SECRET=fake_app_secret
20
+ HELP_SCOUT_SIGNATURE_KEY=fake_signature_key
21
+ HELP_SCOUT_VAULT_KEY=ym9ZQg0KPNGCH3C2eD5y6KpL0tFzUqAhwxQO6uEv/ZM=
22
+
23
+ S3_DISABLED=false
24
+ S3_ACCESS_KEY_ID=minioadmin
25
+ S3_SECRET_ACCESS_KEY=minioadmin
26
+ S3_REGION=us-east-1
27
+ S3_ENDPOINT=http://localhost:10000
28
+ S3_EXPORTS_BUCKET=test-exports
29
+ S3_IMPORTS_BUCKET=test-imports
30
+
31
+ VERIFICATION_ENABLED=true
config/ce.exs ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Config
2
+
3
+ import_config "prod.exs"
4
+
5
+ config :phoenix,
6
+ static_compressors: [
7
+ PhoenixBakery.Gzip,
8
+ PhoenixBakery.Brotli
9
+ ]
10
+
11
+ config :esbuild,
12
+ default: [
13
+ args:
14
+ ~w(js/app.js js/dashboard.tsx js/embed.host.js js/embed.content.js --bundle --target=es2017 --loader:.js=jsx --outdir=../priv/static/js --define:BUILD_EXTRA=false),
15
+ cd: Path.expand("../assets", __DIR__),
16
+ env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
17
+ ]
18
+
19
+ config :plausible, Plausible.Auth.ApiKey,
20
+ legacy_per_user_hourly_request_limit: 1_000_000,
21
+ burst_request_limit: 1_000_000,
22
+ burst_period_seconds: 10
config/ce_dev.exs ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Config
2
+
3
+ import_config "dev.exs"
4
+
5
+ config :esbuild,
6
+ default: [
7
+ args:
8
+ ~w(js/app.js js/dashboard.tsx js/embed.host.js js/embed.content.js --bundle --target=es2017 --loader:.js=jsx --outdir=../priv/static/js --define:BUILD_EXTRA=false),
9
+ cd: Path.expand("../assets", __DIR__),
10
+ env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
11
+ ]
config/ce_test.exs ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ import Config
2
+
3
+ import_config "test.exs"
config/config.exs ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Config
2
+
3
+ config :plausible,
4
+ ecto_repos: [Plausible.Repo, Plausible.IngestRepo]
5
+
6
+ config :plausible, PlausibleWeb.Endpoint,
7
+ # Does not to have to be secret, as per: https://github.com/phoenixframework/phoenix/issues/2146
8
+ live_view: [signing_salt: "f+bZg/crMtgjZJJY7X6OwIWc3XJR2C5Y"],
9
+ pubsub_server: Plausible.PubSub,
10
+ render_errors: [
11
+ view: PlausibleWeb.ErrorView,
12
+ layout: {PlausibleWeb.LayoutView, "base_error.html"},
13
+ accepts: ~w(html json)
14
+ ]
15
+
16
+ # Use Jason for JSON parsing in Phoenix
17
+ config :phoenix, :json_library, Jason
18
+
19
+ config :esbuild,
20
+ version: "0.17.11",
21
+ default: [
22
+ args:
23
+ ~w(js/app.js js/dashboard.tsx js/embed.host.js js/embed.content.js --bundle --target=es2017 --loader:.js=jsx --outdir=../priv/static/js --define:BUILD_EXTRA=true),
24
+ cd: Path.expand("../assets", __DIR__),
25
+ env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
26
+ ]
27
+
28
+ config :tailwind,
29
+ version: "4.1.12",
30
+ default: [
31
+ args: ~w(
32
+ --input=assets/css/app.css
33
+ --output=priv/static/css/app.css
34
+ ),
35
+ cd: Path.expand("..", __DIR__)
36
+ ]
37
+
38
+ config :ua_inspector,
39
+ database_path: "priv/ua_inspector",
40
+ remote_release: "6.5.0"
41
+
42
+ config :ref_inspector,
43
+ database_path: "priv/ref_inspector"
44
+
45
+ config :plausible,
46
+ paddle_api: Plausible.Billing.PaddleApi,
47
+ google_api: Plausible.Google.API
48
+
49
+ config :plausible,
50
+ # 30 minutes
51
+ session_timeout: 1000 * 60 * 30,
52
+ session_length_minutes: 30
53
+
54
+ config :fun_with_flags, :cache_bust_notifications, enabled: false
55
+
56
+ config :fun_with_flags, :persistence,
57
+ adapter: FunWithFlags.Store.Persistent.Ecto,
58
+ repo: Plausible.Repo
59
+
60
+ config :plausible, Plausible.ClickhouseRepo, loggers: [Ecto.LogEntry]
61
+
62
+ config :plausible, Plausible.Repo,
63
+ timeout: 300_000,
64
+ connect_timeout: 300_000,
65
+ handshake_timeout: 300_000,
66
+ queue_target: 500,
67
+ queue_inerval: 1100
68
+
69
+ config :plausible, Plausible.Cache, enabled: true
70
+
71
+ config :plausible, Plausible.Ingestion.Counters, enabled: true
72
+
73
+ config :ex_cldr,
74
+ default_locale: "en",
75
+ default_backend: Plausible.Cldr
76
+
77
+ config :sentry,
78
+ enable_source_code_context: true,
79
+ root_source_code_path: [File.cwd!()]
80
+
81
+ config :prom_ex, :storage_adapter, Plausible.PromEx.StripedPeep
82
+ config :peep, :bucket_calculator, Plausible.PromEx.Buckets
83
+
84
+ config :plausible, Plausible.Auth.ApiKey,
85
+ legacy_per_user_hourly_request_limit: 600,
86
+ burst_request_limit: 60,
87
+ burst_period_seconds: 10
88
+
89
+ import_config "#{config_env()}.exs"
config/dev.exs ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Config
2
+
3
+ config :plausible, PlausibleWeb.Endpoint,
4
+ server: true,
5
+ debug_errors: true,
6
+ code_reloader: true,
7
+ check_origin: false,
8
+ watchers: [
9
+ esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
10
+ tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]},
11
+ npm: ["--prefix", "assets", "run", "typecheck", "--", "--watch", "--preserveWatchOutput"],
12
+ npm: [
13
+ "run",
14
+ "deploy",
15
+ cd: Path.expand("../tracker", __DIR__)
16
+ ]
17
+ ],
18
+ live_reload: [
19
+ dirs: [
20
+ "extra"
21
+ ],
22
+ patterns: [
23
+ ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$},
24
+ ~r"lib/plausible_web/(controllers|live|components|templates|views|plugs)/.*(ex|heex)$"
25
+ ]
26
+ ]
27
+
28
+ config :plausible, paddle_api: Plausible.Billing.DevPaddleApiMock
29
+
30
+ config :phoenix, :stacktrace_depth, 20
31
+ config :phoenix, :plug_init_mode, :runtime
32
+
33
+ config :plausible, Plausible.Repo, stacktrace: true
34
+ config :plausible, Plausible.ClickhouseRepo, stacktrace: true
35
+ config :plausible, Plausible.IngestRepo, stacktrace: true
36
+ config :plausible, Plausible.AsyncInsertRepo, stacktrace: true
37
+
38
+ config :phoenix_live_view,
39
+ debug_heex_annotations: true,
40
+ debug_attributes: true,
41
+ enable_expensive_runtime_checks: true
config/e2e_test.exs ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Config
2
+
3
+ config :plausible, PlausibleWeb.Endpoint,
4
+ server: true,
5
+ check_origin: false
6
+
7
+ config :plausible,
8
+ paddle_api: Plausible.Billing.DevPaddleApiMock,
9
+ google_api: Plausible.Google.API.Mock
10
+
11
+ config :phoenix, :stacktrace_depth, 20
12
+ config :phoenix, :plug_init_mode, :runtime
13
+
14
+ config :bcrypt_elixir, :log_rounds, 4
15
+
16
+ config :plausible, Plausible.Ingestion.Counters, enabled: false
17
+
18
+ config :plausible, Oban, testing: :manual
19
+
20
+ config :plausible, Plausible.Session.Salts, interval: :timer.hours(1)
config/load.exs ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Config
2
+
3
+ config :plausible, PlausibleWeb.Endpoint,
4
+ cache_static_manifest: "priv/static/cache_manifest.json",
5
+ check_origin: false,
6
+ server: true,
7
+ code_reloader: false,
8
+ http: [
9
+ transport_options: [
10
+ num_acceptors: 1000
11
+ ]
12
+ ],
13
+ protocol_options: [
14
+ max_keepalive: 5_000,
15
+ idle_timeout: 120_000,
16
+ request_timeout: 120_000
17
+ ]
config/prod.exs ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import Config
2
+
3
+ config :plausible, PlausibleWeb.Endpoint,
4
+ cache_static_manifest: "priv/static/cache_manifest.json",
5
+ check_origin: false,
6
+ server: true,
7
+ code_reloader: false
config/runtime.exs ADDED
@@ -0,0 +1,1102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Config
2
+ import Plausible.ConfigHelpers
3
+ require Logger
4
+
5
+ if config_env() in [:dev, :test, :load] do
6
+ Envy.load(["config/.env.#{config_env()}"])
7
+ end
8
+
9
+ if config_env() == :ce_dev do
10
+ Envy.load(["config/.env.dev"])
11
+ end
12
+
13
+ if config_env() == :ce_test do
14
+ Envy.load(["config/.env.test"])
15
+ end
16
+
17
+ if config_env() == :e2e_test do
18
+ Envy.load(["config/.env.e2e_test"])
19
+ end
20
+
21
+ config_dir = System.get_env("CONFIG_DIR", "/run/secrets")
22
+
23
+ log_format =
24
+ get_var_from_path_or_env(config_dir, "LOG_FORMAT", "standard")
25
+
26
+ default_log_level = if config_env() == :ce, do: "notice", else: "warning"
27
+
28
+ log_level =
29
+ config_dir
30
+ |> get_var_from_path_or_env("LOG_LEVEL", default_log_level)
31
+ |> String.to_existing_atom()
32
+
33
+ config :logger, level: log_level
34
+ config :logger, :default_formatter, metadata: [:request_id, :trace_id]
35
+
36
+ config :logger, Sentry.LoggerBackend,
37
+ capture_log_messages: true,
38
+ level: :error
39
+
40
+ case String.downcase(log_format) do
41
+ "standard" ->
42
+ config :logger, :default_formatter, format: "$time $metadata[$level] $message\n"
43
+
44
+ "json" ->
45
+ config :logger, :default_formatter, format: {ExJsonLogger, :format}
46
+ end
47
+
48
+ # Listen IP supports IPv4 and IPv6 addresses.
49
+ listen_ip =
50
+ (
51
+ str = get_var_from_path_or_env(config_dir, "LISTEN_IP") || "127.0.0.1"
52
+
53
+ case :inet.parse_address(String.to_charlist(str)) do
54
+ {:ok, ip_addr} ->
55
+ ip_addr
56
+
57
+ {:error, reason} ->
58
+ raise "Invalid LISTEN_IP '#{str}' error: #{inspect(reason)}"
59
+ end
60
+ )
61
+
62
+ # System.get_env does not accept a non string default
63
+ http_port =
64
+ get_int_from_path_or_env(config_dir, "HTTP_PORT") ||
65
+ get_int_from_path_or_env(config_dir, "PORT", 8000)
66
+
67
+ https_port = get_int_from_path_or_env(config_dir, "HTTPS_PORT")
68
+
69
+ base_url = get_var_from_path_or_env(config_dir, "BASE_URL")
70
+
71
+ if !base_url do
72
+ raise "BASE_URL configuration option is required. See https://github.com/plausible/community-edition/wiki/configuration#base_url"
73
+ end
74
+
75
+ base_url = URI.parse(base_url)
76
+
77
+ if base_url.scheme not in ["http", "https"] do
78
+ raise "BASE_URL must start with `http` or `https`. Currently configured as `#{System.get_env("BASE_URL")}`"
79
+ end
80
+
81
+ secret_key_base = get_var_from_path_or_env(config_dir, "SECRET_KEY_BASE", nil)
82
+
83
+ case secret_key_base do
84
+ nil ->
85
+ raise "SECRET_KEY_BASE configuration option is required. See https://github.com/plausible/community-edition/wiki/configuration#secret_key_base"
86
+
87
+ key when byte_size(key) < 32 ->
88
+ raise "SECRET_KEY_BASE must be at least 32 bytes long. See https://github.com/plausible/community-edition/wiki/configuration#secret_key_base"
89
+
90
+ _ ->
91
+ nil
92
+ end
93
+
94
+ super_admin_user_ids =
95
+ get_var_from_path_or_env(config_dir, "ADMIN_USER_IDS", "")
96
+ |> String.split(",")
97
+ |> Enum.map(fn id -> Integer.parse(id) end)
98
+ |> Enum.map(fn
99
+ {int, ""} -> int
100
+ _ -> nil
101
+ end)
102
+ |> Enum.filter(& &1)
103
+
104
+ env = get_var_from_path_or_env(config_dir, "ENVIRONMENT", "prod")
105
+ mailer_adapter = get_var_from_path_or_env(config_dir, "MAILER_ADAPTER", "Bamboo.Mua")
106
+ mailer_email = get_var_from_path_or_env(config_dir, "MAILER_EMAIL", "plausible@#{base_url.host}")
107
+
108
+ mailer_email =
109
+ if mailer_name = get_var_from_path_or_env(config_dir, "MAILER_NAME") do
110
+ {mailer_name, mailer_email}
111
+ else
112
+ mailer_email
113
+ end
114
+
115
+ app_version = get_var_from_path_or_env(config_dir, "APP_VERSION", "0.0.1")
116
+
117
+ ch_db_url =
118
+ get_var_from_path_or_env(
119
+ config_dir,
120
+ "CLICKHOUSE_DATABASE_URL",
121
+ "http://plausible_events_db:8123/plausible_events_db"
122
+ )
123
+
124
+ {ingest_pool_size, ""} =
125
+ get_var_from_path_or_env(
126
+ config_dir,
127
+ "CLICKHOUSE_INGEST_POOL_SIZE",
128
+ "5"
129
+ )
130
+ |> Integer.parse()
131
+
132
+ {ch_flush_interval_ms, ""} =
133
+ config_dir
134
+ |> get_var_from_path_or_env("CLICKHOUSE_FLUSH_INTERVAL_MS", "5000")
135
+ |> Integer.parse()
136
+
137
+ if get_var_from_path_or_env(config_dir, "CLICKHOUSE_MAX_BUFFER_SIZE") do
138
+ Logger.warning(
139
+ "CLICKHOUSE_MAX_BUFFER_SIZE is deprecated, please use CLICKHOUSE_MAX_BUFFER_SIZE_BYTES instead"
140
+ )
141
+ end
142
+
143
+ {ch_max_buffer_size, ""} =
144
+ config_dir
145
+ |> get_var_from_path_or_env("CLICKHOUSE_MAX_BUFFER_SIZE_BYTES", "100000")
146
+ |> Integer.parse()
147
+
148
+ persistor_backend =
149
+ case get_var_from_path_or_env(config_dir, "PERSISTOR_BACKEND", "embedded") do
150
+ "embedded" -> Plausible.Ingestion.Persistor.Embedded
151
+ "embedded_with_relay" -> Plausible.Ingestion.Persistor.EmbeddedWithRelay
152
+ "remote" -> Plausible.Ingestion.Persistor.Remote
153
+ end
154
+
155
+ {persistor_backend_percent_enabled, ""} =
156
+ config_dir
157
+ |> get_var_from_path_or_env("PERSISTOR_BACKEND_PERCENT_ENABLED", "0")
158
+ |> Integer.parse()
159
+
160
+ persistor_url =
161
+ get_var_from_path_or_env(config_dir, "PERSISTOR_URL", "http://localhost:8001/event")
162
+
163
+ persistor_count = get_int_from_path_or_env(config_dir, "PERSISTOR_COUNT", 200)
164
+
165
+ persistor_timeout_ms = get_int_from_path_or_env(config_dir, "PERSISTOR_TIMEOUT_MS", 10_000)
166
+
167
+ # Can be generated with `Base.encode64(:crypto.strong_rand_bytes(32))` from
168
+ # iex shell or `openssl rand -base64 32` from command line.
169
+ totp_vault_key =
170
+ if totp_vault_key_base64 = get_var_from_path_or_env(config_dir, "TOTP_VAULT_KEY") do
171
+ case Base.decode64(totp_vault_key_base64) do
172
+ {:ok, totp_vault_key} ->
173
+ if byte_size(totp_vault_key) == 32 do
174
+ totp_vault_key
175
+ else
176
+ raise ArgumentError, """
177
+ TOTP_VAULT_KEY must be Base64 encoded 32 bytes, e.g. `openssl rand -base64 32`.
178
+ Got Base64 encoded #{byte_size(totp_vault_key)} bytes.
179
+ More info: https://github.com/plausible/community-edition/wiki/configuration#totp_vault_key
180
+ """
181
+ end
182
+
183
+ :error ->
184
+ raise ArgumentError, """
185
+ TOTP_VAULT_KEY must be Base64 encoded 32 bytes, e.g. `openssl rand -base64 32`
186
+ More info: https://github.com/plausible/community-edition/wiki/configuration#totp_vault_key
187
+ """
188
+ end
189
+ else
190
+ Plug.Crypto.KeyGenerator.generate(secret_key_base, "totp", length: 32, iterations: 100_000)
191
+ end
192
+
193
+ fallback_totp_vault_key =
194
+ if totp_vault_key_base64 = get_var_from_path_or_env(config_dir, "TOTP_VAULT_KEY_FALLBACK") do
195
+ case Base.decode64(totp_vault_key_base64) do
196
+ {:ok, totp_vault_key} ->
197
+ if byte_size(totp_vault_key) == 32 do
198
+ totp_vault_key
199
+ else
200
+ raise ArgumentError, """
201
+ TOTP_VAULT_KEY_FALLBACK must be Base64 encoded 32 bytes, e.g. `openssl rand -base64 32`.
202
+ Got Base64 encoded #{byte_size(totp_vault_key)} bytes.
203
+ More info: https://github.com/plausible/community-edition/wiki/configuration#totp_vault_key
204
+ """
205
+ end
206
+
207
+ :error ->
208
+ raise ArgumentError, """
209
+ TOTP_VAULT_KEY_FALLBACK must be Base64 encoded 32 bytes, e.g. `openssl rand -base64 32`
210
+ More info: https://github.com/plausible/community-edition/wiki/configuration#totp_vault_key
211
+ """
212
+ end
213
+ end
214
+
215
+ config :plausible, Plausible.Auth.TOTP,
216
+ vault_key: totp_vault_key,
217
+ fallback_vault_key: fallback_totp_vault_key || totp_vault_key
218
+
219
+ build_metadata_raw = get_var_from_path_or_env(config_dir, "BUILD_METADATA", "{}")
220
+
221
+ build_metadata =
222
+ case Jason.decode(build_metadata_raw) do
223
+ {:ok, build_metadata} ->
224
+ build_metadata
225
+
226
+ {:error, error} ->
227
+ error = Exception.format(:error, error)
228
+
229
+ Logger.warning("""
230
+ failed to parse $BUILD_METADATA: #{error}
231
+
232
+ $BUILD_METADATA is set to #{build_metadata_raw}\
233
+ """)
234
+
235
+ Logger.warning("falling back to empty build metadata, as if $BUILD_METADATA was set to {}")
236
+
237
+ _fallback = %{}
238
+ end
239
+
240
+ app_host = get_var_from_path_or_env(config_dir, "APP_HOST")
241
+
242
+ runtime_metadata = [
243
+ version: get_in(build_metadata, ["labels", "org.opencontainers.image.version"]),
244
+ commit: get_in(build_metadata, ["labels", "org.opencontainers.image.revision"]),
245
+ created: get_in(build_metadata, ["labels", "org.opencontainers.image.created"]),
246
+ tags: get_in(build_metadata, ["tags"]),
247
+ app_host: app_host
248
+ ]
249
+
250
+ config :plausible, :runtime_metadata, runtime_metadata
251
+
252
+ sentry_dsn = get_var_from_path_or_env(config_dir, "SENTRY_DSN")
253
+ honeycomb_api_key = get_var_from_path_or_env(config_dir, "HONEYCOMB_API_KEY")
254
+ honeycomb_dataset = get_var_from_path_or_env(config_dir, "HONEYCOMB_DATASET")
255
+ paddle_auth_code = get_var_from_path_or_env(config_dir, "PADDLE_VENDOR_AUTH_CODE")
256
+ paddle_vendor_id = get_var_from_path_or_env(config_dir, "PADDLE_VENDOR_ID")
257
+ google_cid = get_var_from_path_or_env(config_dir, "GOOGLE_CLIENT_ID")
258
+ google_secret = get_var_from_path_or_env(config_dir, "GOOGLE_CLIENT_SECRET")
259
+ postmark_api_key = get_var_from_path_or_env(config_dir, "POSTMARK_API_KEY")
260
+ help_scout_app_id = get_var_from_path_or_env(config_dir, "HELP_SCOUT_APP_ID")
261
+ help_scout_app_secret = get_var_from_path_or_env(config_dir, "HELP_SCOUT_APP_SECRET")
262
+ help_scout_signature_key = get_var_from_path_or_env(config_dir, "HELP_SCOUT_SIGNATURE_KEY")
263
+ help_scout_vault_key = get_var_from_path_or_env(config_dir, "HELP_SCOUT_VAULT_KEY")
264
+
265
+ otlp_endpoint =
266
+ get_var_from_path_or_env(config_dir, "OTLP_ENDPOINT", "https://api.honeycomb.io:443")
267
+
268
+ geolite2_country_db =
269
+ get_var_from_path_or_env(
270
+ config_dir,
271
+ "GEOLITE2_COUNTRY_DB",
272
+ Application.app_dir(:plausible, "/priv/geodb/dbip-country.mmdb.gz")
273
+ )
274
+
275
+ ip_geolocation_db = get_var_from_path_or_env(config_dir, "IP_GEOLOCATION_DB", geolite2_country_db)
276
+ geonames_source_file = get_var_from_path_or_env(config_dir, "GEONAMES_SOURCE_FILE")
277
+ maxmind_license_key = get_var_from_path_or_env(config_dir, "MAXMIND_LICENSE_KEY")
278
+ maxmind_edition = get_var_from_path_or_env(config_dir, "MAXMIND_EDITION", "GeoLite2-City")
279
+ data_dir = get_var_from_path_or_env(config_dir, "DATA_DIR")
280
+ persistent_cache_dir = get_var_from_path_or_env(config_dir, "PERSISTENT_CACHE_DIR")
281
+
282
+ # DEFAULT_DATA_DIR comes from the container image, please see our Dockerfile
283
+ data_dir = data_dir || persistent_cache_dir || System.get_env("DEFAULT_DATA_DIR")
284
+ persistent_cache_dir = persistent_cache_dir || data_dir
285
+
286
+ session_transfer_dir =
287
+ if get_bool_from_path_or_env(config_dir, "ENABLE_SESSION_TRANSFER", config_env() == :prod) do
288
+ if persistent_cache_dir do
289
+ Path.join(persistent_cache_dir, "sessions")
290
+ end
291
+ end
292
+
293
+ enable_email_verification =
294
+ get_bool_from_path_or_env(config_dir, "ENABLE_EMAIL_VERIFICATION", false)
295
+
296
+ is_selfhost = get_bool_from_path_or_env(config_dir, "SELFHOST", true)
297
+
298
+ # by default, only registration from invites is enabled in CE
299
+ disable_registration_default =
300
+ if config_env() == :ce do
301
+ "invite_only"
302
+ else
303
+ "false"
304
+ end
305
+
306
+ disable_registration =
307
+ config_dir
308
+ |> get_var_from_path_or_env("DISABLE_REGISTRATION", disable_registration_default)
309
+ |> String.to_existing_atom()
310
+
311
+ if disable_registration not in [true, false, :invite_only] do
312
+ raise "DISABLE_REGISTRATION must be one of `true`, `false`, or `invite_only`. See https://github.com/plausible/community-edition/wiki/configuration#disable_registration"
313
+ end
314
+
315
+ hcaptcha_sitekey = get_var_from_path_or_env(config_dir, "HCAPTCHA_SITEKEY")
316
+ hcaptcha_secret = get_var_from_path_or_env(config_dir, "HCAPTCHA_SECRET")
317
+
318
+ custom_script_name =
319
+ config_dir
320
+ |> get_var_from_path_or_env("CUSTOM_SCRIPT_NAME", "script")
321
+
322
+ disable_cron = get_bool_from_path_or_env(config_dir, "DISABLE_CRON", false)
323
+
324
+ log_failed_login_attempts =
325
+ get_bool_from_path_or_env(config_dir, "LOG_FAILED_LOGIN_ATTEMPTS", false)
326
+
327
+ websocket_url = get_var_from_path_or_env(config_dir, "WEBSOCKET_URL", "")
328
+
329
+ if byte_size(websocket_url) > 0 and
330
+ not String.ends_with?(URI.new!(websocket_url).host, base_url.host) do
331
+ raise """
332
+ Cross-domain websocket authentication is not supported for this server.
333
+
334
+ WEBSOCKET_URL=#{websocket_url} - host must be: '#{base_url.host}',
335
+ because BASE_URL=#{base_url}.
336
+ """
337
+ end
338
+
339
+ secure_cookie_default =
340
+ case base_url.scheme do
341
+ "http" -> "false"
342
+ "https" -> "true"
343
+ end
344
+
345
+ secure_cookie =
346
+ config_dir
347
+ |> get_var_from_path_or_env("SECURE_COOKIE", secure_cookie_default)
348
+ |> String.to_existing_atom()
349
+
350
+ license_key = get_var_from_path_or_env(config_dir, "LICENSE_KEY", "")
351
+
352
+ sso_saml_adapter =
353
+ case get_var_from_path_or_env(config_dir, "SSO_SAML_ADAPTER", "fake") do
354
+ "fake" -> PlausibleWeb.SSO.FakeSAMLAdapter
355
+ "real" -> PlausibleWeb.SSO.RealSAMLAdapter
356
+ end
357
+
358
+ sso_verification_nameservers =
359
+ case get_var_from_path_or_env(config_dir, "SSO_VERIFICATION_NAMESERVERS") do
360
+ nil ->
361
+ nil
362
+
363
+ some when is_binary(some) ->
364
+ some
365
+ |> String.split(",")
366
+ |> Enum.map(fn addr ->
367
+ uri = URI.parse("dns://#{addr}")
368
+ host = uri.host
369
+ port = uri.port || 53
370
+ {:ok, addr} = :inet.parse_address(to_charlist(host))
371
+ {addr, port}
372
+ end)
373
+ end
374
+
375
+ config :plausible,
376
+ environment: env,
377
+ mailer_email: mailer_email,
378
+ super_admin_user_ids: super_admin_user_ids,
379
+ is_selfhost: is_selfhost,
380
+ custom_script_name: custom_script_name,
381
+ log_failed_login_attempts: log_failed_login_attempts,
382
+ license_key: license_key,
383
+ data_dir: data_dir,
384
+ session_transfer_dir: session_transfer_dir,
385
+ sso_saml_adapter: sso_saml_adapter,
386
+ sso_verification_nameservers: sso_verification_nameservers
387
+
388
+ config :plausible, :selfhost,
389
+ enable_email_verification: enable_email_verification,
390
+ disable_registration: disable_registration
391
+
392
+ default_http_opts = [
393
+ transport_options: [max_connections: :infinity],
394
+ protocol_options: [max_request_line_length: 8192, max_header_value_length: 8192]
395
+ ]
396
+
397
+ config :plausible, PlausibleWeb.Endpoint,
398
+ url: [scheme: base_url.scheme, host: base_url.host, path: base_url.path, port: base_url.port],
399
+ http: [port: http_port, ip: listen_ip] ++ default_http_opts,
400
+ secret_key_base: secret_key_base,
401
+ websocket_url: websocket_url,
402
+ secure_cookie: secure_cookie,
403
+ base_url: base_url
404
+
405
+ # maybe enable HTTPS in CE
406
+ if config_env() in [:ce, :ce_dev, :ce_test] do
407
+ if https_port do
408
+ # the following configuration is based on https://wiki.mozilla.org/Security/Server_Side_TLS#Intermediate_compatibility_.28recommended.29
409
+ # except we enforce the cipher and ecc order and only use ciphers with support
410
+ # for ecdsa certificates since that's what certbot generates by default
411
+ https_opts =
412
+ [
413
+ port: https_port,
414
+ ip: listen_ip,
415
+ transport_options: [socket_opts: [log_level: :warning]],
416
+ versions: [:"tlsv1.2", :"tlsv1.3"],
417
+ honor_cipher_order: true,
418
+ honor_ecc_order: true,
419
+ eccs: [:x25519, :secp256r1, :secp384r1],
420
+ supported_groups: [:x25519, :secp256r1, :secp384r1],
421
+ ciphers: [
422
+ # Mozilla recommended cipher suites (TLS 1.3)
423
+ ~c"TLS_AES_128_GCM_SHA256",
424
+ ~c"TLS_AES_256_GCM_SHA384",
425
+ ~c"TLS_CHACHA20_POLY1305_SHA256",
426
+ # Mozilla recommended cipher suites (TLS 1.2)
427
+ ~c"ECDHE-ECDSA-AES128-GCM-SHA256",
428
+ ~c"ECDHE-ECDSA-AES256-GCM-SHA384",
429
+ ~c"ECDHE-ECDSA-CHACHA20-POLY1305"
430
+ ]
431
+ ]
432
+
433
+ https_opts = Config.Reader.merge(default_http_opts, https_opts)
434
+ config :plausible, PlausibleWeb.Endpoint, https: https_opts
435
+
436
+ domain = base_url.host
437
+
438
+ # do stricter checking in CE prod
439
+ if config_env() == :ce do
440
+ domain_is_ip? =
441
+ case :inet.parse_address(to_charlist(domain)) do
442
+ {:ok, _address} -> true
443
+ _other -> false
444
+ end
445
+
446
+ if domain_is_ip? do
447
+ raise ArgumentError, "Cannot generate TLS certificates for IP address #{inspect(domain)}"
448
+ end
449
+
450
+ domain_is_local? = domain == "localhost" or not String.contains?(domain, ".")
451
+
452
+ if domain_is_local? do
453
+ raise ArgumentError,
454
+ "Cannot generate TLS certificates for local domain #{inspect(domain)}"
455
+ end
456
+
457
+ unless http_port == 80 do
458
+ Logger.warning("""
459
+ HTTPS is enabled but the HTTP port is not 80. \
460
+ This will prevent automatic TLS certificate issuance as ACME validates the domain on port 80.\
461
+ """)
462
+ end
463
+ end
464
+
465
+ acme_directory_url =
466
+ get_var_from_path_or_env(
467
+ config_dir,
468
+ "ACME_DIRECTORY_URL",
469
+ "https://acme-v02.api.letsencrypt.org/directory"
470
+ )
471
+
472
+ db_folder = Path.join(data_dir || System.tmp_dir!(), "site_encrypt")
473
+
474
+ email =
475
+ case mailer_email do
476
+ {_, email} -> email
477
+ email when is_binary(email) -> email
478
+ end
479
+
480
+ config :plausible, :selfhost,
481
+ site_encrypt: [
482
+ domain: domain,
483
+ email: email,
484
+ db_folder: db_folder,
485
+ directory_url: acme_directory_url
486
+ ]
487
+ end
488
+ end
489
+
490
+ db_maybe_ipv6 =
491
+ if get_bool_from_path_or_env(config_dir, "ECTO_IPV6") do
492
+ if config_env() in [:ce, :ce_dev, :ce_test] do
493
+ Logger.warning(
494
+ "ECTO_IPV6 is no longer necessary as all TCP connections now try IPv6 automatically with IPv4 fallback"
495
+ )
496
+ end
497
+
498
+ [:inet6]
499
+ else
500
+ []
501
+ end
502
+
503
+ db_url =
504
+ get_var_from_path_or_env(
505
+ config_dir,
506
+ "DATABASE_URL",
507
+ "postgres://postgres:postgres@plausible_db:5432/plausible_db"
508
+ )
509
+
510
+ if db_socket_dir = get_var_from_path_or_env(config_dir, "DATABASE_SOCKET_DIR") do
511
+ Logger.warning("""
512
+ DATABASE_SOCKET_DIR is deprecated, please use DATABASE_URL instead:
513
+
514
+ DATABASE_URL=postgresql://postgres:postgres@#{URI.encode_www_form(db_socket_dir)}/plausible_db
515
+
516
+ or
517
+
518
+ DATABASE_URL=postgresql:///plausible_db?host=#{db_socket_dir}"
519
+
520
+ """)
521
+ end
522
+
523
+ db_cacertfile = get_var_from_path_or_env(config_dir, "DATABASE_CACERTFILE")
524
+ %URI{host: db_host} = db_uri = URI.parse(db_url)
525
+ db_socket_dir? = String.starts_with?(db_host, "%2F") or db_host == ""
526
+
527
+ if db_socket_dir? do
528
+ [database] = String.split(db_uri.path, "/", trim: true)
529
+
530
+ socket_dir =
531
+ if db_host == "" do
532
+ db_host = (db_uri.query || "") |> URI.decode_query() |> Map.get("host")
533
+ db_host || raise ArgumentError, "DATABASE_URL=#{db_url} doesn't include host info"
534
+ else
535
+ URI.decode_www_form(db_host)
536
+ end
537
+
538
+ config :plausible, Plausible.Repo,
539
+ socket_dir: socket_dir,
540
+ database: database
541
+
542
+ if userinfo = db_uri.userinfo do
543
+ [username, password] = String.split(userinfo, ":")
544
+
545
+ config :plausible, Plausible.Repo,
546
+ username: username,
547
+ password: password
548
+ end
549
+ else
550
+ config :plausible, Plausible.Repo, url: db_url
551
+
552
+ unless Enum.empty?(db_maybe_ipv6) do
553
+ config :plausible, Plausible.Repo, socket_options: db_maybe_ipv6
554
+ end
555
+
556
+ db_query = URI.decode_query(db_uri.query || "")
557
+ # https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS
558
+ pg_sslmode = db_query["sslmode"]
559
+
560
+ pg_ssl =
561
+ cond do
562
+ db_cacertfile ->
563
+ [cacertfile: db_cacertfile, verify: :verify_peer]
564
+
565
+ pg_sslmode == "verify-full" ->
566
+ if pg_sslrootcert = db_query["sslrootcert"] do
567
+ [cacertfile: pg_sslrootcert, verify: :verify_peer]
568
+ else
569
+ raise ArgumentError,
570
+ "PostgreSQL SSL mode `sslmode=#{pg_sslmode}` requires a certificate, set it in `sslrootcert`"
571
+ end
572
+
573
+ pg_sslmode == "verify-ca" ->
574
+ [cacerts: :public_key.cacerts_get(), verify: :verify_peer]
575
+
576
+ pg_sslmode == "require" ->
577
+ [verify: :verify_none]
578
+
579
+ pg_sslmode == "disable" ->
580
+ false
581
+
582
+ pg_sslmode ->
583
+ raise ArgumentError,
584
+ "PostgreSQL SSL mode `sslmode=#{pg_sslmode}` is not supported, use `disable`, `require`, `verify-ca` or `verify-full` instead"
585
+
586
+ true ->
587
+ # tls is disabled by default, because in self-hosted docker compose postgres is co-located
588
+ false
589
+ end
590
+
591
+ config :plausible, Plausible.Repo, ssl: pg_ssl
592
+ end
593
+
594
+ sentry_app_version = runtime_metadata[:version] || app_version
595
+
596
+ config :sentry,
597
+ dsn: sentry_dsn,
598
+ environment_name: env,
599
+ release: sentry_app_version,
600
+ tags: %{
601
+ app_version: sentry_app_version,
602
+ app_host: app_host
603
+ },
604
+ client: Plausible.Sentry.Client,
605
+ send_max_attempts: 1,
606
+ before_send: {Plausible.SentryFilter, :before_send}
607
+
608
+ config :plausible, :paddle,
609
+ vendor_auth_code: paddle_auth_code,
610
+ vendor_id: paddle_vendor_id
611
+
612
+ config :plausible, :google,
613
+ client_id: google_cid,
614
+ client_secret: google_secret,
615
+ api_url: "https://www.googleapis.com",
616
+ reporting_api_url: "https://analyticsreporting.googleapis.com"
617
+
618
+ config :plausible, Plausible.HelpScout,
619
+ app_id: help_scout_app_id,
620
+ app_secret: help_scout_app_secret,
621
+ signature_key: help_scout_signature_key,
622
+ vault_key: help_scout_vault_key
623
+
624
+ config :plausible, :imported,
625
+ max_buffer_size: get_int_from_path_or_env(config_dir, "IMPORTED_MAX_BUFFER_SIZE", 10_000)
626
+
627
+ maybe_ch_ipv6 = get_bool_from_path_or_env(config_dir, "ECTO_CH_IPV6", false)
628
+
629
+ if maybe_ch_ipv6 && config_env() in [:ce, :ce_dev, :ce_test] do
630
+ Logger.warning(
631
+ "ECTO_CH_IPV6 is no longer necessary as all TCP connections now try IPv6 automatically with IPv4 fallback"
632
+ )
633
+ end
634
+
635
+ ch_cacertfile = get_var_from_path_or_env(config_dir, "CLICKHOUSE_CACERTFILE")
636
+
637
+ ch_transport_opts = [
638
+ keepalive: true,
639
+ show_econnreset: true,
640
+ inet6: maybe_ch_ipv6
641
+ ]
642
+
643
+ ch_transport_opts =
644
+ if ch_cacertfile do
645
+ ch_transport_opts ++ [cacertfile: ch_cacertfile]
646
+ else
647
+ ch_transport_opts
648
+ end
649
+
650
+ config :plausible, Plausible.ClickhouseRepo,
651
+ queue_target: 500,
652
+ queue_interval: 2000,
653
+ timeout: 15_000,
654
+ url: ch_db_url,
655
+ transport_opts: ch_transport_opts,
656
+ settings: [
657
+ readonly: 1,
658
+ join_algorithm: "direct,parallel_hash,hash",
659
+ # stops queries when :timeout ClickhouseRepo connection :timeout value reached
660
+ cancel_http_readonly_queries_on_client_close: 1,
661
+ # stops queries when they will likely take over 20s
662
+ # NB! when :timeout is overridden to be over 20s,
663
+ # for it to have meaningful effect,
664
+ # this must be overridden as well
665
+ max_execution_time: 20
666
+ ]
667
+
668
+ config :plausible, Plausible.IngestRepo,
669
+ queue_target: 500,
670
+ queue_interval: 2000,
671
+ url: ch_db_url,
672
+ transport_opts: ch_transport_opts,
673
+ flush_interval_ms: ch_flush_interval_ms,
674
+ max_buffer_size: ch_max_buffer_size,
675
+ pool_size: ingest_pool_size,
676
+ settings: [
677
+ materialized_views_ignore_errors: 1
678
+ ],
679
+ table_settings: [
680
+ storage_policy: get_var_from_path_or_env(config_dir, "CLICKHOUSE_DEFAULT_STORAGE_POLICY")
681
+ ]
682
+
683
+ config :plausible, Plausible.AsyncInsertRepo,
684
+ queue_target: 500,
685
+ queue_interval: 2000,
686
+ url: ch_db_url,
687
+ transport_opts: ch_transport_opts,
688
+ pool_size: 1,
689
+ settings: [
690
+ async_insert: 1,
691
+ wait_for_async_insert: 0,
692
+ materialized_views_ignore_errors: 1
693
+ ]
694
+
695
+ config :plausible, Plausible.ImportDeletionRepo,
696
+ queue_target: 500,
697
+ queue_interval: 2000,
698
+ url: ch_db_url,
699
+ transport_opts: ch_transport_opts,
700
+ pool_size: 1
701
+
702
+ config :plausible, Plausible.Ingestion.Persistor,
703
+ backend: persistor_backend,
704
+ backend_percent_enabled: persistor_backend_percent_enabled
705
+
706
+ config :plausible, Plausible.Ingestion.Persistor.Remote,
707
+ url: persistor_url,
708
+ count: persistor_count,
709
+ timeout_ms: persistor_timeout_ms
710
+
711
+ config :ex_money,
712
+ open_exchange_rates_app_id: get_var_from_path_or_env(config_dir, "OPEN_EXCHANGE_RATES_APP_ID"),
713
+ retrieve_every: :timer.hours(24)
714
+
715
+ case mailer_adapter do
716
+ "Bamboo.PostmarkAdapter" ->
717
+ config :plausible, Plausible.Mailer,
718
+ adapter: Bamboo.PostmarkAdapter,
719
+ request_options: [recv_timeout: 10_000],
720
+ api_key: get_var_from_path_or_env(config_dir, "POSTMARK_API_KEY")
721
+
722
+ "Bamboo.MailgunAdapter" ->
723
+ config :plausible, Plausible.Mailer,
724
+ adapter: Bamboo.MailgunAdapter,
725
+ hackney_opts: [recv_timeout: :timer.seconds(10)],
726
+ api_key: get_var_from_path_or_env(config_dir, "MAILGUN_API_KEY"),
727
+ domain: get_var_from_path_or_env(config_dir, "MAILGUN_DOMAIN")
728
+
729
+ if mailgun_base_uri = get_var_from_path_or_env(config_dir, "MAILGUN_BASE_URI") do
730
+ config :plausible, Plausible.Mailer, base_uri: mailgun_base_uri
731
+ end
732
+
733
+ "Bamboo.MandrillAdapter" ->
734
+ config :plausible, Plausible.Mailer,
735
+ adapter: Bamboo.MandrillAdapter,
736
+ hackney_opts: [recv_timeout: :timer.seconds(10)],
737
+ api_key: get_var_from_path_or_env(config_dir, "MANDRILL_API_KEY")
738
+
739
+ "Bamboo.SendGridAdapter" ->
740
+ config :plausible, Plausible.Mailer,
741
+ adapter: Bamboo.SendGridAdapter,
742
+ hackney_opts: [recv_timeout: :timer.seconds(10)],
743
+ api_key: get_var_from_path_or_env(config_dir, "SENDGRID_API_KEY")
744
+
745
+ "Bamboo.SMTPAdapter" ->
746
+ raise ArgumentError, """
747
+ Bamboo.SMTPAdapter is no longer supported as the adapter is no longer maintained.
748
+ Please switch to Bamboo.Mua instead.
749
+ """
750
+
751
+ "Bamboo.Mua" ->
752
+ config :plausible, Plausible.Mailer, adapter: Bamboo.Mua
753
+
754
+ # prevents common problems with Erlang's TLS v1.3
755
+ middlebox_comp_mode =
756
+ get_bool_from_path_or_env(config_dir, "SMTP_MIDDLEBOX_COMP_MODE", false)
757
+
758
+ config :plausible, Plausible.Mailer, ssl: [middlebox_comp_mode: middlebox_comp_mode]
759
+
760
+ if relay = get_var_from_path_or_env(config_dir, "SMTP_HOST_ADDR") do
761
+ port = get_int_from_path_or_env(config_dir, "SMTP_HOST_PORT", 587)
762
+ ssl_enabled = get_bool_from_path_or_env(config_dir, "SMTP_HOST_SSL_ENABLED")
763
+
764
+ protocol =
765
+ cond do
766
+ ssl_enabled -> :ssl
767
+ is_nil(ssl_enabled) and port == 465 -> :ssl
768
+ true -> :tcp
769
+ end
770
+
771
+ config :plausible, Plausible.Mailer, protocol: protocol, relay: relay, port: port
772
+ end
773
+
774
+ username = get_var_from_path_or_env(config_dir, "SMTP_USER_NAME")
775
+ password = get_var_from_path_or_env(config_dir, "SMTP_USER_PWD")
776
+
777
+ cond do
778
+ username && password ->
779
+ config :plausible, Plausible.Mailer, auth: [username: username, password: password]
780
+
781
+ username || password ->
782
+ raise ArgumentError, """
783
+ Both SMTP_USER_NAME and SMTP_USER_PWD must be set for SMTP authentication.
784
+ Please provide values for both environment variables.
785
+ """
786
+
787
+ _both_nil = true ->
788
+ nil
789
+ end
790
+
791
+ "Bamboo.LocalAdapter" ->
792
+ config :plausible, Plausible.Mailer, adapter: Bamboo.LocalAdapter
793
+
794
+ "Bamboo.TestAdapter" ->
795
+ config :plausible, Plausible.Mailer, adapter: Bamboo.TestAdapter
796
+
797
+ _ ->
798
+ raise ArgumentError, """
799
+ Unknown mailer_adapter: #{inspect(mailer_adapter)}
800
+
801
+ Please see https://hexdocs.pm/bamboo/readme.html#available-adapters
802
+ for the list of available adapters that ship with Bamboo
803
+ """
804
+ end
805
+
806
+ base_cron = [
807
+ # Daily at midnight
808
+ {"0 0 * * *", Plausible.Workers.RotateSalts},
809
+ # hourly
810
+ {"0 * * * *", Plausible.Workers.ScheduleEmailReports},
811
+ # hourly
812
+ {"0 * * * *", Plausible.Workers.SendSiteSetupEmails},
813
+ # Daily at midday
814
+ {"0 12 * * *", Plausible.Workers.SendCheckStatsEmails},
815
+ # Every 15 minutes
816
+ {"*/15 * * * *", Plausible.Workers.TrafficChangeNotifier},
817
+ # Every day at 1am
818
+ {"0 1 * * *", Plausible.Workers.CleanInvitations},
819
+ # Every 2 hours
820
+ {"30 */2 * * *", Plausible.Workers.CleanUserSessions},
821
+ # Every 2 hours
822
+ {"0 */2 * * *", Plausible.Workers.ExpireDomainChangeTransitions},
823
+ # Daily at midnight
824
+ {"0 0 * * *", Plausible.Workers.LocationsSync}
825
+ ]
826
+
827
+ cloud_cron = [
828
+ # Daily at midday
829
+ {"0 12 * * *", Plausible.Workers.SendTrialNotifications},
830
+ # Daily at 14
831
+ {"0 14 * * *", Plausible.Workers.CheckUsage},
832
+ # Daily at 15
833
+ {"0 15 * * *", Plausible.Workers.NotifyAnnualRenewal},
834
+ # Every midnight
835
+ {"0 0 * * *", Plausible.Workers.LockSites},
836
+ # Daily at 8
837
+ {"0 8 * * *", Plausible.Workers.AcceptTrafficUntil},
838
+ # First sunday of the month, 4:00 UTC
839
+ {"0 4 1-7 * SUN", Plausible.Workers.ClickhouseCleanSites},
840
+ # Daily at 4:00 UTC
841
+ {"0 4 * * *", Plausible.Workers.SetLegacyTimeOnPageCutoff},
842
+ # Daily at 2:00 UTC
843
+ {"0 2 * * *", Plausible.Workers.ScoreTrialProspects}
844
+ ]
845
+
846
+ crontab = if(is_selfhost, do: base_cron, else: base_cron ++ cloud_cron)
847
+
848
+ base_queues = [
849
+ rotate_salts: 1,
850
+ schedule_email_reports: 1,
851
+ send_email_reports: 1,
852
+ spike_notifications: 1,
853
+ check_stats_emails: 1,
854
+ site_setup_emails: 1,
855
+ clean_invitations: 1,
856
+ clean_user_sessions: 1,
857
+ analytics_imports: 1,
858
+ analytics_exports: 1,
859
+ notify_exported_analytics: 1,
860
+ domain_change_transition: 1,
861
+ check_accept_traffic_until: 1,
862
+ clickhouse_clean_sites: 1,
863
+ locations_sync: 1
864
+ ]
865
+
866
+ cloud_queues = [
867
+ trial_notification_emails: 1,
868
+ check_usage: 1,
869
+ notify_annual_renewal: 1,
870
+ lock_sites: 1,
871
+ legacy_time_on_page_cutoff: 1,
872
+ purge_cdn_cache: 1,
873
+ sso_domain_ownership_verification: 32,
874
+ score_trial_prospects: 1
875
+ ]
876
+
877
+ queues = if(is_selfhost, do: base_queues, else: base_queues ++ cloud_queues)
878
+ cron_enabled = !disable_cron
879
+
880
+ thirty_days_in_seconds = 60 * 60 * 24 * 30
881
+
882
+ if config_env() in [:prod, :ce, :load] do
883
+ config :plausible, Oban,
884
+ repo: Plausible.Repo,
885
+ plugins: [
886
+ # Keep 30 days history
887
+ {Oban.Plugins.Pruner, max_age: thirty_days_in_seconds},
888
+ {Oban.Plugins.Cron, crontab: if(cron_enabled, do: crontab, else: [])},
889
+ # Rescue orphaned jobs after 2 hours
890
+ {Oban.Plugins.Lifeline, rescue_after: :timer.minutes(120)},
891
+ # Daily at 1am
892
+ {Oban.Plugins.Reindexer, schedule: "0 1 * * *"}
893
+ ],
894
+ queues: if(cron_enabled, do: queues, else: []),
895
+ peer: if(cron_enabled, do: Oban.Peers.Postgres, else: false)
896
+ else
897
+ config :plausible, Oban,
898
+ repo: Plausible.Repo,
899
+ queues: queues
900
+ end
901
+
902
+ config :plausible, :hcaptcha,
903
+ sitekey: hcaptcha_sitekey,
904
+ secret: hcaptcha_secret
905
+
906
+ nolt_sso_secret = get_var_from_path_or_env(config_dir, "NOLT_SSO_SECRET")
907
+ config :joken, default_signer: nolt_sso_secret
908
+
909
+ config :plausible, Plausible.Sentry.Client,
910
+ finch_request_opts: [
911
+ pool_timeout: get_int_from_path_or_env(config_dir, "SENTRY_FINCH_POOL_TIMEOUT", 5000),
912
+ receive_timeout: get_int_from_path_or_env(config_dir, "SENTRY_FINCH_RECEIVE_TIMEOUT", 15000)
913
+ ]
914
+
915
+ config :plausible, Plausible.Workers.PurgeCDNCache,
916
+ pullzone_id: get_var_from_path_or_env(config_dir, "BUNNY_PULLZONE_ID"),
917
+ api_key: get_var_from_path_or_env(config_dir, "BUNNY_API_KEY")
918
+
919
+ config :ref_inspector,
920
+ init: {Plausible.Release, :configure_ref_inspector}
921
+
922
+ config :ua_inspector,
923
+ init: {Plausible.Release, :configure_ua_inspector}
924
+
925
+ geo_opts =
926
+ cond do
927
+ maxmind_license_key ->
928
+ [
929
+ license_key: maxmind_license_key,
930
+ edition: maxmind_edition,
931
+ cache_dir: persistent_cache_dir,
932
+ async: true
933
+ ]
934
+
935
+ ip_geolocation_db ->
936
+ [path: ip_geolocation_db]
937
+
938
+ true ->
939
+ raise """
940
+ Missing geolocation database configuration.
941
+
942
+ Please set the IP_GEOLOCATION_DB environment value to the location of
943
+ your IP geolocation .mmdb file:
944
+
945
+ IP_GEOLOCATION_DB=/etc/plausible/dbip-city.mmdb
946
+
947
+ Or authenticate with MaxMind by
948
+ configuring MAXMIND_LICENSE_KEY and (optionally) MAXMIND_EDITION environment
949
+ variables:
950
+
951
+ MAXMIND_LICENSE_KEY=LNpsJCCKPis6XvBP
952
+ MAXMIND_EDITION=GeoLite2-City # this is the default edition
953
+
954
+ """
955
+ end
956
+
957
+ config :plausible, Plausible.Geo, geo_opts
958
+
959
+ if geonames_source_file do
960
+ config :location, :geonames_source_file, geonames_source_file
961
+ end
962
+
963
+ if honeycomb_api_key && honeycomb_dataset do
964
+ config :opentelemetry,
965
+ resource: Plausible.OpenTelemetry.resource_attributes(runtime_metadata),
966
+ span_processor: :batch,
967
+ traces_exporter: :otlp
968
+
969
+ config :opentelemetry_exporter,
970
+ otlp_protocol: :grpc,
971
+ otlp_endpoint: otlp_endpoint,
972
+ otlp_headers: [
973
+ {"x-honeycomb-team", honeycomb_api_key},
974
+ {"x-honeycomb-dataset", honeycomb_dataset}
975
+ ]
976
+ else
977
+ config :opentelemetry,
978
+ sampler: :always_off,
979
+ traces_exporter: :none
980
+ end
981
+
982
+ beam_metrics_enabled? = get_bool_from_path_or_env(config_dir, "BEAM_METRICS_ENABLED", false)
983
+
984
+ if beam_metrics_enabled? do
985
+ beam_metrics_interval = get_int_from_path_or_env(config_dir, "BEAM_METRICS_INTERVAL_MS", 5_000)
986
+
987
+ beam_metrics_otlp_endpoint =
988
+ get_var_from_path_or_env(config_dir, "OTEL_EXPORTER_OTLP_ENDPOINT") || otlp_endpoint
989
+
990
+ config :opentelemetry_experimental,
991
+ readers: [
992
+ %{
993
+ module: :otel_metric_reader,
994
+ config: %{
995
+ export_interval_ms: beam_metrics_interval,
996
+ exporter:
997
+ {:otel_exporter_metrics_otlp,
998
+ %{
999
+ endpoints: [beam_metrics_otlp_endpoint]
1000
+ }}
1001
+ }
1002
+ }
1003
+ ]
1004
+ end
1005
+
1006
+ config :tzdata, :data_dir, Path.join(persistent_cache_dir || System.tmp_dir!(), "tzdata_data")
1007
+
1008
+ promex_disabled? = get_bool_from_path_or_env(config_dir, "PROMEX_DISABLED", true)
1009
+
1010
+ config :plausible, Plausible.PromEx,
1011
+ disabled: promex_disabled?,
1012
+ manual_metrics_start_delay: :no_delay,
1013
+ drop_metrics_groups: [],
1014
+ grafana: :disabled,
1015
+ metrics_server: :disabled
1016
+
1017
+ config :plausible, Plausible.InstallationSupport.BrowserlessConfig,
1018
+ token: get_var_from_path_or_env(config_dir, "BROWSERLESS_TOKEN", "dummy_token"),
1019
+ endpoint: get_var_from_path_or_env(config_dir, "BROWSERLESS_ENDPOINT", "http://0.0.0.0:3000")
1020
+
1021
+ if not is_selfhost do
1022
+ site_default_ingest_threshold =
1023
+ case System.get_env("SITE_DEFAULT_INGEST_THRESHOLD") do
1024
+ threshold when byte_size(threshold) > 0 ->
1025
+ {value, ""} = Integer.parse(threshold)
1026
+ value
1027
+
1028
+ _ ->
1029
+ nil
1030
+ end
1031
+
1032
+ config :plausible, Plausible.Site, default_ingest_threshold: site_default_ingest_threshold
1033
+ end
1034
+
1035
+ s3_disabled? = get_bool_from_path_or_env(config_dir, "S3_DISABLED", true)
1036
+
1037
+ unless s3_disabled? do
1038
+ s3_env = [
1039
+ %{
1040
+ name: "S3_ACCESS_KEY_ID",
1041
+ example: "AKIAIOSFODNN7EXAMPLE"
1042
+ },
1043
+ %{
1044
+ name: "S3_SECRET_ACCESS_KEY",
1045
+ example: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
1046
+ },
1047
+ %{
1048
+ name: "S3_REGION",
1049
+ example: "us-east-1"
1050
+ },
1051
+ %{
1052
+ name: "S3_ENDPOINT",
1053
+ example: "https://<ACCOUNT_ID>.r2.cloudflarestorage.com"
1054
+ },
1055
+ %{
1056
+ name: "S3_EXPORTS_BUCKET",
1057
+ example: "my-csv-exports-bucket"
1058
+ },
1059
+ %{
1060
+ name: "S3_IMPORTS_BUCKET",
1061
+ example: "my-csv-imports-bucket"
1062
+ }
1063
+ ]
1064
+
1065
+ s3_env =
1066
+ Enum.map(s3_env, fn var ->
1067
+ Map.put(var, :value, get_var_from_path_or_env(config_dir, var.name))
1068
+ end)
1069
+
1070
+ s3_missing_env = Enum.filter(s3_env, &is_nil(&1.value))
1071
+
1072
+ unless s3_missing_env == [] do
1073
+ raise ArgumentError, """
1074
+ Missing S3 configuration. Please set #{s3_missing_env |> Enum.map(& &1.name) |> Enum.join(", ")} environment variable(s):
1075
+
1076
+ #{s3_missing_env |> Enum.map(fn %{name: name, example: example} -> "\t#{name}=#{example}" end) |> Enum.join("\n")}
1077
+ """
1078
+ end
1079
+
1080
+ s3_env_value = fn name ->
1081
+ s3_env |> Enum.find(&(&1.name == name)) |> Map.fetch!(:value)
1082
+ end
1083
+
1084
+ config :ex_aws,
1085
+ http_client: Plausible.S3.Client,
1086
+ access_key_id: s3_env_value.("S3_ACCESS_KEY_ID"),
1087
+ secret_access_key: s3_env_value.("S3_SECRET_ACCESS_KEY"),
1088
+ region: s3_env_value.("S3_REGION")
1089
+
1090
+ %URI{scheme: s3_scheme, host: s3_host, port: s3_port} = URI.parse(s3_env_value.("S3_ENDPOINT"))
1091
+
1092
+ config :ex_aws, :s3,
1093
+ scheme: s3_scheme <> "://",
1094
+ host: s3_host,
1095
+ port: s3_port
1096
+
1097
+ config :plausible, Plausible.S3,
1098
+ exports_bucket: s3_env_value.("S3_EXPORTS_BUCKET"),
1099
+ imports_bucket: s3_env_value.("S3_IMPORTS_BUCKET")
1100
+ end
1101
+
1102
+ config :plausible, Plausible.Cache.Adapter, sessions: [partitions: 100]