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.
113 lines
4.0 KiB
113 lines
4.0 KiB
import { generateHash } from '../helpers/common.ts'
|
|
import type { FastifyInstance, FastifyReply } from 'fastify'
|
|
|
|
/** Ceiling on how many icons one batch request may ask for. */
|
|
const MAX_ICONS_PER_REQUEST = 128
|
|
|
|
/** An icon never changes under a given name, so the answer can be cached as hard as HTTP allows. */
|
|
const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable'
|
|
|
|
/** Long enough that a page's icons are asked for once, short enough to pick up new sets. */
|
|
const BATCH_CACHE = 'public, max-age=604800'
|
|
|
|
/** A batch that came back incomplete is worth asking about again soon. */
|
|
const INCOMPLETE_CACHE = 'public, max-age=60'
|
|
|
|
/**
|
|
* Answer with a body only when the client does not already have it.
|
|
*
|
|
* Icons are immutable and served for a year, so this only matters for the client that arrives without
|
|
* a warm HTTP cache but with a stale one — cheap enough to be worth the few lines.
|
|
*/
|
|
function sendCacheable(
|
|
reply: FastifyReply,
|
|
ifNoneMatch: string | undefined,
|
|
body: string,
|
|
{ contentType, cacheControl }: { contentType: string; cacheControl: string }
|
|
): FastifyReply {
|
|
const etag = `"${generateHash(body)}"`
|
|
reply.header('ETag', etag)
|
|
reply.header('Cache-Control', cacheControl)
|
|
if (ifNoneMatch === etag) {
|
|
return reply.code(304).send()
|
|
}
|
|
return reply.type(contentType).send(body)
|
|
}
|
|
|
|
/**
|
|
* _icons Routes
|
|
*
|
|
* Implements the part of the Iconify API protocol the frontend uses, so that `iconify-icon` and any
|
|
* other Iconify client can be pointed at this wiki instead of a third-party host: content references
|
|
* `mdi:account-edit`, the browser asks this route for it, and nothing about which icons a reader looks
|
|
* at leaves the instance.
|
|
*
|
|
* Public on purpose — icons are page furniture, and a reader who can see a page can see its icons.
|
|
* The routes only serve what the wiki holds or can fill in for an enabled set, and filling is bounded
|
|
* by the model's upstream budget.
|
|
*/
|
|
async function routes(app: FastifyInstance) {
|
|
/**
|
|
* BATCH ICON DATA — what `iconify-icon` requests, one call per set per page
|
|
*/
|
|
app.get<{ Params: { prefix: string }; Querystring: { icons?: string } }>(
|
|
'/:prefix.json',
|
|
async (req, reply) => {
|
|
const prefix = req.params.prefix.toLowerCase()
|
|
const names = (req.query.icons ?? '')
|
|
.split(',')
|
|
.map((name) => name.trim().toLowerCase())
|
|
.filter(Boolean)
|
|
.slice(0, MAX_ICONS_PER_REQUEST)
|
|
if (names.length < 1) {
|
|
return reply.badRequest('No icons requested.')
|
|
}
|
|
|
|
const set = await WIKI.models.icons.getSet(prefix)
|
|
if (!set) {
|
|
return reply.notFound('Icon set not found.')
|
|
}
|
|
|
|
const resolved = await WIKI.models.icons.resolveIcons(prefix, names)
|
|
const payload = {
|
|
prefix,
|
|
icons: resolved.icons,
|
|
...(resolved.notFound.length > 0 && { not_found: resolved.notFound })
|
|
}
|
|
|
|
return sendCacheable(reply, req.headers['if-none-match'], JSON.stringify(payload), {
|
|
contentType: 'application/json; charset=utf-8',
|
|
cacheControl: resolved.notFound.length > 0 ? INCOMPLETE_CACHE : BATCH_CACHE
|
|
})
|
|
}
|
|
)
|
|
|
|
/**
|
|
* SINGLE ICON AS SVG — for `<img>` and CSS, where a URL is all that fits
|
|
*/
|
|
app.get<{ Params: { prefix: string; name: string } }>(
|
|
'/:prefix/:name.svg',
|
|
async (req, reply) => {
|
|
const svg = await WIKI.models.icons.getIconSvg(
|
|
req.params.prefix.toLowerCase(),
|
|
req.params.name.toLowerCase()
|
|
)
|
|
if (!svg) {
|
|
return reply.notFound('Icon not found.')
|
|
}
|
|
|
|
// -> The markup comes from a third party and is served from our own origin, so it is locked down
|
|
// for the case where it is opened as a document rather than drawn as an image
|
|
reply.header('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'")
|
|
reply.header('X-Content-Type-Options', 'nosniff')
|
|
|
|
return sendCacheable(reply, req.headers['if-none-match'], svg, {
|
|
contentType: 'image/svg+xml; charset=utf-8',
|
|
cacheControl: IMMUTABLE_CACHE
|
|
})
|
|
}
|
|
)
|
|
}
|
|
|
|
export default routes
|