feat: add scoped upload tokens
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user