Files
files/expressjs/src/server.js
2026-01-12 16:44:56 +01:00

677 lines
18 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 line of lines) {
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="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${title}</title>
<style>
:root {
--ink: #1f1a14;
--muted: #6c5e52;
--paper: #f5efe6;
--card: #fffaf3;
--accent: #b44b2a;
--accent-dark: #8d3a21;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Palatino Linotype", Palatino, "Book Antiqua", serif;
color: var(--ink);
background: radial-gradient(circle at top, #fff8ee 0%, #f5efe6 45%, #efe6da 100%);
min-height: 100vh;
}
main {
max-width: 980px;
margin: 0 auto;
padding: 28px 18px 60px;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
h1 {
margin: 0;
font-size: 1.8rem;
letter-spacing: 0.02em;
}
.muted {
color: var(--muted);
font-size: 0.95rem;
}
.card {
margin-top: 18px;
padding: 18px 18px 20px;
background: var(--card);
border-radius: 14px;
box-shadow: 0 10px 30px rgba(31, 26, 20, 0.12);
animation: fadeUp 0.5s ease;
}
form {
display: grid;
gap: 12px;
}
label {
display: grid;
gap: 6px;
font-weight: 600;
}
input, button {
font: inherit;
padding: 8px 10px;
border-radius: 8px;
}
input {
border: 1px solid #cdbdab;
background: #fff;
}
button {
border: none;
background: var(--accent);
color: #fff;
cursor: pointer;
transition: transform 0.15s ease, background 0.2s ease;
}
button:hover { background: var(--accent-dark); }
button:active { transform: translateY(1px); }
button.secondary {
background: #5d5146;
}
.row {
display: grid;
gap: 8px;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.95rem;
}
th, td {
text-align: left;
padding: 8px 6px;
border-bottom: 1px dashed #d9caba;
vertical-align: top;
}
progress {
width: 100%;
height: 14px;
accent-color: var(--accent);
}
.actions {
display: grid;
gap: 6px;
}
.pill {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
background: #f0e4d6;
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
</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>File Vault</h1>
<div class="muted">Sign in to manage shared uploads.</div>
</div>
</header>
<section class="card">
<form method="post" action="${baseUrl('/login')}">
<label>
Username
<input name="username" autocomplete="username" required />
</label>
<label>
Password
<input type="password" name="password" autocomplete="current-password" required />
</label>
<button type="submit">Log in</button>
</form>
</section>
`;
res.send(renderPage('Login', 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>File Vault</h1>
<div class="muted">Sign in to manage shared uploads.</div>
</div>
</header>
<section class="card">
<div class="pill">Login failed</div>
<form method="post" action="${baseUrl('/login')}">
<label>
Username
<input name="username" autocomplete="username" required />
</label>
<label>
Password
<input type="password" name="password" autocomplete="current-password" required />
</label>
<button type="submit">Log in</button>
</form>
</section>
`;
res.status(401).send(renderPage('Login', 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">${formatCountdown(item.expires_at)} left</div>
</td>
<td class="actions">
<form method="post" action="${baseUrl(`/files/${item.id}/delete`)}">
<button type="submit" class="secondary">Delete</button>
</form>
<form method="post" action="${baseUrl(`/files/${item.id}/extend`)}">
<input name="extendSeconds" placeholder="Add seconds" />
<button type="submit">Extend</button>
</form>
</td>
</tr>
`;
}).join('');
const body = `
<header>
<div>
<h1>File Vault</h1>
<div class="muted">Signed in as ${req.user.username}</div>
</div>
<form method="post" action="${baseUrl('/logout')}">
<button type="submit" class="secondary">Log out</button>
</form>
</header>
<section class="card">
<h2>Upload new file</h2>
<form id="upload-form">
<label>
File
<input type="file" name="file" required />
</label>
<label>
Retention override (seconds)
<input name="retentionSeconds" placeholder="${uploadTtlSeconds}" />
</label>
<button type="submit">Upload</button>
<progress id="upload-progress" value="0" max="100"></progress>
<div id="upload-status" class="muted"></div>
</form>
</section>
<section class="card">
<h2>Current uploads</h2>
${uploads.length ? `
<table>
<thead>
<tr>
<th>File</th>
<th>Size</th>
<th>Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
` : '<div class="muted">No uploads yet.</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 = 'Uploaded. Refreshing list...';
window.location.reload();
} else {
status.textContent = 'Upload failed.';
}
});
xhr.addEventListener('error', () => {
status.textContent = 'Upload failed.';
});
xhr.send(new FormData(uploadForm));
});
</script>
`;
res.send(renderPage('Dashboard', 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 = parseInt(req.body.retentionSeconds || '', 10);
const retentionSeconds = Number.isFinite(retentionOverride) && retentionOverride > 0
? retentionOverride
: 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('Not found', '<p class="card">Upload not found.</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('Not found', '<p class="card">Upload not found.</p>'));
return;
}
const override = parseInt(req.body.extendSeconds || '', 10);
const extensionSeconds = Number.isFinite(override) && override > 0
? override
: 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('Not found', '<p class="card">Not found.</p>'));
});
app.listen(port, () => {
console.log(`Express server listening on ${port} with base path ${basePath}`);
});