Files
website/server/src/utils/teamForumUploadSweep.js
wtclaude 4ac353684a feat(teams): harden the upload path for an uploader who is not an admin
The existing admin upload path is already good for an admin: an 8 MB cap, a
mimetype allowlist, a random filename, an extension derived from the mimetype map
and never from originalname, and nosniff forced on serve. All of it is kept. What
it does not have is anything that assumes a hostile uploader, because until now it
has not had one.

Magic-byte sniffing, because `file.mimetype` is the client's own Content-Type
header — a player can send image/png with arbitrary bytes and land arbitrary
content under a .png. Unrecognised bytes are a rejection and never a fallback to
what the header claimed. The file is on disk before it can be sniffed, so the
rejection path removes it: a rejected upload left on disk is the same
disk-exhaustion vector reached another way.

A rolling per-account byte quota and a per-IP rate limit, because community uploads
with no ceiling is disk exhaustion on the operator's own host.

An attribution row per accepted file. Not bookkeeping: the acknowledgement is
meaningless if "who uploaded this" cannot be answered afterwards, which is exactly
what the operator has just accepted responsibility for.

A nightly sweep for soft-deleted files past retention and for never-referenced
orphans, in the same in-process shape as the activity prune. It runs whether or not
`uploads` is the current mode, and that is the point — an operator who turns
uploads off after a problem still has the files, and a sweep that switched itself
off with the setting would strand exactly the bytes they were trying to be rid of.
It works from the forum's own rows outward and never from the directory listing
inward, because UPLOAD_DIR is shared with the admin upload path.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 07:24:02 -05:00

67 lines
2.5 KiB
JavaScript

// ── Team forum upload sweep ────────────────────────────────────────────────
//
// TEAMS.md §5.5.4's lifecycle half: soft-deleted uploads lose their bytes after a
// retention window, and files uploaded into a composer that was never submitted
// lose theirs after a grace period. The existing admin upload path never deletes
// anything, which is fine at admin volume and is not fine once a community can
// upload.
//
// Same in-process shape as utils/teamActivityPrune — setInterval + unref + stop(),
// wired into server.js start/shutdown. There is no cron in this stack.
//
// **It runs whether or not `teams_forum_images` is `uploads`, and that is the
// point.** An operator who turns uploads off after a problem has files already on
// disk; a sweep that switched itself off with the setting would strand exactly the
// bytes they were trying to be rid of. The admin help text says the same thing in
// the other direction — disabling uploads stops new files, it does not delete old
// ones — and this is the only thing that eventually does.
const uploads = require('../model/teams/teamForumUploads.model')
const log = require('./logger')('teams')
const INTERVAL_MS = Number(process.env.TEAM_FORUM_SWEEP_MS) || 24 * 60 * 60 * 1000
// Later than the activity prune's five minutes, so two table-walking jobs do not
// land on the same boot at the same moment.
const FIRST_RUN_MS = Number(process.env.TEAM_FORUM_SWEEP_DELAY_MS) || 10 * 60 * 1000
let timer = null
let firstRun = null
/** One sweep. Never throws — it runs on a timer with nobody to catch it. */
async function tick() {
try {
const result = await uploads.sweep()
if (result.swept) log.info('team forum upload sweep', result)
return result
} catch (err) {
log.error('team forum upload sweep failed', { message: err.message })
return null
}
}
function start() {
if (timer || firstRun) return timer
firstRun = setTimeout(() => {
firstRun = null
tick()
timer = setInterval(() => { tick() }, INTERVAL_MS)
if (timer.unref) timer.unref()
}, FIRST_RUN_MS)
if (firstRun.unref) firstRun.unref()
log.info('team forum upload sweep started', { intervalMs: INTERVAL_MS, firstRunMs: FIRST_RUN_MS })
return timer
}
function stop() {
if (firstRun) {
clearTimeout(firstRun)
firstRun = null
}
if (timer) {
clearInterval(timer)
timer = null
}
}
module.exports = { start, stop, tick, INTERVAL_MS, FIRST_RUN_MS }