feat: add scoped upload tokens

This commit is contained in:
Ludwig Lehnert
2026-06-18 19:17:51 +00:00
parent a2b6054440
commit 0ed32f7623
17 changed files with 592 additions and 95 deletions

View File

@@ -24,6 +24,7 @@ import {
resolveAdminPath,
safeBaseName,
sanitizeRelativePath,
sanitizeScopedUploadPath,
} from './files.js';
import {
checkLoginRateLimit,
@@ -67,6 +68,11 @@ function getUploadId(formData, fieldName = 'uploadId') {
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function getTokenId(formData) {
const parsed = Number.parseInt(String(formData.get('tokenId') || ''), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function parseHours(value, fallbackSeconds) {
const parsed = Number.parseFloat(String(value || ''));
if (Number.isFinite(parsed) && parsed > 0) {
@@ -102,6 +108,14 @@ function createRandomId() {
return toBase32(crypto.randomBytes(5));
}
function createUploadTokenSecret() {
return `fut_${crypto.randomBytes(32).toString('base64url')}`;
}
function hashUploadToken(token) {
return crypto.createHash('sha256').update(String(token || '')).digest('hex');
}
function uploadedFileFromForm(formData, fieldName = 'file') {
const candidate = formData.get(fieldName);
if (!candidate || typeof candidate === 'string') {
@@ -675,6 +689,95 @@ export async function adminSaveUploadToPathAction(formData) {
redirectWithSuccess(dashboardPath, 'Upload im Dateimanager gespeichert.');
}
export async function adminCreateUploadTokenAction(formData) {
try {
await verifyCsrf(formData);
} catch {
redirectWithError(`${managementBasePath}/admin/tokens`, 'CSRF-Prüfung fehlgeschlagen.');
}
const actor = await requireAdminUser();
const label = String(formData.get('label') || '').trim().slice(0, 120);
const scopePath = sanitizeScopedUploadPath(formData.get('scopePath'));
if (!scopePath) {
redirectWithError(`${managementBasePath}/admin/tokens`, 'Zielordner ist erforderlich.');
}
const scopeAbsolutePath = resolveAdminPath(scopePath);
if (!scopeAbsolutePath) {
redirectWithError(`${managementBasePath}/admin/tokens`, 'Ungültiger Zielordner.');
}
try {
await fs.promises.mkdir(scopeAbsolutePath, { recursive: true });
const stat = await fs.promises.stat(scopeAbsolutePath);
if (!stat.isDirectory()) {
redirectWithError(`${managementBasePath}/admin/tokens`, 'Ziel ist kein Ordner.');
}
} catch (error) {
if (error && error.digest) {
throw error;
}
redirectWithError(`${managementBasePath}/admin/tokens`, 'Zielordner konnte nicht erstellt werden.');
}
const token = createUploadTokenSecret();
const tokenHash = hashUploadToken(token);
const now = Date.now();
const expiresSeconds = parseHours(formData.get('expiresHours'), 0);
const expiresAt = expiresSeconds > 0
? now + Math.min(expiresSeconds, maxRetentionSeconds) * 1000
: null;
try {
await run(
`INSERT INTO upload_tokens (label, token_hash, scope_path, created_by, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)`,
[label || null, tokenHash, scopePath, actor.username, now, expiresAt]
);
} catch {
redirectWithError(`${managementBasePath}/admin/tokens`, 'Upload-Token konnte nicht erstellt werden.');
}
await logEvent(
'admin_upload_token_create',
actor.username,
{ scope_path: scopePath, label: label || null, expires_at: expiresAt },
await getRequestMeta()
);
redirectWithSuccess(
buildPathWithQuery(`${managementBasePath}/admin/tokens`, { createdToken: token }),
'Upload-Token erstellt. Token nur jetzt sichtbar.'
);
}
export async function adminDisableUploadTokenAction(formData) {
try {
await verifyCsrf(formData);
} catch {
redirectWithError(`${managementBasePath}/admin/tokens`, 'CSRF-Prüfung fehlgeschlagen.');
}
const actor = await requireAdminUser();
const tokenId = getTokenId(formData);
if (!tokenId) {
redirectWithError(`${managementBasePath}/admin/tokens`, 'Ungültige Token-ID.');
}
const now = Date.now();
const result = await run(
'UPDATE upload_tokens SET disabled_at = ? WHERE id = ? AND disabled_at IS NULL',
[now, tokenId]
);
if (!result || result.changes < 1) {
redirectWithError(`${managementBasePath}/admin/tokens`, 'Upload-Token nicht gefunden oder bereits deaktiviert.');
}
await logEvent('admin_upload_token_disable', actor.username, { token_id: tokenId }, await getRequestMeta());
redirectWithSuccess(`${managementBasePath}/admin/tokens`, 'Upload-Token deaktiviert.');
}
export async function adminMkdirAction(formData) {
try {
await verifyCsrf(formData);

View File

@@ -69,9 +69,22 @@ function initializeSchema(database) {
fulfilled_by TEXT,
notification_sent_at INTEGER
)`);
database.run(`CREATE TABLE IF NOT EXISTS upload_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT,
token_hash TEXT NOT NULL UNIQUE,
scope_path TEXT NOT NULL,
created_by TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER,
last_used_at INTEGER,
disabled_at INTEGER
)`);
database.run('CREATE INDEX IF NOT EXISTS upload_requests_owner_idx ON upload_requests(owner)');
database.run('CREATE INDEX IF NOT EXISTS upload_requests_expires_idx ON upload_requests(expires_at)');
database.run('CREATE INDEX IF NOT EXISTS upload_requests_completed_idx ON upload_requests(completed_at)');
database.run('CREATE INDEX IF NOT EXISTS upload_tokens_hash_idx ON upload_tokens(token_hash)');
database.run('CREATE INDEX IF NOT EXISTS upload_tokens_scope_idx ON upload_tokens(scope_path)');
database.run('ALTER TABLE uploads ADD COLUMN downloads INTEGER DEFAULT 0', () => undefined);
database.run('ALTER TABLE admin_logs ADD COLUMN ip TEXT', () => undefined);
database.run('ALTER TABLE admin_logs ADD COLUMN user_agent TEXT', () => undefined);

View File

@@ -45,6 +45,43 @@ export function safeBaseName(value, fallback = 'file') {
return base || fallback;
}
export function sanitizeScopedUploadPath(value) {
const parts = String(value || '')
.replace(/\\/g, '/')
.split('/')
.filter(Boolean);
if (parts.length === 0) {
return '';
}
for (const part of parts) {
if (!isValidNodeName(part) || part === '.' || part === '..') {
return '';
}
}
return parts.join('/');
}
export function resolveScopedAdminUploadPath(scopePath, uploadPath) {
const scopeAbsolutePath = resolveAdminPath(scopePath);
const cleanUploadPath = sanitizeScopedUploadPath(uploadPath);
if (!scopeAbsolutePath || !cleanUploadPath) {
return null;
}
const targetPath = path.resolve(scopeAbsolutePath, cleanUploadPath);
if (targetPath === scopeAbsolutePath || !targetPath.startsWith(`${scopeAbsolutePath}${path.sep}`)) {
return null;
}
return {
targetPath,
relativePath: sanitizeRelativePath(path.join(scopePath, cleanUploadPath)),
};
}
export function sanitizeExtension(value) {
const extension = path.extname(String(value || '')).toLowerCase();
if (!extension) {

View File

@@ -131,14 +131,18 @@ export async function streamBodyToPath(request, options) {
},
});
const writeStream = fs.createWriteStream(targetPath, { flags: 'wx' });
let opened = false;
writeStream.on('open', () => {
opened = true;
});
try {
await pipeline(
Readable.fromWeb(request.body),
counter,
fs.createWriteStream(targetPath, { flags: 'wx' })
);
await pipeline(Readable.fromWeb(request.body), counter, writeStream);
} catch (error) {
await fs.promises.rm(targetPath, { force: true }).catch(() => undefined);
if (opened) {
await fs.promises.rm(targetPath, { force: true }).catch(() => undefined);
}
throw error;
}