mirror of https://github.com/requarks/wiki
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
409 lines
17 KiB
409 lines
17 KiB
import fs from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import { load } from 'js-yaml'
|
|
import { htmlEscape, parseModuleProps } from '../helpers/common.ts'
|
|
import type { ModuleProp } from '../helpers/common.ts'
|
|
|
|
/**
|
|
* The two places a provider's markup can be asked to go.
|
|
*
|
|
* `head` is where nearly everything belongs — a tracking tag is loaded as early as possible so that
|
|
* it sees the page load it is meant to be counting. `bodyStart` exists for the one thing that cannot
|
|
* go in the head: Google Tag Manager's `<noscript>` fallback, which is an `<iframe>` and so has to be
|
|
* in the body, immediately after it opens.
|
|
*/
|
|
const SLOTS = ['head', 'bodyStart'] as const
|
|
|
|
type Slot = (typeof SLOTS)[number]
|
|
|
|
/**
|
|
* A placeholder in a provider's code template: `{{<context>:<prop>}}`.
|
|
*
|
|
* The context is how the value is written into the snippet, and it is declared in the template rather
|
|
* than on the prop because the same value goes into different places — a Matomo server URL is a
|
|
* JavaScript string in the tracker and an attribute in the `<noscript>` pixel beside it, and those
|
|
* escape differently. See `resolvePlaceholder`.
|
|
*/
|
|
const PLACEHOLDER = /\{\{(js|attr|num|bool):([A-Za-z0-9_]+)\}\}/g
|
|
|
|
/**
|
|
* What a character becomes inside a JavaScript string literal in an inline `<script>`.
|
|
*
|
|
* The quotes and the backslash are the obvious half — an apostrophe in a site name would otherwise
|
|
* end the string it is in. `<`, `>` and `&` are the half that is easy to miss: the contents of a
|
|
* `<script>` element are not parsed for entities, but the HTML parser still ends the element at
|
|
* `</script`, and `<!--` inside one changes how the rest of it is read. Escaping the three characters
|
|
* as `\uXXXX` keeps the value from ever meaning anything to the parser, while reading back as itself
|
|
* in JavaScript. `\u2028` and `\u2029` are line terminators to a JavaScript parser and nothing to
|
|
* anybody else, so an unescaped one is a syntax error nobody can see.
|
|
*/
|
|
const JS_ESCAPES: Record<string, string> = {
|
|
'\\': '\\\\',
|
|
"'": "\\'",
|
|
'"': '\\"',
|
|
'`': '\\`',
|
|
'\n': '\\n',
|
|
'\r': '\\r',
|
|
'\t': '\\t',
|
|
'<': '\\u003C',
|
|
'>': '\\u003E',
|
|
'&': '\\u0026',
|
|
'\u2028': '\\u2028',
|
|
'\u2029': '\\u2029'
|
|
}
|
|
|
|
const JS_ESCAPE_PATTERN = /[\\'"`\n\r\t<>&\u2028\u2029]/g
|
|
|
|
/** An analytics module, as declared by its `definition.yml` and `code.yml`. */
|
|
export interface AnalyticsDefinition {
|
|
/** Directory name under `modules/analytics`, and how a site's config addresses the provider. */
|
|
key: string
|
|
title: string
|
|
description: string
|
|
/** The provider's own site, linked from the panel beside its configuration. */
|
|
website: string
|
|
icon: string
|
|
props: Record<string, ModuleProp>
|
|
/**
|
|
* The props that must hold a value before this provider renders anything at all.
|
|
*
|
|
* A tag with an empty tracking ID in it is not a tag that collects less — it is a script that
|
|
* reports to nothing, or to whatever account an empty ID happens to resolve to at the other end. So
|
|
* an enabled provider missing one of these is skipped, and the admin area says which field is
|
|
* empty rather than letting the wiki serve a broken snippet.
|
|
*
|
|
* A prop left out of this list may legitimately be empty, e.g. an Elastic APM environment name.
|
|
*/
|
|
requires: string[]
|
|
/** The markup each slot contributes, before any value is substituted into it. */
|
|
code: Record<Slot, string>
|
|
}
|
|
|
|
/** One provider as a site has it configured, which is what the admin area edits. */
|
|
export interface AnalyticsProvider {
|
|
key: string
|
|
title: string
|
|
description: string
|
|
website: string
|
|
icon: string
|
|
isEnabled: boolean
|
|
props: Record<string, ModuleProp>
|
|
config: Record<string, any>
|
|
requires: string[]
|
|
}
|
|
|
|
/** What a client may change about one provider. */
|
|
export interface AnalyticsProviderInput {
|
|
key: string
|
|
isEnabled?: boolean
|
|
config?: Record<string, any>
|
|
}
|
|
|
|
/** The markup a site contributes to every document it serves. See `injectionsFor`. */
|
|
export type AnalyticsInjections = Record<Slot, string>
|
|
|
|
/** A site with nothing configured, which is every site until somebody turns a provider on. */
|
|
const NO_INJECTIONS: AnalyticsInjections = { head: '', bodyStart: '' }
|
|
|
|
/**
|
|
* Analytics model
|
|
*
|
|
* An analytics provider is one module from `modules/analytics/<key>/` turned on for one site. The
|
|
* module is two files: a `definition.yml` declaring what it is and what it needs configured, and a
|
|
* `code.yml` holding the markup it contributes, with `{{context:prop}}` placeholders for the values.
|
|
* There is no third file — unlike a storage module a provider has no code to run here, since the
|
|
* whole of what it does happens in the reader's browser.
|
|
*
|
|
* **The markup is served, not injected by the app.** It goes into the document the server hands out
|
|
* (`helpers/appShell.ts`), so it is in the HTML of every response including the one a client that
|
|
* never runs JavaScript receives. That is deliberate and is the point: several providers verify an
|
|
* installation by fetching the page and looking for their tag, which a script the SPA adds after boot
|
|
* would fail — and a tag added after boot also misses the load it exists to measure.
|
|
*
|
|
* **Configuration lives in the site's own config blob**, under `analytics.providers`, rather than in
|
|
* a table of its own. Every request that produces a document needs it, and the site configurations
|
|
* are already in memory on every instance (`WIKI.sites`) and already reloaded across the cluster when
|
|
* one changes — so an analytics tag costs no query, and a saved change applies to the next request.
|
|
*
|
|
* **`manage:sites` is the trust boundary**, the same as for the theme's head and body injections
|
|
* beside which this markup lands. The escaping here is about correctness rather than privilege: a
|
|
* value has to stay inside the string or the attribute it was written into, so that an apostrophe in
|
|
* a service name cannot break every script on the page.
|
|
*/
|
|
class Analytics {
|
|
/** Definitions read from disk, refreshed by `refreshFromDisk()`. */
|
|
definitions: AnalyticsDefinition[] = []
|
|
|
|
/**
|
|
* Load the analytics module definitions from disk.
|
|
*
|
|
* One directory per provider, each with both files. A directory missing either is skipped with a
|
|
* warning rather than taking the rest down with it: a provider that cannot be read is one provider
|
|
* nobody can turn on, where an empty list would silently stop every site's existing tags.
|
|
*/
|
|
async refreshFromDisk(): Promise<void> {
|
|
const analyticsPath = path.join(WIKI.SERVERPATH, 'modules/analytics')
|
|
const definitions: AnalyticsDefinition[] = []
|
|
try {
|
|
for (const dir of await fs.readdir(analyticsPath)) {
|
|
try {
|
|
const parsed = load(
|
|
await fs.readFile(path.join(analyticsPath, dir, 'definition.yml'), 'utf8')
|
|
) as Record<string, any>
|
|
const code = load(
|
|
await fs.readFile(path.join(analyticsPath, dir, 'code.yml'), 'utf8')
|
|
) as Record<string, any>
|
|
definitions.push({
|
|
// -> The directory name is the key, as it is for every other module type
|
|
key: dir,
|
|
title: parsed.title ?? dir,
|
|
description: parsed.description ?? '',
|
|
website: parsed.website ?? '',
|
|
icon: parsed.icon ?? '',
|
|
// -> Props carry a display `order`, applied once here so that every consumer reads them
|
|
// in the order the module meant them to be shown in
|
|
props: Object.fromEntries(
|
|
Object.entries(parseModuleProps(parsed.props ?? {})).sort(
|
|
([, a], [, b]) => a.order - b.order
|
|
)
|
|
),
|
|
requires: parsed.requires ?? [],
|
|
code: {
|
|
head: typeof code?.head === 'string' ? code.head.trim() : '',
|
|
bodyStart: typeof code?.bodyStart === 'string' ? code.bodyStart.trim() : ''
|
|
}
|
|
})
|
|
} catch (err: any) {
|
|
WIKI.logger.warn(`Skipping analytics module ${dir}: ${err.message}`)
|
|
}
|
|
}
|
|
this.definitions = definitions.sort((a, b) => a.title.localeCompare(b.title))
|
|
WIKI.logger.info(`Found ${this.definitions.length} analytics modules [ OK ]`)
|
|
} catch (err: any) {
|
|
this.definitions = []
|
|
WIKI.logger.error(
|
|
`Could not read the analytics module definitions at ${analyticsPath} [ FAILED ]`
|
|
)
|
|
WIKI.logger.error(err.message)
|
|
}
|
|
}
|
|
|
|
/** A single definition, or null when nothing on disk declares that key. */
|
|
getDefinition(key: string): AnalyticsDefinition | null {
|
|
return this.definitions.find((d) => d.key === key) ?? null
|
|
}
|
|
|
|
/** What a site has stored, keyed by module. Empty for a site that has never saved this screen. */
|
|
storedProviders(siteId: string): Record<string, { isEnabled?: boolean; config?: any }> {
|
|
return WIKI.sites[siteId]?.config?.analytics?.providers ?? {}
|
|
}
|
|
|
|
/**
|
|
* Every provider installed on disk, with what this site has configured for it merged in.
|
|
*
|
|
* Driven by the definitions rather than by what is stored, so a provider that has never been
|
|
* touched is listed with its defaults and one dropped from disk simply stops appearing — its stored
|
|
* values stay in the site config, harmless and ignored, until the screen is next saved.
|
|
*/
|
|
getSiteProviders(siteId: string): AnalyticsProvider[] {
|
|
const stored = this.storedProviders(siteId)
|
|
return this.definitions.map((definition) => ({
|
|
key: definition.key,
|
|
title: definition.title,
|
|
description: definition.description,
|
|
website: definition.website,
|
|
icon: definition.icon,
|
|
requires: definition.requires,
|
|
isEnabled: stored[definition.key]?.isEnabled === true,
|
|
props: definition.props,
|
|
config: this.buildConfig(definition.key, {}, stored[definition.key]?.config ?? {})
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* Merge incoming config values onto the ones already stored, keeping only what the module declares.
|
|
*
|
|
* Unknown keys are dropped rather than refused, so a provider that loses a prop does not make the
|
|
* screen unsaveable. Read-only props are never taken from the client — the same rule the storage
|
|
* and authentication forms follow.
|
|
*
|
|
* There is no sensitive-prop handling here, and there is no `maskSensitiveProps` on the route
|
|
* either: every value on this screen is rendered into a document served to the public, so a prop
|
|
* that had to be kept out of a browser could not be used by a provider in the first place.
|
|
*/
|
|
buildConfig(
|
|
moduleKey: string,
|
|
incoming: Record<string, any> = {},
|
|
existing: Record<string, any> = {}
|
|
): Record<string, any> {
|
|
const props = this.getDefinition(moduleKey)?.props ?? {}
|
|
const config: Record<string, any> = {}
|
|
for (const [key, prop] of Object.entries(props)) {
|
|
const current = existing[key] !== undefined ? existing[key] : prop.default
|
|
config[key] = prop.readOnly || incoming[key] === undefined ? current : incoming[key]
|
|
}
|
|
return config
|
|
}
|
|
|
|
/**
|
|
* Check an incoming provider patch against what the module declares.
|
|
*
|
|
* The props are a runtime declaration read from a YAML file, so no JSON Schema can cover them.
|
|
*
|
|
* @returns The reason it is invalid, or null when it is fine
|
|
*/
|
|
validateProvider(patch: AnalyticsProviderInput): string | null {
|
|
const definition = this.getDefinition(patch.key)
|
|
if (!definition) {
|
|
return `There is no analytics provider called "${patch.key}".`
|
|
}
|
|
for (const [key, value] of Object.entries(patch.config ?? {})) {
|
|
const prop = definition.props[key]
|
|
if (!prop || prop.readOnly || value === undefined) {
|
|
continue
|
|
}
|
|
if (prop.enum) {
|
|
// -> Enum entries are declared as `value` or `value|label`
|
|
const allowed = prop.enum.map((entry) => entry.split('|')[0])
|
|
if (!allowed.includes(`${value}`)) {
|
|
return `"${value}" is not a valid value for ${prop.title}.`
|
|
}
|
|
continue
|
|
}
|
|
switch (prop.type) {
|
|
case 'boolean':
|
|
if (typeof value !== 'boolean') {
|
|
return `${prop.title} must be true or false.`
|
|
}
|
|
break
|
|
case 'number':
|
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
return `${prop.title} must be a number.`
|
|
}
|
|
break
|
|
default:
|
|
if (typeof value !== 'string') {
|
|
return `${prop.title} must be a string.`
|
|
}
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Write the providers a client sent, leaving the ones it did not mention alone.
|
|
*
|
|
* One write for the lot: this goes into the site's config blob, and `sites.updateSite` is what
|
|
* reloads the cache on every instance and drops the app shell fragments — without which a tag
|
|
* turned on would not appear until the next restart.
|
|
*/
|
|
async updateSiteProviders(siteId: string, patches: AnalyticsProviderInput[]): Promise<number> {
|
|
const stored = this.storedProviders(siteId)
|
|
const providers: Record<string, { isEnabled: boolean; config: Record<string, any> }> = {}
|
|
for (const patch of patches) {
|
|
providers[patch.key] = {
|
|
isEnabled: patch.isEnabled ?? stored[patch.key]?.isEnabled === true,
|
|
config: this.buildConfig(patch.key, patch.config ?? {}, stored[patch.key]?.config ?? {})
|
|
}
|
|
}
|
|
if (Object.keys(providers).length < 1) {
|
|
return 0
|
|
}
|
|
await WIKI.models.sites.updateSite(siteId, { config: { analytics: { providers } } })
|
|
return Object.keys(providers).length
|
|
}
|
|
|
|
/**
|
|
* The markup every document this site serves carries, one string per slot.
|
|
*
|
|
* Read off the cached site config, so this costs nothing and is current on every instance the
|
|
* moment the screen is saved — the same reasoning as the theme injections it lands beside. Nothing
|
|
* here varies by requester, by page or by session, which is why it can be built per request without
|
|
* a cache of its own and why it is not part of the fragments that are cached per URL.
|
|
*/
|
|
injectionsFor(siteId: string | undefined): AnalyticsInjections {
|
|
if (!siteId) {
|
|
return NO_INJECTIONS
|
|
}
|
|
const stored = this.storedProviders(siteId)
|
|
const parts: Record<Slot, string[]> = { head: [], bodyStart: [] }
|
|
for (const definition of this.definitions) {
|
|
if (stored[definition.key]?.isEnabled !== true) {
|
|
continue
|
|
}
|
|
const config = this.buildConfig(definition.key, {}, stored[definition.key]?.config ?? {})
|
|
if (this.missingRequired(definition, config).length > 0) {
|
|
continue
|
|
}
|
|
for (const slot of SLOTS) {
|
|
const rendered = renderTemplate(definition.code[slot], config)
|
|
if (rendered !== null && rendered.length > 0) {
|
|
parts[slot].push(rendered)
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
head: parts.head.join('\n '),
|
|
bodyStart: parts.bodyStart.join('\n')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Which of a provider's required props are empty, in declaration order.
|
|
*
|
|
* The same question the admin area asks of the form in front of it, so that "Google Analytics is
|
|
* enabled but has no measurement ID" is something an administrator reads on the screen rather than
|
|
* discovering from a tag that never fires.
|
|
*/
|
|
missingRequired(definition: AnalyticsDefinition, config: Record<string, any>): string[] {
|
|
return definition.requires.filter((key) => {
|
|
const value = config[key]
|
|
return value === undefined || value === null || `${value}`.trim().length < 1
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A value as it is written into a JavaScript string literal. See `JS_ESCAPES`.
|
|
*/
|
|
function jsEscape(value: string): string {
|
|
return value.replace(JS_ESCAPE_PATTERN, (char) => JS_ESCAPES[char]!)
|
|
}
|
|
|
|
/**
|
|
* Substitute a provider's configured values into one of its templates.
|
|
*
|
|
* @returns The markup, or null where a placeholder could not be resolved to something that would
|
|
* parse — a `num` slot is a bare numeric literal, so a value that is not a number would produce a
|
|
* syntax error taking every other script on the page with it. A template that resolves to nothing
|
|
* is skipped rather than emitted broken.
|
|
*/
|
|
function renderTemplate(template: string, config: Record<string, any>): string | null {
|
|
if (!template) {
|
|
return ''
|
|
}
|
|
let usable = true
|
|
const rendered = template.replace(PLACEHOLDER, (_match, context: string, key: string) => {
|
|
const value = config[key]
|
|
switch (context) {
|
|
case 'num': {
|
|
const num = Number(value)
|
|
if (!Number.isFinite(num)) {
|
|
usable = false
|
|
return '0'
|
|
}
|
|
return `${num}`
|
|
}
|
|
case 'bool':
|
|
return value === true ? 'true' : 'false'
|
|
case 'attr':
|
|
return htmlEscape(`${value ?? ''}`)
|
|
default:
|
|
return jsEscape(`${value ?? ''}`)
|
|
}
|
|
})
|
|
return usable ? rendered : null
|
|
}
|
|
|
|
export const analytics = new Analytics()
|