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.
88 lines
3.4 KiB
88 lines
3.4 KiB
import { validate as uuidValidate } from 'uuid'
|
|
import { replyWithFile } from '../helpers/common.ts'
|
|
import { svgMimeType } from '../helpers/images.ts'
|
|
import crypto from 'node:crypto'
|
|
import path from 'node:path'
|
|
import type { SiteAssetKind } from '../models/sites.ts'
|
|
import type { FastifyInstance } from 'fastify'
|
|
|
|
/**
|
|
* What is served for each of a site's images while nobody has uploaded one. The keys are the names
|
|
* the images are addressed by, which are the asset kinds themselves.
|
|
*/
|
|
const SITE_ASSET_FALLBACKS: Record<SiteAssetKind, string> = {
|
|
logo: 'assets/_assets/logo-wikijs.svg',
|
|
favicon: 'assets/_assets/logo-wikijs.svg',
|
|
loginBg: 'assets/_assets/bg/login.jpg'
|
|
}
|
|
|
|
/**
|
|
* An uploaded site image changes whenever an administrator replaces it, and the URL never carries a
|
|
* version — so it is always revalidated, and the ETag turns that into an empty 304 rather than a
|
|
* re-download.
|
|
*/
|
|
const SITE_ASSET_CACHE = 'public, no-cache'
|
|
|
|
/**
|
|
* An SVG is a document, not an image file: opened directly rather than through an `<img>`, a browser
|
|
* will run whatever scripts are in it, in this origin. Uploading one takes `manage:sites`, which
|
|
* already allows injecting markup into every page of the site — but that is a reason to keep the
|
|
* blast radius of a stolen admin session small, not to ignore it. Nothing legitimate in a logo needs
|
|
* more than the markup itself, so the response allows nothing else.
|
|
*/
|
|
const SVG_CSP = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
|
|
|
|
/**
|
|
* _site Routes
|
|
*/
|
|
async function routes(app: FastifyInstance) {
|
|
app.get<{ Params: { siteId: string; resource: string } }>(
|
|
'/:siteId/:resource',
|
|
async (req, reply) => {
|
|
let site: any
|
|
if (req.params.siteId === 'current' && req.hostname) {
|
|
site = await WIKI.models.sites.getSiteByHostname({ hostname: req.hostname })
|
|
} else if (uuidValidate(req.params.siteId)) {
|
|
site = await WIKI.models.sites.getSiteById({ id: req.params.siteId })
|
|
} else {
|
|
site = await WIKI.models.sites.getSiteByHostname({ hostname: req.params.siteId })
|
|
}
|
|
if (!site) {
|
|
return reply.notFound('Site not found')
|
|
}
|
|
|
|
const kind = req.params.resource as SiteAssetKind
|
|
const fallback = SITE_ASSET_FALLBACKS[kind]
|
|
if (!fallback) {
|
|
return reply.badRequest('Invalid Site Resource')
|
|
}
|
|
|
|
// -> The flag lives in the cached site config, so a site that has uploaded nothing — which is
|
|
// every site until an administrator says otherwise — never touches the database here
|
|
const asset = site.config.assets?.[kind]
|
|
? await WIKI.models.sites.getAsset(site.id, kind)
|
|
: null
|
|
if (!asset) {
|
|
return replyWithFile(reply, path.join(WIKI.ROOTPATH, fallback))
|
|
}
|
|
|
|
const etag = `"${crypto.createHash('sha1').update(asset.data).digest('hex')}"`
|
|
reply.header('ETag', etag)
|
|
reply.header('Cache-Control', SITE_ASSET_CACHE)
|
|
// -> The bytes were uploaded, so the browser must take the type at its word rather than looking
|
|
// for something more interesting in them
|
|
reply.header('X-Content-Type-Options', 'nosniff')
|
|
if (asset.mime === svgMimeType) {
|
|
reply.header('Content-Security-Policy', SVG_CSP)
|
|
}
|
|
if (req.headers['if-none-match'] === etag) {
|
|
return reply.code(304).send()
|
|
}
|
|
|
|
return reply.type(asset.mime).send(asset.data)
|
|
}
|
|
)
|
|
}
|
|
|
|
export default routes
|