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.
wiki/backend/api/assets.ts

306 lines
8.6 KiB

import type { FastifyInstance } from 'fastify'
/** Extensions a browser may render inline. Everything else is sent as a download. */
const INLINE_EXTS = new Set(['png', 'apng', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg'])
const assetIdParam = {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
},
assetId: {
type: 'string',
format: 'uuid'
}
},
required: ['siteId', 'assetId']
}
/**
* Assets API Routes
*/
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',
{
config: {
permissions: ['write:assets', 'manage:assets']
},
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.`,
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 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',
{
config: {
permissions: ['read:assets', 'manage:assets']
},
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)
if (!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',
{
config: {
permissions: ['read:assets', 'manage:assets']
},
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) {
return reply.notFound('This asset does not exist.')
}
const content = await WIKI.models.assets.getContent(req.params.assetId)
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')
return reply.type(content.mimeType).send(content.data)
}
)
/**
* RENAME ASSET
*/
app.patch<{ Params: { siteId: string; assetId: string }; Body: { fileName: string } }>(
'/sites/:siteId/assets/:assetId',
{
config: {
permissions: ['manage:assets']
},
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 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',
{
config: {
permissions: ['manage:assets']
},
schema: {
summary: 'Delete an asset',
tags: ['Assets'],
params: assetIdParam,
response: {
204: {
description: 'Asset deleted successfully'
}
}
}
},
async (req, reply) => {
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