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.
306 lines
8.6 KiB
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
|