import type { FastifyInstance, FastifyRequest } from 'fastify' import { decodeTreePath } from '../helpers/common.ts' import { INLINE_EXTS } from '../models/assets.ts' const assetIdParam = { type: 'object', properties: { siteId: { type: 'string', format: 'uuid' }, assetId: { type: 'string', format: 'uuid' } }, required: ['siteId', 'assetId'] } /** * Assets API Routes */ /** * Whether the caller holds an asset permission on an asset, judged on where it sits. * * Assets live in the same tree as pages and are addressed by the same rules — a rule over a branch * covers the files in it as well as the pages, which is why the asset permissions are offered * alongside the page ones in the group editor. */ function mayOnAsset( req: FastifyRequest, permission: string, asset: { folderPath?: string | null; fileName: string } ): boolean { const folder = asset.folderPath ?? '' return WIKI.models.groups.checkAccess(WIKI.models.groups.actorForRequest(req), permission, { path: folder ? `${folder}/${asset.fileName}` : asset.fileName }) } async function routes(app: FastifyInstance) { // -> An upload is the raw file rather than a multipart form: one file per request, with the name and // the destination in the query string. The catch-all only claims content types nothing else // parses, so the JSON routes below are unaffected. // // The limit is read once, here, because a route's body limit is fixed when it is registered — // changing it in the admin area takes effect on the next restart, as the rest of the security // settings do. app.addContentTypeParser( '*', { parseAs: 'buffer', bodyLimit: WIKI.config.security?.uploadMaxFileSize ?? 10485760 }, (req, body, done) => { done(null, body) } ) /** * UPLOAD ASSET */ app.post<{ Params: { siteId: string } Querystring: { fileName: string; folderId?: string; locale?: string } }>( '/sites/:siteId/assets', { /* No route-level `permissions`: that hook reads the group-wide list, and asset permissions come from a group's RULES, which address the folder the file is in. Checked below. */ schema: { summary: 'Upload an asset', description: `The body is the file itself, not a multipart form — send the bytes with their \`Content-Type\`. At most ${Math.round((WIKI.config.security?.uploadMaxFileSize ?? 10485760) / 1024 / 1024)} MB. The file name is sanitized, so the stored name in the response may differ from the one sent; the type served back later comes from that name's extension rather than from the request. Images get a thumbnail when the Sharp extension is installed.\n\nA file already at that name in that folder is settled by the site's upload conflict behavior: \`overwrite\` (the default) replaces it in place and answers with its existing ID, \`reject\` answers 409, and \`new\` stores the arrival as the next free \`name-1.ext\`. So the name and ID in the response are what to link to — never the ones that were sent. A page or a folder holding the name is answered 409 whichever behavior is set.`, tags: ['Assets'], consumes: ['*/*'], params: { type: 'object', properties: { siteId: { type: 'string', format: 'uuid' } }, required: ['siteId'] }, querystring: { type: 'object', properties: { fileName: { type: 'string', minLength: 1, maxLength: 255 }, folderId: { type: 'string', format: 'uuid', description: 'The folder to upload into. The site root when absent.' }, locale: { type: 'string', maxLength: 10, description: "The site's primary locale when absent." } }, required: ['fileName'] }, response: { 200: { description: 'Asset uploaded successfully', type: 'object', properties: { ok: { type: 'boolean' }, message: { type: 'string' }, asset: { $ref: 'Asset#' } } } } } }, async (req, reply) => { // -> An asset records who uploaded it, and an API key is not a who const authorId = req.session?.authenticated ? req.session.user?.id : null if (!authorId) { return reply.unauthorized('Uploading an asset requires a logged in user.') } const data = req.body if (!Buffer.isBuffer(data) || data.length < 1) { return reply.badRequest('No file was sent.') } const folder = req.query.folderId ? await WIKI.models.tree.getFolderById(req.query.folderId) : null const folderPath = folder ? (decodeTreePath(folder.folderPath ?? '') ?? '') : '' const destination = folder ? [folderPath, folder.fileName].filter(Boolean).join('/') : '' if ( !mayOnAsset(req, 'write:assets', { folderPath: destination, fileName: req.query.fileName }) ) { return reply.forbidden('You are not allowed to upload a file here.') } const asset = await WIKI.models.assets.upload({ siteId: req.params.siteId, locale: req.query.locale ?? WIKI.sites[req.params.siteId]?.config?.locales?.primary ?? 'en', folderId: req.query.folderId, fileName: req.query.fileName, mimeType: req.headers['content-type'], data, authorId }) return { ok: true, message: 'Asset uploaded successfully.', asset } } ) /** * GET ASSET */ app.get<{ Params: { siteId: string; assetId: string } }>( '/sites/:siteId/assets/:assetId', { /* No route-level `permissions`: that hook reads the group-wide list, and asset permissions come from a group's RULES, which address the folder the file is in. Checked below. */ schema: { summary: 'Get a single asset', description: 'Metadata only. `/content` serves the file itself.', tags: ['Assets'], params: assetIdParam, response: { 200: { $ref: 'Asset#' } } } }, async (req, reply) => { const asset = await WIKI.models.assets.getAsset(req.params.siteId, req.params.assetId) // -> Not readable is answered as not there, so the endpoint cannot be used to probe for files if (!asset || !mayOnAsset(req, 'read:assets', asset)) { return reply.notFound('This asset does not exist.') } return asset } ) /** * DOWNLOAD ASSET */ app.get<{ Params: { siteId: string; assetId: string } }>( '/sites/:siteId/assets/:assetId/content', { /* No route-level `permissions`: that hook reads the group-wide list, and asset permissions come from a group's RULES, which address the folder the file is in. Checked below. */ schema: { summary: 'Download an asset', description: 'The file itself. Anything a browser should not render inline is sent as an attachment, and the type is always the one derived from the stored file name.', tags: ['Assets'], params: assetIdParam, response: { 200: { description: 'The file', content: { '*/*': { schema: { type: 'string', format: 'binary' } } } } } } }, async (req, reply) => { const asset = await WIKI.models.assets.getAsset(req.params.siteId, req.params.assetId) if (!asset || !mayOnAsset(req, 'read:assets', asset)) { return reply.notFound('This asset does not exist.') } // -> Through the same local disk cache `/_files/` serves from, since this is the download // button in the file manager rather than an administrative route: anyone who may read a // file may press it const content = await WIKI.models.assets.readContent(asset) if (!content) { return reply.notFound('This asset has no content.') } if (WIKI.config.security?.forceAssetDownload || !INLINE_EXTS.has(asset.fileExt)) { reply.header( 'Content-Disposition', `attachment; filename="${encodeURIComponent(asset.fileName)}"` ) } // -> The bytes came from a user, 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') // -> Set by hand because the body may be a stream, which Fastify would otherwise send chunked reply.header('Content-Length', content.size) return reply.type(asset.mimeType).send(content.body) } ) /** * RENAME ASSET */ app.patch<{ Params: { siteId: string; assetId: string }; Body: { fileName: string } }>( '/sites/:siteId/assets/:assetId', { /* No route-level `permissions`: that hook reads the group-wide list, and asset permissions come from a group's RULES, which address the folder the file is in. Checked below. */ schema: { summary: 'Rename an asset', description: 'The extension is part of the name, and changing it changes the type the file is served as.', tags: ['Assets'], params: assetIdParam, body: { type: 'object', required: ['fileName'], properties: { fileName: { type: 'string', minLength: 3, maxLength: 255, description: 'Sanitized, so the stored name may differ from the one sent.' } } }, response: { 200: { description: 'Asset renamed successfully', type: 'object', properties: { ok: { type: 'boolean' }, message: { type: 'string' }, asset: { $ref: 'Asset#' } } } } } }, async (req, reply) => { const existing = await WIKI.models.assets.getAsset(req.params.siteId, req.params.assetId) if (!existing) { return reply.notFound('This asset does not exist.') } if (!mayOnAsset(req, 'manage:assets', existing)) { return reply.forbidden('You are not allowed to rename this file.') } const asset = await WIKI.models.assets.renameAsset( req.params.siteId, req.params.assetId, req.body.fileName ) if (!asset) { return reply.notFound('This asset does not exist.') } return { ok: true, message: 'Asset renamed successfully.', asset } } ) /** * DELETE ASSET */ app.delete<{ Params: { siteId: string; assetId: string } }>( '/sites/:siteId/assets/:assetId', { /* No route-level `permissions`: that hook reads the group-wide list, and asset permissions come from a group's RULES, which address the folder the file is in. Checked below. */ schema: { summary: 'Delete an asset', tags: ['Assets'], params: assetIdParam, response: { 204: { description: 'Asset deleted successfully' } } } }, async (req, reply) => { const doomed = await WIKI.models.assets.getAsset(req.params.siteId, req.params.assetId) if (!doomed) { return reply.notFound('This asset does not exist.') } if (!mayOnAsset(req, 'manage:assets', doomed)) { return reply.forbidden('You are not allowed to delete this file.') } if (!(await WIKI.models.assets.deleteAsset(req.params.siteId, req.params.assetId))) { return reply.notFound('This asset does not exist.') } return reply.code(204).send() } ) } export default routes