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.
816 lines
27 KiB
816 lines
27 KiB
import fs from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import { and, count, eq, inArray } from 'drizzle-orm'
|
|
import { getIconData, iconToHTML, iconToSVG } from '@iconify/utils'
|
|
import { icons as iconsTable, iconSets as iconSetsTable } from '../db/schema.ts'
|
|
import type { IconifyIcon, IconifyInfo, IconifyJSON } from '@iconify/types'
|
|
|
|
/** An icon set as stored, plus how many of its icons the wiki holds. */
|
|
export interface IconSet {
|
|
prefix: string
|
|
name: string
|
|
isEnabled: boolean
|
|
info: IconifyInfo | Record<string, never>
|
|
refreshedAt: Date | null
|
|
createdAt: Date
|
|
/** Icons of this set stored in the database, i.e. the ones this wiki can serve on its own. */
|
|
iconCount: number
|
|
}
|
|
|
|
/** An icon set offered by the upstream API but not added here yet. */
|
|
export interface AvailableIconSet {
|
|
prefix: string
|
|
name: string
|
|
total: number
|
|
author: string
|
|
license: string
|
|
category: string
|
|
/** Whether the set's icons carry their own colors, i.e. cannot be recolored with `currentColor`. */
|
|
palette: boolean
|
|
samples: string[]
|
|
isAdded: boolean
|
|
}
|
|
|
|
/** The result of a resolve, in the shape the Iconify API protocol expects. */
|
|
export interface ResolvedIcons {
|
|
icons: Record<string, IconifyIcon>
|
|
notFound: string[]
|
|
}
|
|
|
|
/**
|
|
* Icon sets seeded on a fresh instance, so that the picker is usable before an administrator has
|
|
* added anything. The names are the upstream ones and get overwritten by the first metadata refresh.
|
|
*/
|
|
const DEFAULT_SETS: { prefix: string; name: string }[] = [
|
|
{ prefix: 'mdi', name: 'Material Design Icons' },
|
|
{ prefix: 'la', name: 'Line Awesome' }
|
|
]
|
|
|
|
/** Iconify prefixes and icon names are lowercase, dash-separated words. */
|
|
const PREFIX_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
const NAME_PATTERN = /^[a-z0-9]+(?:[-.][a-z0-9]+)*$/
|
|
|
|
/** How many resolved icons to hold per instance. An icon body is ~1 kB, so this is a few MB. */
|
|
const MEMORY_CACHE_MAX = 2000
|
|
|
|
/** How long the upstream collection list and per-set icon lists stay memoized. */
|
|
const CATALOG_TTL_MS = 60 * 60 * 1000
|
|
|
|
/**
|
|
* Ceiling on upstream requests per minute, across every caller.
|
|
*
|
|
* The public icon route fills the cache on a miss, and it is reachable by anyone who can read a page.
|
|
* Without a ceiling, a stream of requests for icons that do not exist would be amplified into a
|
|
* stream of requests to the Iconify API. Icons already stored are unaffected — they never go upstream.
|
|
*/
|
|
const UPSTREAM_BUDGET_PER_MINUTE = 60
|
|
|
|
/** How long a name that upstream does not know stays remembered as missing. */
|
|
const NOT_FOUND_TTL_MS = 60 * 60 * 1000
|
|
|
|
/**
|
|
* Reject anything that could execute when an icon is opened directly rather than drawn into a page.
|
|
*
|
|
* Icon bodies come from a third-party API, and while Iconify publishes shape markup, a compromised or
|
|
* misconfigured upstream is exactly the case worth being defensive about. Nothing legitimate in an
|
|
* icon body needs a script, an event handler or an external reference.
|
|
*/
|
|
function isSafeIconBody(body: string): boolean {
|
|
return !/<script|<foreignobject|<iframe|<use[^>]+href\s*=\s*["']?https?:|\son\w+\s*=|javascript:/i.test(
|
|
body
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Icons model
|
|
*
|
|
* Icons are addressed the way Iconify addresses them — `<prefix>:<name>`, e.g. `mdi:account-edit` —
|
|
* and that reference is all content ever stores. Resolving one to markup goes through four tiers:
|
|
*
|
|
* 1. **memory**, per instance, for the icons a page is actually made of
|
|
* 2. **disk**, under `<dataPath>/cache/icons`, one small JSON file per icon
|
|
* 3. **the database**, the permanent record: every icon the wiki has ever served lives here, so a new
|
|
* instance with an empty disk (or an instance with no outbound network at all) serves everything
|
|
* that content references
|
|
* 4. **the Iconify API**, consulted only for an icon nobody has used yet, and then persisted
|
|
*
|
|
* Rendering a page never resolves an icon: the page carries names, the browser asks for the icons it
|
|
* needs in one batch, and those answers are cached hard by the browser. Serving them touches the
|
|
* database only for an icon that is neither in memory nor on disk — so on a warm instance, never.
|
|
*/
|
|
class Icons {
|
|
/** Resolved icon data, keyed `prefix:name`. Insertion-ordered, so the oldest entry is evictable. */
|
|
memoryCache = new Map<string, IconifyIcon>()
|
|
|
|
/** Names upstream has no icon for, keyed `prefix:name` with the time they were last looked up. */
|
|
notFoundCache = new Map<string, number>()
|
|
|
|
/** Upstream catalog responses, memoized to keep the admin area and the picker snappy. */
|
|
catalogCache = new Map<string, { fetchedAt: number; data: any }>()
|
|
|
|
/** Rolling count of upstream requests, for `UPSTREAM_BUDGET_PER_MINUTE`. */
|
|
upstreamBudget = { windowStartedAt: 0, used: 0 }
|
|
|
|
get cachePath(): string {
|
|
return path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, 'cache/icons')
|
|
}
|
|
|
|
get apiUrl(): string {
|
|
return WIKI.config.icons?.apiUrl || 'https://api.iconify.design'
|
|
}
|
|
|
|
/**
|
|
* Split a `prefix:name` reference, or null when it is not one
|
|
*/
|
|
parseRef(ref: string): { prefix: string; name: string } | null {
|
|
const [prefix, name, ...rest] = `${ref}`.toLowerCase().split(':')
|
|
if (!prefix || !name || rest.length > 0) {
|
|
return null
|
|
}
|
|
return this.isValidRef(prefix, name) ? { prefix, name } : null
|
|
}
|
|
|
|
/**
|
|
* Whether a prefix and name are shaped like Iconify identifiers.
|
|
*
|
|
* Both end up in a file path, so this is what keeps `../` and friends out of the disk cache.
|
|
*/
|
|
isValidRef(prefix: string, name: string): boolean {
|
|
return PREFIX_PATTERN.test(prefix) && NAME_PATTERN.test(name)
|
|
}
|
|
|
|
// == SETS ===========================
|
|
|
|
/**
|
|
* Every added icon set, alphabetically, with the number of icons stored for each
|
|
*/
|
|
async getSets(): Promise<IconSet[]> {
|
|
const sets = await WIKI.db.select().from(iconSetsTable).orderBy(iconSetsTable.name)
|
|
const counts = await WIKI.db
|
|
.select({ prefix: iconsTable.prefix, total: count() })
|
|
.from(iconsTable)
|
|
.groupBy(iconsTable.prefix)
|
|
return sets.map((set) => ({
|
|
...set,
|
|
info: (set.info ?? {}) as IconifyInfo,
|
|
iconCount: counts.find((c) => c.prefix === set.prefix)?.total ?? 0
|
|
})) as IconSet[]
|
|
}
|
|
|
|
/**
|
|
* A single set, or null when it has not been added
|
|
*/
|
|
async getSet(prefix: string): Promise<IconSet | null> {
|
|
return (await this.getSets()).find((set) => set.prefix === prefix) ?? null
|
|
}
|
|
|
|
/**
|
|
* The prefixes of the sets icons may currently be drawn from
|
|
*/
|
|
async getEnabledPrefixes(): Promise<string[]> {
|
|
const sets = await WIKI.db
|
|
.select({ prefix: iconSetsTable.prefix })
|
|
.from(iconSetsTable)
|
|
.where(eq(iconSetsTable.isEnabled, true))
|
|
return sets.map((s) => s.prefix)
|
|
}
|
|
|
|
/**
|
|
* Add an icon set, taking its name and metadata from upstream.
|
|
*
|
|
* @returns The set as added
|
|
* @throws When the prefix is malformed, already added, or unknown upstream
|
|
*/
|
|
async addSet(prefix: string): Promise<IconSet> {
|
|
if (!PREFIX_PATTERN.test(prefix)) {
|
|
return Promise.reject(new Error(`"${prefix}" is not a valid icon set prefix.`))
|
|
}
|
|
if (await this.getSet(prefix)) {
|
|
return Promise.reject(new Error(`The ${prefix} icon set has already been added.`))
|
|
}
|
|
const collections = await this.getCollections()
|
|
const info = collections[prefix]
|
|
if (!info) {
|
|
return Promise.reject(new Error(`There is no "${prefix}" icon set available upstream.`))
|
|
}
|
|
|
|
await WIKI.db.insert(iconSetsTable).values({
|
|
prefix,
|
|
name: info.name ?? prefix,
|
|
isEnabled: true,
|
|
info,
|
|
refreshedAt: new Date()
|
|
})
|
|
WIKI.logger.info(`Added icon set ${prefix} [ OK ]`)
|
|
return (await this.getSet(prefix))!
|
|
}
|
|
|
|
/**
|
|
* Enable or disable an icon set.
|
|
*
|
|
* A disabled set stops being searchable and stops being filled from upstream, but the icons already
|
|
* stored for it keep being served: content referencing them is already published, and answering
|
|
* those requests with nothing would silently break pages.
|
|
*
|
|
* @returns Whether the set was updated
|
|
*/
|
|
async setSetState(prefix: string, isEnabled: boolean): Promise<boolean> {
|
|
const result = await WIKI.db
|
|
.update(iconSetsTable)
|
|
.set({ isEnabled })
|
|
.where(eq(iconSetsTable.prefix, prefix))
|
|
return (result.rowCount ?? 0) > 0
|
|
}
|
|
|
|
/**
|
|
* Delete an icon set along with every icon stored for it, and drop its disk cache.
|
|
*
|
|
* Content referencing those icons will stop rendering them, which is why the admin area asks first.
|
|
*
|
|
* @returns How many stored icons went with it
|
|
*/
|
|
async deleteSet(prefix: string): Promise<number> {
|
|
const deletedIcons = await WIKI.db.delete(iconsTable).where(eq(iconsTable.prefix, prefix))
|
|
await WIKI.db.delete(iconSetsTable).where(eq(iconSetsTable.prefix, prefix))
|
|
|
|
for (const key of this.memoryCache.keys()) {
|
|
if (key.startsWith(`${prefix}:`)) {
|
|
this.memoryCache.delete(key)
|
|
}
|
|
}
|
|
await fs.rm(path.join(this.cachePath, prefix), { recursive: true, force: true })
|
|
|
|
WIKI.logger.info(`Deleted icon set ${prefix} [ OK ]`)
|
|
return deletedIcons.rowCount ?? 0
|
|
}
|
|
|
|
/**
|
|
* Re-read the metadata of every added set from upstream.
|
|
*
|
|
* Only the description of a set changes here — its icons are untouched.
|
|
*
|
|
* @returns How many sets were refreshed
|
|
*/
|
|
async refreshSets(): Promise<number> {
|
|
const collections = await this.getCollections()
|
|
const sets = await WIKI.db.select({ prefix: iconSetsTable.prefix }).from(iconSetsTable)
|
|
let refreshed = 0
|
|
for (const set of sets) {
|
|
const info = collections[set.prefix]
|
|
if (!info) {
|
|
// -> A set can be renamed or withdrawn upstream. Keeping the row is the right call: its icons
|
|
// are stored here and content still references them.
|
|
WIKI.logger.warn(`Icon set ${set.prefix} is no longer offered upstream [ SKIPPED ]`)
|
|
continue
|
|
}
|
|
await WIKI.db
|
|
.update(iconSetsTable)
|
|
.set({ name: info.name ?? set.prefix, info, refreshedAt: new Date() })
|
|
.where(eq(iconSetsTable.prefix, set.prefix))
|
|
refreshed++
|
|
}
|
|
return refreshed
|
|
}
|
|
|
|
// == UPSTREAM CATALOG ===============
|
|
|
|
/**
|
|
* Every icon set the upstream API offers, keyed by prefix
|
|
*/
|
|
async getCollections(): Promise<Record<string, IconifyInfo>> {
|
|
return this.fetchCatalog('collections', '/collections')
|
|
}
|
|
|
|
/**
|
|
* The upstream catalog as the admin area lists it, marking the sets already added
|
|
*/
|
|
async getAvailableSets(): Promise<AvailableIconSet[]> {
|
|
const [collections, added] = await Promise.all([
|
|
this.getCollections(),
|
|
WIKI.db.select({ prefix: iconSetsTable.prefix }).from(iconSetsTable)
|
|
])
|
|
const addedPrefixes = added.map((s) => s.prefix)
|
|
return Object.entries(collections)
|
|
.map(([prefix, info]) => ({
|
|
prefix,
|
|
name: info.name ?? prefix,
|
|
total: info.total ?? 0,
|
|
author: info.author?.name ?? '',
|
|
license: info.license?.title ?? '',
|
|
category: info.category ?? '',
|
|
palette: info.palette === true,
|
|
samples: info.samples ?? [],
|
|
isAdded: addedPrefixes.includes(prefix)
|
|
}))
|
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
}
|
|
|
|
/**
|
|
* The names of every icon in a set, for browsing it without a search term.
|
|
*
|
|
* @throws When the set has not been added or is disabled
|
|
*/
|
|
async listSetIcons(prefix: string): Promise<string[]> {
|
|
const set = await this.getSet(prefix)
|
|
if (!set?.isEnabled) {
|
|
return Promise.reject(new Error(`The ${prefix} icon set is not available.`))
|
|
}
|
|
const collection = await this.fetchCatalog(
|
|
`collection:${prefix}`,
|
|
`/collection?prefix=${encodeURIComponent(prefix)}`
|
|
)
|
|
// -> Icons come either grouped in categories or as a flat `uncategorized` list, and a set can use
|
|
// both. Hidden icons are deprecated ones kept for compatibility, so they are left out.
|
|
const categorized = Object.values(
|
|
(collection.categories ?? {}) as Record<string, string[]>
|
|
).flat()
|
|
const names = [...categorized, ...((collection.uncategorized ?? []) as string[])]
|
|
return [...new Set(names)].sort()
|
|
}
|
|
|
|
/**
|
|
* Search icons upstream, within the sets that are enabled here.
|
|
*
|
|
* @returns References shaped `prefix:name`
|
|
*/
|
|
async searchIcons({
|
|
query,
|
|
prefixes,
|
|
limit = 96
|
|
}: {
|
|
query: string
|
|
prefixes?: string[]
|
|
limit?: number
|
|
}): Promise<string[]> {
|
|
const enabled = await this.getEnabledPrefixes()
|
|
// -> Searching a disabled set would offer icons that cannot then be stored
|
|
const searchIn = prefixes?.length ? prefixes.filter((p) => enabled.includes(p)) : enabled
|
|
if (searchIn.length < 1) {
|
|
return []
|
|
}
|
|
const params = new URLSearchParams({
|
|
query,
|
|
limit: `${Math.min(Math.max(limit, 32), 999)}`,
|
|
prefixes: searchIn.join(',')
|
|
})
|
|
const result = await this.apiFetch(`/search?${params}`)
|
|
return (result.icons ?? []) as string[]
|
|
}
|
|
|
|
/**
|
|
* Fetch an upstream catalog response, memoized for `CATALOG_TTL_MS`.
|
|
*
|
|
* These are large and change rarely, whereas the admin area and the picker ask for them often.
|
|
*/
|
|
async fetchCatalog(key: string, pathname: string): Promise<any> {
|
|
const cached = this.catalogCache.get(key)
|
|
if (cached && Date.now() - cached.fetchedAt < CATALOG_TTL_MS) {
|
|
return cached.data
|
|
}
|
|
const data = await this.apiFetch(pathname)
|
|
this.catalogCache.set(key, { fetchedAt: Date.now(), data })
|
|
return data
|
|
}
|
|
|
|
// == RESOLVING ======================
|
|
|
|
/**
|
|
* Resolve icons of one set, filling the cache from upstream for any the wiki does not hold yet.
|
|
*
|
|
* @param allowUpstream Whether a miss may be fetched upstream. False for callers that must not
|
|
* cause outbound traffic, e.g. a bulk render.
|
|
*/
|
|
async resolveIcons(
|
|
prefix: string,
|
|
names: string[],
|
|
{ allowUpstream = true }: { allowUpstream?: boolean } = {}
|
|
): Promise<ResolvedIcons> {
|
|
const wanted = [...new Set(names)].filter((name) => this.isValidRef(prefix, name))
|
|
const icons: Record<string, IconifyIcon> = {}
|
|
const missing: string[] = []
|
|
|
|
// -> Memory first: the icons a page is made of are asked for again and again
|
|
for (const name of wanted) {
|
|
const cached = this.memoryCache.get(`${prefix}:${name}`)
|
|
if (cached) {
|
|
icons[name] = cached
|
|
} else {
|
|
missing.push(name)
|
|
}
|
|
}
|
|
if (missing.length < 1) {
|
|
return { icons, notFound: [] }
|
|
}
|
|
|
|
// -> Then disk, which survives a restart and is what keeps page views off the database
|
|
const stillMissingAfterDisk: string[] = []
|
|
for (const name of missing) {
|
|
const cached = await this.readDiskCache(prefix, name)
|
|
if (cached) {
|
|
this.remember(prefix, name, cached)
|
|
icons[name] = cached
|
|
} else {
|
|
stillMissingAfterDisk.push(name)
|
|
}
|
|
}
|
|
if (stillMissingAfterDisk.length < 1) {
|
|
return { icons, notFound: [] }
|
|
}
|
|
|
|
// -> Then the permanent record, in one query for everything still missing
|
|
const rows = await WIKI.db
|
|
.select()
|
|
.from(iconsTable)
|
|
.where(and(eq(iconsTable.prefix, prefix), inArray(iconsTable.name, stillMissingAfterDisk)))
|
|
for (const row of rows) {
|
|
const icon = this.rowToIcon(row)
|
|
this.remember(prefix, row.name, icon)
|
|
await this.writeDiskCache(prefix, row.name, icon)
|
|
icons[row.name] = icon
|
|
}
|
|
|
|
const stillMissing = stillMissingAfterDisk.filter((name) => !(name in icons))
|
|
if (stillMissing.length < 1) {
|
|
return { icons, notFound: [] }
|
|
}
|
|
if (!allowUpstream) {
|
|
return { icons, notFound: stillMissing }
|
|
}
|
|
|
|
const fetched = await this.fetchIconsUpstream(prefix, stillMissing)
|
|
return {
|
|
icons: { ...icons, ...fetched.icons },
|
|
notFound: fetched.notFound
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch icons from upstream and store them permanently.
|
|
*
|
|
* Refuses for a set that is not enabled, so that a disabled set cannot grow, and holds to the
|
|
* upstream budget so that requests for icons that do not exist cannot be amplified.
|
|
*/
|
|
async fetchIconsUpstream(prefix: string, names: string[]): Promise<ResolvedIcons> {
|
|
const set = await this.getSet(prefix)
|
|
if (!set?.isEnabled) {
|
|
return { icons: {}, notFound: names }
|
|
}
|
|
|
|
// -> A name upstream has already denied is not worth asking about again
|
|
const asking = names.filter((name) => !this.isKnownMissing(prefix, name))
|
|
if (asking.length < 1) {
|
|
return { icons: {}, notFound: names }
|
|
}
|
|
if (!this.claimUpstreamBudget()) {
|
|
WIKI.logger.warn(
|
|
`Upstream icon request budget exhausted, not fetching ${prefix}:${asking.join(',')} [ SKIPPED ]`
|
|
)
|
|
return { icons: {}, notFound: names }
|
|
}
|
|
|
|
let iconSet: IconifyJSON
|
|
try {
|
|
iconSet = (await this.apiFetch(
|
|
`/${prefix}.json?icons=${asking.map(encodeURIComponent).join(',')}`
|
|
)) as IconifyJSON
|
|
} catch (err: any) {
|
|
WIKI.logger.warn(`Could not fetch icons from ${this.apiUrl} [ FAILED ]`)
|
|
WIKI.logger.warn(err.message)
|
|
return { icons: {}, notFound: names }
|
|
}
|
|
|
|
const icons: Record<string, IconifyIcon> = {}
|
|
const notFound: string[] = []
|
|
for (const name of asking) {
|
|
// -> Resolves aliases, character references and set-level defaults into one self-contained icon
|
|
const data = getIconData(iconSet, name)
|
|
if (!data?.body) {
|
|
notFound.push(name)
|
|
this.notFoundCache.set(`${prefix}:${name}`, Date.now())
|
|
continue
|
|
}
|
|
if (!isSafeIconBody(data.body)) {
|
|
notFound.push(name)
|
|
WIKI.logger.warn(`Refused unsafe icon body for ${prefix}:${name} [ FAILED ]`)
|
|
continue
|
|
}
|
|
icons[name] = data
|
|
await this.storeIcon(prefix, name, data)
|
|
await this.writeDiskCache(prefix, name, data)
|
|
this.remember(prefix, name, data)
|
|
}
|
|
|
|
if (Object.keys(icons).length > 0) {
|
|
WIKI.logger.debug(`Stored ${Object.keys(icons).length} new icons for set ${prefix} [ OK ]`)
|
|
}
|
|
return { icons, notFound: [...notFound, ...names.filter((n) => !asking.includes(n))] }
|
|
}
|
|
|
|
/**
|
|
* Write an icon to the permanent record
|
|
*/
|
|
async storeIcon(prefix: string, name: string, icon: IconifyIcon): Promise<void> {
|
|
const values = {
|
|
prefix,
|
|
name,
|
|
body: icon.body,
|
|
width: icon.width ?? 16,
|
|
height: icon.height ?? 16,
|
|
left: icon.left ?? 0,
|
|
top: icon.top ?? 0,
|
|
rotate: icon.rotate ?? 0,
|
|
hFlip: icon.hFlip ?? false,
|
|
vFlip: icon.vFlip ?? false
|
|
}
|
|
await WIKI.db
|
|
.insert(iconsTable)
|
|
.values(values)
|
|
.onConflictDoUpdate({
|
|
target: [iconsTable.prefix, iconsTable.name],
|
|
set: values
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Materialize icons so the wiki can serve them without the upstream API.
|
|
*
|
|
* Called when an icon is picked, i.e. while the author is online and before anyone else needs it.
|
|
*
|
|
* @param refs References shaped `prefix:name`
|
|
* @returns The references that could not be stored
|
|
*/
|
|
async materializeIcons(refs: string[]): Promise<string[]> {
|
|
const byPrefix = new Map<string, string[]>()
|
|
const invalid: string[] = []
|
|
for (const ref of refs) {
|
|
const parsed = this.parseRef(ref)
|
|
if (!parsed) {
|
|
invalid.push(ref)
|
|
continue
|
|
}
|
|
byPrefix.set(parsed.prefix, [...(byPrefix.get(parsed.prefix) ?? []), parsed.name])
|
|
}
|
|
|
|
const failed = [...invalid]
|
|
for (const [prefix, names] of byPrefix) {
|
|
const result = await this.resolveIcons(prefix, names)
|
|
failed.push(...result.notFound.map((name) => `${prefix}:${name}`))
|
|
}
|
|
return failed
|
|
}
|
|
|
|
/**
|
|
* The SVG for one icon, for the callers that can only carry a URL — an `<img>`, a CSS background.
|
|
*
|
|
* @returns The SVG markup, or null when there is no such icon
|
|
*/
|
|
async getIconSvg(
|
|
prefix: string,
|
|
name: string,
|
|
{ allowUpstream = true }: { allowUpstream?: boolean } = {}
|
|
): Promise<string | null> {
|
|
const resolved = await this.resolveIcons(prefix, [name], { allowUpstream })
|
|
const icon = resolved.icons[name]
|
|
return icon ? this.renderSvg(icon) : null
|
|
}
|
|
|
|
/**
|
|
* Turn resolved icon data into standalone SVG markup.
|
|
*
|
|
* Sized in pixels rather than the `1em` Iconify defaults to, since this file is also used as a plain
|
|
* image — an `<img>` has no font size to scale against.
|
|
*/
|
|
renderSvg(icon: IconifyIcon): string {
|
|
const rendered = iconToSVG(icon, {
|
|
width: `${icon.width ?? 16}`,
|
|
height: `${icon.height ?? 16}`
|
|
})
|
|
return iconToHTML(rendered.body, rendered.attributes)
|
|
}
|
|
|
|
// == CACHE ==========================
|
|
|
|
/**
|
|
* Hold an icon in memory, evicting the least recently stored one when full
|
|
*/
|
|
remember(prefix: string, name: string, icon: IconifyIcon): void {
|
|
if (this.memoryCache.size >= MEMORY_CACHE_MAX) {
|
|
const oldest = this.memoryCache.keys().next().value
|
|
if (oldest) {
|
|
this.memoryCache.delete(oldest)
|
|
}
|
|
}
|
|
this.memoryCache.set(`${prefix}:${name}`, icon)
|
|
}
|
|
|
|
/**
|
|
* Whether upstream said recently that it has no such icon
|
|
*/
|
|
isKnownMissing(prefix: string, name: string): boolean {
|
|
const at = this.notFoundCache.get(`${prefix}:${name}`)
|
|
if (at === undefined) {
|
|
return false
|
|
}
|
|
if (Date.now() - at > NOT_FOUND_TTL_MS) {
|
|
this.notFoundCache.delete(`${prefix}:${name}`)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Take one slot from the per-minute upstream allowance
|
|
*
|
|
* @returns Whether the request may go ahead
|
|
*/
|
|
claimUpstreamBudget(): boolean {
|
|
const now = Date.now()
|
|
if (now - this.upstreamBudget.windowStartedAt > 60_000) {
|
|
this.upstreamBudget = { windowStartedAt: now, used: 0 }
|
|
}
|
|
if (this.upstreamBudget.used >= UPSTREAM_BUDGET_PER_MINUTE) {
|
|
return false
|
|
}
|
|
this.upstreamBudget.used++
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Where an icon sits in the disk cache.
|
|
*
|
|
* Icon data rather than rendered SVG, so that one cached file answers both the batch data requests
|
|
* the frontend makes and the SVG requests an `<img>` makes — rendering from data is string building.
|
|
*/
|
|
diskCachePath(prefix: string, name: string): string {
|
|
return path.join(this.cachePath, prefix, `${name}.json`)
|
|
}
|
|
|
|
/**
|
|
* Read an icon from the disk cache
|
|
*
|
|
* @returns The icon, or null when it is not cached or the file is unusable
|
|
*/
|
|
async readDiskCache(prefix: string, name: string): Promise<IconifyIcon | null> {
|
|
try {
|
|
const icon = JSON.parse(await fs.readFile(this.diskCachePath(prefix, name), 'utf8'))
|
|
return typeof icon?.body === 'string' ? icon : null
|
|
} catch {
|
|
// -> Not cached on this instance yet, which is the normal state of a fresh container. A corrupt
|
|
// file lands here too and is treated the same way: refill it from the database.
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write an icon to the disk cache, best effort.
|
|
*
|
|
* A full or read-only disk must not stop an icon from being served, hence the swallowed error: the
|
|
* cache is derived data and every request can be answered without it.
|
|
*
|
|
* The file is written under a temporary name and renamed, so that a concurrent reader either sees
|
|
* the previous file or the complete new one, never a half-written one.
|
|
*/
|
|
async writeDiskCache(prefix: string, name: string, icon: IconifyIcon): Promise<void> {
|
|
const filePath = this.diskCachePath(prefix, name)
|
|
const tempPath = `${filePath}.${process.pid}.tmp`
|
|
try {
|
|
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
|
await fs.writeFile(tempPath, JSON.stringify(icon), 'utf8')
|
|
await fs.rename(tempPath, filePath)
|
|
} catch (err: any) {
|
|
WIKI.logger.warn(`Could not write ${filePath} to the icon cache [ SKIPPED ]`)
|
|
WIKI.logger.warn(err.message)
|
|
await fs.rm(tempPath, { force: true }).catch(() => {})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Drop the disk and memory caches. Nothing is lost: both are rebuilt from the database on demand.
|
|
*/
|
|
async purgeCache(): Promise<void> {
|
|
this.memoryCache.clear()
|
|
this.notFoundCache.clear()
|
|
this.catalogCache.clear()
|
|
await fs.rm(this.cachePath, { recursive: true, force: true })
|
|
await fs.mkdir(this.cachePath, { recursive: true })
|
|
WIKI.logger.info('Purged the icon cache [ OK ]')
|
|
}
|
|
|
|
/**
|
|
* What the wiki holds and what it has cached, for the admin area
|
|
*/
|
|
async getStats(): Promise<{
|
|
setCount: number
|
|
enabledSetCount: number
|
|
iconCount: number
|
|
memoryCount: number
|
|
diskCount: number
|
|
diskSize: number
|
|
}> {
|
|
const [sets, iconCount] = await Promise.all([
|
|
WIKI.db.select({ isEnabled: iconSetsTable.isEnabled }).from(iconSetsTable),
|
|
WIKI.db.$count(iconsTable)
|
|
])
|
|
const disk = await this.measureDiskCache()
|
|
return {
|
|
setCount: sets.length,
|
|
enabledSetCount: sets.filter((s) => s.isEnabled).length,
|
|
iconCount,
|
|
memoryCount: this.memoryCache.size,
|
|
diskCount: disk.files,
|
|
diskSize: disk.bytes
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Walk the disk cache. Cheap enough to do on demand: it holds one small file per icon in use.
|
|
*/
|
|
async measureDiskCache(): Promise<{ files: number; bytes: number }> {
|
|
let files = 0
|
|
let bytes = 0
|
|
try {
|
|
const entries = await fs.readdir(this.cachePath, { recursive: true, withFileTypes: true })
|
|
for (const entry of entries) {
|
|
if (!entry.isFile() || !entry.name.endsWith('.json')) {
|
|
continue
|
|
}
|
|
files++
|
|
bytes += (await fs.stat(path.join(entry.parentPath, entry.name))).size
|
|
}
|
|
} catch {
|
|
// -> No cache directory yet, which is simply an empty cache
|
|
}
|
|
return { files, bytes }
|
|
}
|
|
|
|
// == PLUMBING =======================
|
|
|
|
/**
|
|
* Call the upstream Iconify API
|
|
*
|
|
* @throws When offline mode is on, the request fails, or the response is not JSON
|
|
*/
|
|
async apiFetch(pathname: string): Promise<any> {
|
|
if (WIKI.config.offline) {
|
|
return Promise.reject(
|
|
new Error('Wiki.js is in offline mode and cannot reach the Iconify API.')
|
|
)
|
|
}
|
|
const url = `${this.apiUrl}${pathname}`
|
|
WIKI.logger.debug(`Fetching ${url}`)
|
|
const resp = await fetch(url, {
|
|
headers: { Accept: 'application/json' },
|
|
signal: AbortSignal.timeout(15_000)
|
|
})
|
|
if (!resp.ok) {
|
|
return Promise.reject(new Error(`${this.apiUrl} answered ${resp.status} for ${pathname}`))
|
|
}
|
|
const data = await resp.json()
|
|
// -> The API answers an unknown prefix with the string `404` and a 200 status
|
|
if (typeof data !== 'object' || data === null) {
|
|
return Promise.reject(new Error(`${this.apiUrl} has nothing for ${pathname}`))
|
|
}
|
|
return data
|
|
}
|
|
|
|
rowToIcon(row: typeof iconsTable.$inferSelect): IconifyIcon {
|
|
return {
|
|
body: row.body,
|
|
width: row.width,
|
|
height: row.height,
|
|
left: row.left,
|
|
top: row.top,
|
|
rotate: row.rotate,
|
|
hFlip: row.hFlip,
|
|
vFlip: row.vFlip
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Make sure the cache directory exists, so that the first icon request is not the one to find out
|
|
*/
|
|
async ensureCacheDir(): Promise<void> {
|
|
try {
|
|
await fs.mkdir(this.cachePath, { recursive: true })
|
|
} catch (err: any) {
|
|
WIKI.logger.warn(`Could not create the icon cache directory ${this.cachePath} [ SKIPPED ]`)
|
|
WIKI.logger.warn(err.message)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Seed the icon sets a fresh instance starts with.
|
|
*
|
|
* Deliberately network-free: the wiki has to install without outbound access, so only the prefix and
|
|
* a name go in, and the metadata is filled in by the first refresh.
|
|
*/
|
|
async init(): Promise<void> {
|
|
WIKI.logger.info('Inserting default icon sets...')
|
|
await WIKI.db
|
|
.insert(iconSetsTable)
|
|
.values(DEFAULT_SETS.map((set) => ({ ...set, isEnabled: true })))
|
|
}
|
|
}
|
|
|
|
export const icons = new Icons()
|