Upload folder using huggingface_hub (part 2)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- assets/js/dashboard.tsx +112 -0
- assets/js/dashboard/util/filter-text.tsx +78 -0
- assets/js/dashboard/util/filters.js +311 -0
- assets/js/dashboard/util/filters.test.ts +82 -0
- assets/js/dashboard/util/goals.ts +34 -0
- assets/js/dashboard/util/money.ts +23 -0
- assets/js/dashboard/util/number-formatter.test.ts +33 -0
- assets/js/dashboard/util/number-formatter.ts +84 -0
- assets/js/dashboard/util/realtime-update-timer.js +8 -0
- assets/js/dashboard/util/seconds-since-last-load.js +15 -0
- assets/js/dashboard/util/storage.js +37 -0
- assets/js/dashboard/util/tooltip.tsx +109 -0
- assets/js/dashboard/util/url-search-params-v1.ts +121 -0
- assets/js/dashboard/util/url-search-params-v2.test.ts +204 -0
- assets/js/dashboard/util/url-search-params-v2.ts +83 -0
- assets/js/dashboard/util/url-search-params.test.ts +331 -0
- assets/js/dashboard/util/url-search-params.ts +376 -0
- assets/js/dashboard/util/url.test.ts +105 -0
- assets/js/dashboard/util/url.ts +86 -0
- assets/js/embed.content.js +16 -0
- assets/js/embed.host.js +24 -0
- assets/js/liveview/combo-box.js +98 -0
- assets/js/liveview/dropdown.js +20 -0
- assets/js/liveview/live_socket.js +78 -0
- assets/js/liveview/phx_events.js +20 -0
- assets/js/polyfills/closest.js +14 -0
- assets/js/types/globals.d.ts +1 -0
- assets/js/types/query-api.d.ts +216 -0
- assets/package-lock.json +0 -0
- assets/package.json +79 -0
- assets/test-utils/app-context-providers.tsx +117 -0
- assets/test-utils/extend-expect.ts +1 -0
- assets/test-utils/index.ts +23 -0
- assets/test-utils/jsdom-mocks.ts +5 -0
- assets/test-utils/mock-api.ts +92 -0
- assets/test-utils/reset-state.ts +11 -0
- assets/tsconfig.json +14 -0
- config/.env.dev +38 -0
- config/.env.e2e_test +39 -0
- config/.env.load +37 -0
- config/.env.test +31 -0
- config/ce.exs +22 -0
- config/ce_dev.exs +11 -0
- config/ce_test.exs +3 -0
- config/config.exs +89 -0
- config/dev.exs +41 -0
- config/e2e_test.exs +20 -0
- config/load.exs +17 -0
- config/prod.exs +7 -0
- 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]
|