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.
359 lines
12 KiB
359 lines
12 KiB
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
|