Files
archy/neode-ui/demo-uploads.js
T

87 lines
4.0 KiB
JavaScript

// In-memory File Browser TUS subset used by the public demo. Each visitor's
// reservations and bytes belong to their existing isolated, expiring session.
export function installDemoUploads(app, { currentStore, quota, normalize, parent, base, type }) {
const route = '/app/filebrowser/api/tus/*'
const number = value => typeof value === 'string' && /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) ? Number(value) : null
const locate = req => normalize(req.params[0] || '')
const headers = (res, upload) => res.set({
'Tus-Resumable': '1.0.0', 'Upload-Offset': String(upload.offset),
'Upload-Length': String(upload.length), 'Cache-Control': 'no-store',
})
const complete = (files, name, upload) => {
const data = Buffer.concat(upload.chunks, upload.length)
files.contents[name] = data
files.tree[parent(name)].push({ name: base(name), path: name, size: data.length,
modified: new Date().toISOString(), isDir: false, type: type(base(name)) })
files.bytes += data.length
files.reserved -= upload.length
files.uploaded.add(name)
files.uploads.delete(name)
}
app.head(route, (req, res) => {
const upload = currentStore().files.uploads.get(locate(req))
if (!upload) return res.sendStatus(404)
headers(res, upload).status(200).end()
})
app.post(route, (req, res) => {
const name = locate(req)
const files = currentStore().files
const length = number(req.get('Upload-Length'))
if (req.get('Tus-Resumable') !== '1.0.0' || length === null || name === '/' ||
name.split('/').some(p => p === '.' || p === '..' || p.includes('\0'))) return res.sendStatus(400)
if (!files.tree[parent(name)]) return res.sendStatus(404)
if (files.uploads.has(name) || files.tree[parent(name)].some(e => e.path === name)) return res.sendStatus(409)
// Reserve the whole declared size, so concurrent uploads cannot evade quota.
// Bound empty/abandoned session entries independently of their byte length.
if (files.bytes + files.reserved + length > quota || files.uploads.size >= 64) return res.sendStatus(507)
const upload = { length, offset: 0, chunks: [], writing: false }
files.uploads.set(name, upload)
files.reserved += length
if (!length) complete(files, name, upload)
headers(res, upload).location(req.path).status(201).end()
})
app.patch(route, async (req, res) => {
const name = locate(req)
const files = currentStore().files
const upload = files.uploads.get(name)
if (!upload) return res.sendStatus(404)
if (req.get('Tus-Resumable') !== '1.0.0') return res.sendStatus(412)
if (req.get('Content-Type') !== 'application/offset+octet-stream') return res.sendStatus(415)
if (upload.writing || number(req.get('Upload-Offset')) !== upload.offset) return headers(res, upload).status(409).end()
upload.writing = true
const chunks = []
let size = 0
try {
for await (const chunk of req) {
size += chunk.length
if (size > 2 * 1024 * 1024 || size > upload.length - upload.offset) {
res.status(413).end()
return
}
chunks.push(chunk)
}
// A simultaneous cancellation must never resurrect its staging file.
if (files.uploads.get(name) !== upload) return res.sendStatus(404)
upload.chunks.push(...chunks)
upload.offset += size
if (upload.offset === upload.length) complete(files, name, upload)
headers(res, upload).status(204).end()
} catch {
// An interrupted chunk does not advance the committed offset. HEAD tells
// the reconnecting client exactly where to resume.
if (!res.headersSent && !req.destroyed) res.sendStatus(400)
} finally {
upload.writing = false
}
})
app.delete(route, (req, res) => {
const files = currentStore().files
const name = locate(req)
const upload = files.uploads.get(name)
if (!upload) return res.sendStatus(404)
files.reserved -= upload.length
files.uploads.delete(name)
res.status(204).end()
})
}