import type { FastifyInstance } from 'fastify' /** * Permissions for looking icons up and storing them. * * Anyone who can put an icon somewhere — a page, a navigation item, a page relation — needs to be able * to search for one and have it stored, which is what makes it servable from this instance afterwards. */ const PICKER_PERMISSIONS = ['write:pages', 'manage:pages', 'manage:sites', 'manage:system'] /** * Icons API Routes * * Administration of the icon sets, plus the search and materialize calls the icon picker makes. The * icons themselves are served outside `/_api`, under `/_icons` — see `controllers/icons.ts`. */ async function routes(app: FastifyInstance) { /** * LIST ADDED ICON SETS */ app.get( '/sets', { config: { permissions: PICKER_PERMISSIONS }, schema: { summary: 'List the icon sets added to this wiki', description: 'Alphabetical. `iconCount` is how many icons of the set are stored in the database, which is what this instance can serve on its own — the disk cache is derived from those rows and may be empty.', tags: ['Icons'], response: { 200: { description: 'List of icon sets', type: 'array', items: { $ref: 'IconSet#' } } } } }, async () => { return WIKI.models.icons.getSets() } ) /** * ADD ICON SET */ app.post<{ Body: { prefix: string } }>( '/sets', { config: { permissions: ['manage:system'] }, schema: { summary: 'Add an icon set', description: 'The set must exist upstream, and its name and metadata are taken from there — so this call needs outbound access to the Iconify API. Nothing is downloaded beyond the metadata: icons are stored the first time something references them.', tags: ['Icons'], body: { type: 'object', required: ['prefix'], properties: { prefix: { type: 'string', maxLength: 64, description: 'Iconify prefix of the set, e.g. `tabler`.' } } }, response: { 200: { description: 'Icon set added successfully', type: 'object', properties: { ok: { type: 'boolean' }, message: { type: 'string' }, set: { $ref: 'IconSet#' } } } } } }, async (req, reply) => { try { const set = await WIKI.models.icons.addSet(req.body.prefix.toLowerCase()) return { ok: true, message: `The ${set.name} icon set has been added.`, set } } catch (err: any) { WIKI.logger.warn(err.message) return reply.badRequest(err.message) } } ) /** * ENABLE / DISABLE ICON SET */ app.put<{ Params: { prefix: string }; Body: { isEnabled: boolean } }>( '/sets/:prefix', { config: { permissions: ['manage:system'] }, schema: { summary: 'Enable or disable an icon set', description: 'A disabled set disappears from the picker and stops taking on new icons. Icons already stored for it keep being served, since content referencing them is already published.', tags: ['Icons'], params: { type: 'object', properties: { prefix: { type: 'string', maxLength: 64 } }, required: ['prefix'] }, body: { type: 'object', required: ['isEnabled'], properties: { isEnabled: { type: 'boolean' } } }, response: { 200: { description: 'Icon set updated successfully', type: 'object', properties: { ok: { type: 'boolean' }, message: { type: 'string' } } } } } }, async (req, reply) => { const prefix = req.params.prefix.toLowerCase() if (!(await WIKI.models.icons.getSet(prefix))) { return reply.notFound('Icon set has not been added.') } await WIKI.models.icons.setSetState(prefix, req.body.isEnabled) return { ok: true, message: `The ${prefix} icon set has been ${req.body.isEnabled ? 'enabled' : 'disabled'}.` } } ) /** * DELETE ICON SET */ app.delete<{ Params: { prefix: string } }>( '/sets/:prefix', { config: { permissions: ['manage:system'] }, schema: { summary: 'Delete an icon set', description: 'Deletes the set and every icon stored for it, and drops its disk cache. Content still referencing those icons stops rendering them — disable the set instead to keep serving what is already in use.', tags: ['Icons'], params: { type: 'object', properties: { prefix: { type: 'string', maxLength: 64 } }, required: ['prefix'] }, response: { 200: { description: 'Icon set deleted successfully', type: 'object', properties: { ok: { type: 'boolean' }, message: { type: 'string' }, deletedIcons: { type: 'integer' } } } } } }, async (req, reply) => { const prefix = req.params.prefix.toLowerCase() if (!(await WIKI.models.icons.getSet(prefix))) { return reply.notFound('Icon set has not been added.') } const deletedIcons = await WIKI.models.icons.deleteSet(prefix) return { ok: true, message: `The ${prefix} icon set has been deleted.`, deletedIcons } } ) /** * LIST ICON SETS AVAILABLE UPSTREAM */ app.get( '/available-sets', { config: { permissions: ['manage:system'] }, schema: { summary: 'List the icon sets offered by the Iconify API', description: 'The catalog an administrator picks from, marking the sets already added. Fetched from upstream and memoized for an hour, so it needs outbound access.', tags: ['Icons'], response: { 200: { description: 'List of available icon sets', type: 'array', items: { $ref: 'AvailableIconSet#' } } } } }, async (_req, reply) => { try { return await WIKI.models.icons.getAvailableSets() } catch (err: any) { WIKI.logger.warn(err.message) return reply.badGateway(`Could not reach the Iconify API: ${err.message}`) } } ) /** * REFRESH ICON SET METADATA */ app.post( '/sets/refresh', { config: { permissions: ['manage:system'] }, schema: { summary: 'Refresh the metadata of every added icon set', description: 'Re-reads names, totals and licenses from upstream. Stored icons are untouched.', tags: ['Icons'], response: { 200: { description: 'Icon sets refreshed successfully', type: 'object', properties: { ok: { type: 'boolean' }, message: { type: 'string' }, refreshed: { type: 'integer' } } } } } }, async (_req, reply) => { try { const refreshed = await WIKI.models.icons.refreshSets() return { ok: true, message: `Refreshed ${refreshed} icon sets.`, refreshed } } catch (err: any) { WIKI.logger.warn(err.message) return reply.badGateway(`Could not reach the Iconify API: ${err.message}`) } } ) /** * SEARCH ICONS */ app.get<{ Querystring: { query: string; prefixes?: string; limit?: number } }>( '/search', { config: { permissions: PICKER_PERMISSIONS }, schema: { summary: 'Search icons across the enabled icon sets', description: 'Searched upstream, then narrowed to the sets enabled here — so results are always icons that can actually be used. Returns references shaped `prefix:name`, which is what content stores.', tags: ['Icons'], querystring: { type: 'object', required: ['query'], properties: { query: { type: 'string', minLength: 2, maxLength: 128 }, prefixes: { type: 'string', description: 'Comma-separated set prefixes to search in. Defaults to every enabled set.' }, limit: { type: 'integer', minimum: 32, maximum: 999, default: 96 } } }, response: { 200: { description: 'Matching icon references', type: 'object', properties: { icons: { type: 'array', items: { type: 'string' } } } } } } }, async (req, reply) => { try { const icons = await WIKI.models.icons.searchIcons({ query: req.query.query, prefixes: req.query.prefixes?.split(',').filter(Boolean), limit: req.query.limit }) return { icons } } catch (err: any) { WIKI.logger.warn(err.message) return reply.badGateway(`Could not reach the Iconify API: ${err.message}`) } } ) /** * LIST THE ICONS OF ONE SET */ app.get<{ Params: { prefix: string } }>( '/sets/:prefix/icons', { config: { permissions: PICKER_PERMISSIONS }, schema: { summary: 'List every icon name in an enabled set', description: 'For browsing a set with no search term. Deprecated icons are left out. Fetched from upstream and memoized for an hour.', tags: ['Icons'], params: { type: 'object', properties: { prefix: { type: 'string', maxLength: 64 } }, required: ['prefix'] }, response: { 200: { description: 'Icon names, without the set prefix', type: 'object', properties: { prefix: { type: 'string' }, icons: { type: 'array', items: { type: 'string' } } } } } } }, async (req, reply) => { const prefix = req.params.prefix.toLowerCase() try { return { prefix, icons: await WIKI.models.icons.listSetIcons(prefix) } } catch (err: any) { WIKI.logger.warn(err.message) return reply.badRequest(err.message) } } ) /** * MATERIALIZE ICONS */ app.post<{ Body: { icons: string[] } }>( '/materialize', { config: { permissions: PICKER_PERMISSIONS }, schema: { summary: 'Store icons so this instance can serve them', description: 'Called when an icon is chosen, while the author is online: it fetches the icon from upstream and writes it to the database, after which the wiki serves it forever without the Iconify API. Icons already stored are a no-op.', tags: ['Icons'], body: { type: 'object', required: ['icons'], properties: { icons: { type: 'array', minItems: 1, maxItems: 128, items: { type: 'string', maxLength: 320, description: 'An icon reference shaped `prefix:name`.' } } } }, response: { 200: { description: 'Icons materialized', type: 'object', properties: { ok: { type: 'boolean' }, message: { type: 'string' }, failed: { type: 'array', items: { type: 'string' }, description: 'References that could not be stored: malformed, from a set that is not enabled, or unknown upstream.' } } } } } }, async (req) => { const failed = await WIKI.models.icons.materializeIcons(req.body.icons) return { ok: failed.length < 1, message: failed.length < 1 ? 'Icons are stored and ready to be served.' : `${failed.length} of ${req.body.icons.length} icons could not be stored.`, failed } } ) /** * ICON CACHE STATE */ app.get( '/cache', { config: { permissions: ['manage:system'] }, schema: { summary: 'Report what this instance holds and has cached', description: '`iconCount` is permanent (database), the rest is this instance’s cache and can be discarded at any time.', tags: ['Icons'], response: { 200: { description: 'Icon storage and cache state', type: 'object', properties: { setCount: { type: 'integer' }, enabledSetCount: { type: 'integer' }, iconCount: { type: 'integer' }, memoryCount: { type: 'integer' }, diskCount: { type: 'integer' }, diskSize: { type: 'integer', description: 'Bytes held by the SVG files in the disk cache.' } } } } } }, async () => { return WIKI.models.icons.getStats() } ) /** * PURGE ICON CACHE */ app.delete( '/cache', { config: { permissions: ['manage:system'] }, schema: { summary: 'Purge the icon cache of this instance', description: 'Empties the memory and disk caches. Nothing is lost — both are rebuilt from the database as icons are requested again.', tags: ['Icons'], response: { 200: { description: 'Cache purged successfully', type: 'object', properties: { ok: { type: 'boolean' }, message: { type: 'string' } } } } } }, async () => { await WIKI.models.icons.purgeCache() return { ok: true, message: 'The icon cache has been purged.' } } ) } export default routes