589 lines
17 KiB
JavaScript
589 lines
17 KiB
JavaScript
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 `<!doctype html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>${title}</title>
|
|
<style>
|
|
:root { --ink:#0f172a; --muted:#6b7280; --line:#e5e7eb; --bg:#f7f7f4; --card:#ffffff; --accent:#111827; }
|
|
* { box-sizing: border-box; }
|
|
body { margin: 0; font-family: "IBM Plex Sans", "Noto Sans", sans-serif; color: var(--ink); background: var(--bg); }
|
|
main { max-width: 920px; margin: 0 auto; padding: 22px 16px 48px; }
|
|
header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
h1 { margin: 0; font-size: 1.55rem; }
|
|
h2 { margin: 0 0 12px; font-size: 1.08rem; }
|
|
.muted { color: var(--muted); font-size: 0.95rem; }
|
|
.card { margin-top: 16px; padding: 16px; background: var(--card); border-radius: 12px; border: 1px solid var(--line); }
|
|
form { display: grid; gap: 10px; }
|
|
label { display: grid; gap: 6px; font-weight: 600; }
|
|
input, button { font: inherit; padding: 8px 10px; border-radius: 8px; }
|
|
input { border: 1px solid var(--line); background: #fff; }
|
|
button { border: 1px solid var(--accent); background: var(--accent); color: #fff; cursor: pointer; }
|
|
button.secondary { background: transparent; color: var(--accent); }
|
|
table { width: 100%; border-collapse: collapse; font-size: 0.95rem; }
|
|
th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
|
progress { width: 100%; height: 12px; accent-color: var(--accent); }
|
|
.actions { display: grid; gap: 6px; }
|
|
.pill { display: inline-block; padding: 2px 8px; border-radius: 999px; background: #f1f5f9; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
${body}
|
|
</main>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
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 = `
|
|
<header>
|
|
<div>
|
|
<h1>Dateiverwaltung</h1>
|
|
<div class="muted">Bitte anmelden, um Uploads zu verwalten.</div>
|
|
</div>
|
|
</header>
|
|
<section class="card">
|
|
<form method="post" action="${baseUrl('/login')}">
|
|
<label>
|
|
Benutzername
|
|
<input name="username" autocomplete="username" required />
|
|
</label>
|
|
<label>
|
|
Passwort
|
|
<input type="password" name="password" autocomplete="current-password" required />
|
|
</label>
|
|
<button type="submit">Anmelden</button>
|
|
</form>
|
|
</section>
|
|
`;
|
|
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 = `
|
|
<header>
|
|
<div>
|
|
<h1>Dateiverwaltung</h1>
|
|
<div class="muted">Bitte anmelden, um Uploads zu verwalten.</div>
|
|
</div>
|
|
</header>
|
|
<section class="card">
|
|
<div class="pill">Anmeldung fehlgeschlagen</div>
|
|
<form method="post" action="${baseUrl('/login')}">
|
|
<label>
|
|
Benutzername
|
|
<input name="username" autocomplete="username" required />
|
|
</label>
|
|
<label>
|
|
Passwort
|
|
<input type="password" name="password" autocomplete="current-password" required />
|
|
</label>
|
|
<button type="submit">Anmelden</button>
|
|
</form>
|
|
</section>
|
|
`;
|
|
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 `
|
|
<tr>
|
|
<td>
|
|
<div><strong>${item.original_name}</strong></div>
|
|
<div class="muted"><a href="${fileUrl}" target="_blank" rel="noopener">${item.stored_name}</a></div>
|
|
</td>
|
|
<td>${formatBytes(item.size_bytes)}</td>
|
|
<td>
|
|
<div>${formatTimestamp(item.expires_at)}</div>
|
|
<div class="muted">Noch ${formatCountdown(item.expires_at)}</div>
|
|
</td>
|
|
<td class="actions">
|
|
<form method="post" action="${baseUrl(`/files/${item.id}/delete`)}">
|
|
<button type="submit" class="secondary">Löschen</button>
|
|
</form>
|
|
<form method="post" action="${baseUrl(`/files/${item.id}/extend`)}">
|
|
<input name="extendHours" placeholder="Stunden hinzufügen" />
|
|
<button type="submit">Verlängern</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
|
|
const body = `
|
|
<header>
|
|
<div>
|
|
<h1>Dateiverwaltung</h1>
|
|
<div class="muted">Angemeldet als ${req.user.username}</div>
|
|
</div>
|
|
<form method="post" action="${baseUrl('/logout')}">
|
|
<button type="submit" class="secondary">Abmelden</button>
|
|
</form>
|
|
</header>
|
|
|
|
<section class="card">
|
|
<h2>Datei hochladen</h2>
|
|
<form id="upload-form">
|
|
<label>
|
|
Datei
|
|
<input type="file" name="file" required />
|
|
</label>
|
|
<label>
|
|
Aufbewahrung (Stunden)
|
|
<input name="retentionHours" placeholder="${uploadTtlSeconds / 3600}" />
|
|
</label>
|
|
<button type="submit">Hochladen</button>
|
|
<progress id="upload-progress" value="0" max="100"></progress>
|
|
<div id="upload-status" class="muted"></div>
|
|
</form>
|
|
</section>
|
|
|
|
<section class="card">
|
|
<h2>Aktuelle Uploads</h2>
|
|
${uploads.length ? `
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Datei</th>
|
|
<th>Größe</th>
|
|
<th>Läuft ab</th>
|
|
<th>Aktionen</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${rows}
|
|
</tbody>
|
|
</table>
|
|
` : '<div class="muted">Noch keine Uploads.</div>'}
|
|
</section>
|
|
|
|
<script>
|
|
const uploadForm = document.getElementById('upload-form');
|
|
const progress = document.getElementById('upload-progress');
|
|
const status = document.getElementById('upload-status');
|
|
uploadForm.addEventListener('submit', (event) => {
|
|
event.preventDefault();
|
|
status.textContent = '';
|
|
progress.value = 0;
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open('POST', ${JSON.stringify(baseUrl('/api/upload'))});
|
|
xhr.upload.addEventListener('progress', (e) => {
|
|
if (e.lengthComputable) {
|
|
progress.value = Math.round((e.loaded / e.total) * 100);
|
|
}
|
|
});
|
|
xhr.addEventListener('load', () => {
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
status.textContent = 'Upload abgeschlossen. Liste wird aktualisiert...';
|
|
window.location.reload();
|
|
} else {
|
|
status.textContent = 'Upload fehlgeschlagen.';
|
|
}
|
|
});
|
|
xhr.addEventListener('error', () => {
|
|
status.textContent = 'Upload fehlgeschlagen.';
|
|
});
|
|
xhr.send(new FormData(uploadForm));
|
|
});
|
|
</script>
|
|
`;
|
|
|
|
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', '<p class="card">Upload nicht gefunden.</p>'));
|
|
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', '<p class="card">Upload nicht gefunden.</p>'));
|
|
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', '<p class="card">Seite nicht gefunden.</p>'));
|
|
});
|
|
|
|
app.listen(port, () => {
|
|
console.log(`Express server listening on ${port} with base path ${basePath}`);
|
|
});
|