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.
wiki/backend/helpers/common.ts

321 lines
10 KiB

import { isNil, isPlainObject } from 'es-toolkit/predicate'
import { startCase } from 'es-toolkit/string'
import crypto from 'node:crypto'
import mime from 'mime'
import fs from 'node:fs'
import type { FastifyReply } from 'fastify'
export interface Deferred<T = void> {
resolve: (value: T) => void
reject: (reason?: unknown) => void
promise: Promise<T>
}
/** Seconds in each unit a duration setting may be written with. See `durationToSeconds`. */
const DURATION_UNIT_SECONDS = {
s: 1,
m: 60,
h: 3600,
d: 86400,
w: 604800,
y: 31536000
} as const
type DurationUnit = keyof typeof DURATION_UNIT_SECONDS
/* eslint-disable promise/param-names */
export function createDeferred<T = void>(): Deferred<T> {
let result: Promise<T> | undefined
let resolve: ((value: T | PromiseLike<T>) => void) | undefined
let reject: ((reason?: unknown) => void) | undefined
return {
resolve: function (value: T) {
if (resolve) {
resolve(value)
} else {
result =
result ||
new Promise<T>(function (r) {
r(value)
})
}
},
reject: function (reason?: unknown) {
if (reject) {
reject(reason)
} else {
result =
result ||
new Promise<T>(function (x, j) {
j(reason)
})
}
},
promise: new Promise<T>(function (r, j) {
if (result) {
r(result)
} else {
resolve = r
reject = j
}
})
}
}
/**
* Decode a tree path
*
* @param str String to decode
* @returns Decoded tree path
*/
export function decodeTreePath(str?: string | null): string | undefined {
return str?.replaceAll('.', '/')
}
/**
* Encode a tree path
*
* @param str String to encode
* @returns Encoded tree path
*/
export function encodeTreePath(str?: string | null): string {
return str?.toLowerCase()?.replaceAll('/', '.') || ''
}
/**
* Reduce a page path to the single form it is stored, addressed and looked up under.
*
* A path is a URL, and a URL that differs only in casing or in how a space was encoded is the same
* page as far as anyone reading the wiki is concerned — so there is one spelling, and everything
* that takes a path from a human or from page content passes it through here first. Wrapping slashes
* go, runs of whitespace become a single hyphen, and what is left is lowercased.
*
* What it does not do is decide whether the result is *allowed*: the characters a path may contain
* are the page model's rule to enforce, on the normalized form.
*/
export function normalizePagePath(input?: string | null): string {
return (input ?? '')
.trim()
.replace(/^\/+/, '')
.replace(/\/+$/, '')
.replaceAll(/\s+/g, '-')
.toLowerCase()
}
/**
* Drop a site's page extension from the end of a URL path.
*
* A wiki's pages are addressed without one — `/foo/bar`, not `/foo/bar.md` — but the file the page
* was written as keeps turning up in links: an export, a repository mirror, a migration from a system
* that served files. So a site lists the extensions its content is written in, and a path ending in
* one of them means the page underneath it.
*
* Only the last segment is considered, and only when there is a name in front of the dot: `/.md` and
* `/docs.md/thing` address nothing.
*
* @param extensions Lowercase, without the dot, as the site config stores them
* @returns The path without the extension, or null if it does not end in one of them
*/
export function stripPageExtension(urlPath: string, extensions?: string[] | null): string | null {
if (!extensions || extensions.length < 1) {
return null
}
const dot = urlPath.lastIndexOf('.')
if (dot < 1 || urlPath[dot - 1] === '/' || urlPath.lastIndexOf('/') > dot) {
return null
}
if (!extensions.includes(urlPath.slice(dot + 1).toLowerCase())) {
return null
}
return urlPath.slice(0, dot)
}
/**
* Generate SHA-1 Hash of a string
*
* @param str String to hash
* @returns Hashed string
*/
export function generateHash(str: string): string {
return crypto.createHash('sha1').update(str).digest('hex')
}
/**
* Compare two secrets without leaking which character stopped the comparison.
*
* `===` on strings returns as soon as it finds a difference, and the time that takes is measurable
* across enough attempts. Both sides are digested first because `timingSafeEqual` throws on operands
* of different lengths — the digest is a fixed 32 bytes, so the length of the candidate says nothing.
*/
export function timingSafeCompare(a: string, b: string): boolean {
const digest = (value: string) => crypto.createHash('sha256').update(value).digest()
return crypto.timingSafeEqual(digest(a), digest(b))
}
/**
* Hash a page path the way the frontend does.
*
* A page is addressed by the hash of its path rather than the path itself, so that a URL with slashes
* in it stays a single path segment. The frontend computes this before asking for a page, so the two
* implementations have to agree exactly — this is cyrb53, mirroring `fastHash` in
* `frontend/src/stores/page.js`. Not a security boundary: it is a lookup key, and it is checked
* against the site it was requested for.
*
* @param str Page path, without a leading slash
* @returns 53-bit hash as a hex string
*/
export function generatePathHash(str: string, seed = 0): string {
let h1 = 0xdeadbeef ^ seed
let h2 = 0x41c6ce57 ^ seed
for (let i = 0; i < str.length; i++) {
const ch = str.charCodeAt(i)
h1 = Math.imul(h1 ^ ch, 2654435761)
h2 = Math.imul(h2 ^ ch, 1597334677)
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507)
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909)
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507)
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909)
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16)
}
/**
* How long a duration written the way the admin area writes them lasts, in seconds.
*
* `30s`, `15m`, `2h`, `7d`, `2w`, `1y` — one number and one unit, which is the form every duration
* setting takes (the JWT ones included) and the form `DURATION_PATTERN` in `models/security.ts`
* accepts. A year is 365 days and a month is not offered at all: these measure how long something
* lasts, not what date it lands on, so a calendar has no say in it.
*
* @param fallback Returned for anything unparseable, so one bad setting cannot turn a limit off
*/
export function durationToSeconds(value: unknown, fallback: number): number {
const match = /^(\d+)([smhdwy])$/.exec(String(value ?? '').trim())
if (!match) {
return fallback
}
const seconds = Number(match[1]) * DURATION_UNIT_SECONDS[match[2] as DurationUnit]
return seconds > 0 ? seconds : fallback
}
/**
* Get default value of type
*
* @param type primitive type name
* @returns Default value
*/
export function getTypeDefaultValue(type: string): string | number | boolean | undefined {
switch (type.toLowerCase()) {
case 'string':
return ''
case 'number':
return 0
case 'boolean':
return false
}
}
/**
* A single prop, as declared in a module `definition.yml`. Either the bare primitive type name
* (e.g. `String`) or an object describing the prop in full.
*/
export type ModulePropDeclaration = ModulePropDefinition | string
export interface ModulePropDefinition {
type: string
default?: unknown
title?: string
hint?: string
enum?: string[] | false
enumDisplay?: string
multiline?: boolean
sensitive?: boolean
readOnly?: boolean
icon?: string
order?: number
if?: unknown[]
}
/** A prop after normalization, with every field resolved to a concrete value. */
export interface ModuleProp {
default: unknown
type: string
title: string
hint: string
enum: string[] | false
enumDisplay: string
multiline: boolean
sensitive: boolean
/** Shown but not editable — the module declares something this server cannot currently change. */
readOnly: boolean
icon: string
order: number
if: unknown[]
}
export function parseModuleProps(
props: Record<string, ModulePropDeclaration>
): Record<string, ModuleProp> {
const result: Record<string, ModuleProp> = {}
for (const [key, value] of Object.entries(props)) {
const def: Partial<ModulePropDefinition> = isPlainObject(value) ? value : {}
const type = def.type || (value as string)
const defaultValue = !isNil(def.default) ? def.default : getTypeDefaultValue(type)
result[key] = {
default: defaultValue,
type: type.toLowerCase(),
title: def.title || startCase(key),
hint: def.hint || '',
enum: def.enum || false,
enumDisplay: def.enumDisplay || 'select',
multiline: def.multiline || false,
sensitive: def.sensitive || false,
readOnly: def.readOnly || false,
icon: def.icon || 'rename',
order: def.order || 100,
if: def.if ?? []
}
}
return result
}
export function getDictNameFromLocale(locale: string): string {
const loc = locale.length > 2 ? locale.substring(0, 2) : locale
if (loc in WIKI.config.search.dictOverrides) {
return WIKI.config.search.dictOverrides[loc]
} else {
return WIKI.data.tsDictMappings[loc] ?? 'simple'
}
}
export function replyWithFile(reply: FastifyReply, filePath: string): FastifyReply {
const stream = fs.createReadStream(filePath)
reply.header('Content-Type', mime.getType(filePath))
return reply.send(stream)
}
export class CustomError extends Error {
statusCode: number
constructor(name: string, message: string, statusCode = 400) {
super(message)
this.name = name
this.statusCode = statusCode
}
}
/**
* Rethrow a failure raised by the authentication models as an HTTP error.
*
* Those models signal a rejected request by throwing an `ERR_*` code rather than prose, because the
* client has a translation for each one — so the code travels to the client as the message of a 400.
* Anything else is an actual fault and is left alone, for the error handler to log and answer 500 to.
*/
export function rethrowAsBadRequest(err: any): never {
if (typeof err?.message === 'string' && err.message.startsWith('ERR_')) {
throw new CustomError('Bad Request', err.message)
}
throw err
}