const crypto = require('crypto'); const fs = require('fs'); const os = require('os'); const path = require('path'); const bcrypt = require('bcryptjs'); const cookieParser = require('cookie-parser'); const dotenv = require('dotenv'); const express = require('express'); const jwt = require('jsonwebtoken'); const multer = require('multer'); const sqlite3 = require('sqlite3').verbose(); dotenv.config({ path: path.join(__dirname, '..', '..', '.env') }); const basePath = (process.env.BASE_PATH || '/manage').replace(/\/+$/, '') || '/manage'; const port = parseInt(process.env.PORT || '3000', 10); const dataDir = process.env.DATA_DIR || path.join(__dirname, '..', 'data'); const dbPath = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'uploads.sqlite'); const loginFile = process.env.LOGIN_FILE || path.join(__dirname, '..', '..', '.logins'); const uploadTtlSeconds = parseInt(process.env.UPLOAD_TTL_SECONDS || '604800', 10); const maxUploadBytes = parseInt(process.env.UPLOAD_MAX_BYTES || '0', 10); const shareDir = path.join(dataDir, '_share'); const jwtSecret = crypto.randomBytes(32).toString('hex'); const jwtMaxAgeMs = 2 * 60 * 60 * 1000; fs.mkdirSync(shareDir, { recursive: true }); const tempDir = path.join(os.tmpdir(), 'uploads'); fs.mkdirSync(tempDir, { recursive: true }); const upload = multer({ dest: tempDir, limits: maxUploadBytes > 0 ? { fileSize: maxUploadBytes } : undefined, }); const app = express(); app.use(cookieParser()); app.use(express.urlencoded({ extended: true })); app.use(express.json()); const db = new sqlite3.Database(dbPath); db.serialize(() => { db.run(`CREATE TABLE IF NOT EXISTS uploads ( id INTEGER PRIMARY KEY AUTOINCREMENT, owner TEXT NOT NULL, original_name TEXT NOT NULL, stored_name TEXT NOT NULL, stored_path TEXT NOT NULL, size_bytes INTEGER NOT NULL, uploaded_at INTEGER NOT NULL, expires_at INTEGER NOT NULL )`); db.run('CREATE INDEX IF NOT EXISTS uploads_owner_idx ON uploads(owner)'); db.run('CREATE INDEX IF NOT EXISTS uploads_expires_idx ON uploads(expires_at)'); }); function run(sql, params = []) { return new Promise((resolve, reject) => { db.run(sql, params, function (err) { if (err) { reject(err); return; } resolve(this); }); }); } function get(sql, params = []) { return new Promise((resolve, reject) => { db.get(sql, params, (err, row) => { if (err) { reject(err); return; } resolve(row); }); }); } function all(sql, params = []) { return new Promise((resolve, reject) => { db.all(sql, params, (err, rows) => { if (err) { reject(err); return; } resolve(rows); }); }); } function parseLogins(contents) { const entries = new Map(); const lines = contents.split(/\r?\n/); for (const rawLine of lines) { const line = rawLine.trim(); if (!line || line.startsWith('#')) { continue; } const parts = line.split(';;'); if (parts.length !== 2) { continue; } const username = parts[0].trim(); const hash = parts[1].trim(); if (!username || !hash) { continue; } entries.set(username, hash); } return entries; } async function loadLogins() { try { const contents = await fs.promises.readFile(loginFile, 'utf8'); return parseLogins(contents); } catch (err) { return new Map(); } } function toBase32(buffer) { const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; let bits = 0; let value = 0; let output = ''; for (const byte of buffer) { value = (value << 8) | byte; bits += 8; while (bits >= 5) { output += alphabet[(value >>> (bits - 5)) & 31]; bits -= 5; } } if (bits > 0) { output += alphabet[(value << (5 - bits)) & 31]; } return output; } function createToken(timestampMs) { const tsBuffer = Buffer.alloc(8); tsBuffer.writeBigUInt64BE(BigInt(timestampMs)); const randomPart = crypto.randomBytes(12); return toBase32(Buffer.concat([tsBuffer, randomPart])); } function sanitizeExtension(originalName) { const ext = path.extname(originalName || '').toLowerCase(); if (!ext) { return ''; } if (!/^\.[a-z0-9]{1,10}$/.test(ext)) { return ''; } return ext; } function formatBytes(bytes) { if (bytes < 1024) { return `${bytes} B`; } const units = ['KB', 'MB', 'GB', 'TB']; let value = bytes / 1024; let idx = 0; while (value >= 1024 && idx < units.length - 1) { value /= 1024; idx += 1; } return `${value.toFixed(value < 10 ? 1 : 0)} ${units[idx]}`; } function formatTimestamp(ts) { const date = new Date(ts); return date.toLocaleString(); } function formatCountdown(ts) { const delta = Math.max(0, ts - Date.now()); const minutes = Math.floor(delta / 60000); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); if (days > 0) { return `${days}d ${hours % 24}h`; } if (hours > 0) { return `${hours}h ${minutes % 60}m`; } return `${minutes}m`; } function renderPage(title, body) { return ` ${title}
${body}
`; } function baseUrl(pathname) { return `${basePath}${pathname}`; } function getUserFromRequest(req) { const token = req.cookies?.auth; if (!token) { return null; } try { const payload = jwt.verify(token, jwtSecret); if (!payload?.sub) { return null; } return { username: payload.sub }; } catch (err) { return null; } } function requireAuthPage(req, res, next) { const user = getUserFromRequest(req); if (!user) { res.clearCookie('auth'); res.redirect(baseUrl('/login')); return; } req.user = user; next(); } function requireAuthApi(req, res, next) { const user = getUserFromRequest(req); if (!user) { res.clearCookie('auth'); res.status(401).json({ error: 'Unauthorized' }); return; } req.user = user; next(); } async function cleanupExpired() { const now = Date.now(); const expired = await all('SELECT id, stored_path FROM uploads WHERE expires_at <= ?', [now]); for (const entry of expired) { try { await fs.promises.unlink(entry.stored_path); } catch (err) { // File might already be gone. } await run('DELETE FROM uploads WHERE id = ?', [entry.id]); } } setInterval(() => { cleanupExpired().catch(() => undefined); }, 60 * 1000); cleanupExpired().catch(() => undefined); app.get(`${basePath}/`, (req, res) => { const user = getUserFromRequest(req); if (user) { res.redirect(baseUrl('/dashboard')); return; } res.redirect(baseUrl('/login')); }); app.get(`${basePath}/login`, (req, res) => { const user = getUserFromRequest(req); if (user) { res.redirect(baseUrl('/dashboard')); return; } const body = `

Dateiverwaltung

Bitte anmelden, um Uploads zu verwalten.
`; res.send(renderPage('Anmeldung', body)); }); app.post(`${basePath}/login`, async (req, res) => { const username = String(req.body.username || '').trim(); const password = String(req.body.password || ''); const logins = await loadLogins(); const hash = logins.get(username); if (!hash || !bcrypt.compareSync(password, hash)) { const body = `

Dateiverwaltung

Bitte anmelden, um Uploads zu verwalten.
Anmeldung fehlgeschlagen
`; res.status(401).send(renderPage('Anmeldung', body)); return; } const token = jwt.sign({ sub: username }, jwtSecret, { expiresIn: '2h' }); res.cookie('auth', token, { httpOnly: true, sameSite: 'lax', maxAge: jwtMaxAgeMs, secure: process.env.COOKIE_SECURE === 'true', }); res.redirect(baseUrl('/dashboard')); }); app.post(`${basePath}/logout`, (req, res) => { res.clearCookie('auth'); res.redirect(baseUrl('/login')); }); app.get(`${basePath}/dashboard`, requireAuthPage, async (req, res) => { const uploads = await all( 'SELECT id, original_name, stored_name, size_bytes, uploaded_at, expires_at FROM uploads WHERE owner = ? ORDER BY uploaded_at DESC', [req.user.username] ); const rows = uploads.map((item) => { const fileUrl = `/_share/${item.stored_name}`; return `
${item.original_name}
${item.stored_name}
${formatBytes(item.size_bytes)}
${formatTimestamp(item.expires_at)}
Noch ${formatCountdown(item.expires_at)}
`; }).join(''); const body = `

Dateiverwaltung

Angemeldet als ${req.user.username}

Datei hochladen

Aktuelle Uploads

${uploads.length ? ` ${rows}
Datei Größe Läuft ab Aktionen
` : '
Noch keine Uploads.
'}
`; res.send(renderPage('Übersicht', body)); }); app.post(`${basePath}/api/upload`, requireAuthApi, upload.single('file'), async (req, res) => { if (!req.file) { res.status(400).json({ error: 'No file uploaded' }); return; } const now = Date.now(); const token = createToken(now); const ext = sanitizeExtension(req.file.originalname); const storedName = `_${token}${ext}`; const storedPath = path.join(shareDir, storedName); const retentionOverride = parseFloat(req.body.retentionHours || ''); const retentionSeconds = Number.isFinite(retentionOverride) && retentionOverride > 0 ? Math.round(retentionOverride * 3600) : uploadTtlSeconds; try { await fs.promises.rename(req.file.path, storedPath); } catch (err) { if (err.code === 'EXDEV') { await fs.promises.copyFile(req.file.path, storedPath); await fs.promises.unlink(req.file.path); } else { throw err; } } await run( `INSERT INTO uploads (owner, original_name, stored_name, stored_path, size_bytes, uploaded_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, [ req.user.username, req.file.originalname, storedName, storedPath, req.file.size, now, now + retentionSeconds * 1000, ] ); res.json({ ok: true, name: storedName }); }); app.post(`${basePath}/files/:id/delete`, requireAuthPage, async (req, res) => { const uploadEntry = await get('SELECT id, stored_path FROM uploads WHERE id = ? AND owner = ?', [ req.params.id, req.user.username, ]); if (!uploadEntry) { res.status(404).send(renderPage('Nicht gefunden', '

Upload nicht gefunden.

')); return; } try { await fs.promises.unlink(uploadEntry.stored_path); } catch (err) { // Ignore missing files. } await run('DELETE FROM uploads WHERE id = ?', [uploadEntry.id]); res.redirect(baseUrl('/dashboard')); }); app.post(`${basePath}/files/:id/extend`, requireAuthPage, async (req, res) => { const uploadEntry = await get('SELECT id, expires_at FROM uploads WHERE id = ? AND owner = ?', [ req.params.id, req.user.username, ]); if (!uploadEntry) { res.status(404).send(renderPage('Nicht gefunden', '

Upload nicht gefunden.

')); return; } const override = parseFloat(req.body.extendHours || ''); const extensionSeconds = Number.isFinite(override) && override > 0 ? Math.round(override * 3600) : uploadTtlSeconds; const base = Math.max(uploadEntry.expires_at, Date.now()); const nextExpiry = base + extensionSeconds * 1000; await run('UPDATE uploads SET expires_at = ? WHERE id = ?', [nextExpiry, uploadEntry.id]); res.redirect(baseUrl('/dashboard')); }); app.use((req, res) => { res.status(404).send(renderPage('Nicht gefunden', '

Seite nicht gefunden.

')); }); app.listen(port, () => { console.log(`Express server listening on ${port} with base path ${basePath}`); });