extended project by management webserver for file sharing
This commit is contained in:
@@ -1,2 +1,4 @@
|
||||
SERVICE_FQDN=files.example.com
|
||||
LETSENCRYPT_EMAIL=user@example.com
|
||||
DATA_DIR=/storagebox
|
||||
UPLOAD_TTL_SECONDS=604800
|
||||
|
||||
@@ -34,7 +34,7 @@ services:
|
||||
|
||||
volumes:
|
||||
# - "/var/run/docker.sock:/var/run/docker.sock:ro"
|
||||
- "/storagebox:/usr/local/apache2/htdocs:ro"
|
||||
- "${DATA_DIR}:/usr/local/apache2/htdocs:ro"
|
||||
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
@@ -44,6 +44,7 @@ services:
|
||||
- "traefik.http.routers.webserver.tls=true"
|
||||
- "traefik.http.routers.webserver.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.webserver.service=webserver-svc"
|
||||
- "traefik.http.routers.webserver.priority=1"
|
||||
# Optional HTTP redirect
|
||||
- "traefik.http.routers.webserver-http.rule=Host(`${SERVICE_FQDN}`)"
|
||||
- "traefik.http.routers.webserver-http.entrypoints=web"
|
||||
@@ -53,3 +54,39 @@ services:
|
||||
- "traefik.http.services.webserver-svc.loadbalancer.server.port=80"
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
expressjs:
|
||||
build:
|
||||
context: ./expressjs
|
||||
|
||||
container_name: expressjs
|
||||
|
||||
environment:
|
||||
- BASE_PATH=/manage
|
||||
- DATA_DIR=/data
|
||||
- DB_PATH=/app/data/uploads.sqlite
|
||||
- LOGIN_FILE=/app/.logins
|
||||
- UPLOAD_TTL_SECONDS=${UPLOAD_TTL_SECONDS}
|
||||
- PORT=3000
|
||||
|
||||
volumes:
|
||||
- "./data:/app/data"
|
||||
- "./.logins:/app/.logins:ro"
|
||||
- "${DATA_DIR}:/data"
|
||||
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.express.rule=Host(`${SERVICE_FQDN}`) && PathPrefix(`/manage`)"
|
||||
- "traefik.http.routers.express.entrypoints=websecure"
|
||||
- "traefik.http.routers.express.tls=true"
|
||||
- "traefik.http.routers.express.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.express.service=express-svc"
|
||||
- "traefik.http.services.express-svc.loadbalancer.server.port=3000"
|
||||
- "traefik.http.routers.express.priority=10"
|
||||
# Optional HTTP redirect
|
||||
- "traefik.http.routers.express-http.rule=Host(`${SERVICE_FQDN}`) && PathPrefix(`/manage`)"
|
||||
- "traefik.http.routers.express-http.entrypoints=web"
|
||||
- "traefik.http.routers.express-http.middlewares=express-https-redirect"
|
||||
- "traefik.http.middlewares.express-https-redirect.redirectscheme.scheme=https"
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
14
expressjs/Dockerfile
Normal file
14
expressjs/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM node:20-bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY src ./src
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
CMD ["node", "src/server.js"]
|
||||
18
expressjs/package.json
Normal file
18
expressjs/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "files-lehnert-express",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"start": "node src/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"sqlite3": "^5.1.7"
|
||||
}
|
||||
}
|
||||
676
expressjs/src/server.js
Normal file
676
expressjs/src/server.js
Normal file
@@ -0,0 +1,676 @@
|
||||
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}`);
|
||||
});
|
||||
@@ -6,6 +6,8 @@ mkdir -p ./traefik
|
||||
touch traefik/acme.json
|
||||
chmod 600 traefik/acme.json
|
||||
|
||||
mkdir -p ./data
|
||||
|
||||
if [ ! -f .logins ]; then
|
||||
cp .logins.example .logins
|
||||
fi
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
FROM httpd:2.4
|
||||
|
||||
# Enable modules + configure DocumentRoot permissions, .htaccess, and icons for autoindex
|
||||
# Enable modules
|
||||
RUN sed -i \
|
||||
-e 's/^#LoadModule rewrite_module/LoadModule rewrite_module/' \
|
||||
-e 's/^#LoadModule headers_module/LoadModule headers_module/' \
|
||||
-e 's/^#LoadModule autoindex_module/LoadModule autoindex_module/' \
|
||||
-e 's/^#LoadModule alias_module/LoadModule alias_module/' \
|
||||
/usr/local/apache2/conf/httpd.conf \
|
||||
&& printf '\n# --- Custom for file listing + .htaccess ---\n\
|
||||
/usr/local/apache2/conf/httpd.conf
|
||||
|
||||
# Add custom autoindex CSS (served from /icons/)
|
||||
RUN printf '%s\n' \
|
||||
'/* Widen "Name" column in Apache autoindex */' \
|
||||
'table#indexlist td.indexcolname, table#indexlist th.indexcolname {' \
|
||||
' width: 60ch;' \
|
||||
' max-width: 60ch;' \
|
||||
'}' \
|
||||
'table#indexlist td.indexcolname a {' \
|
||||
' display: inline-block;' \
|
||||
' max-width: 60ch;' \
|
||||
'}' \
|
||||
> /usr/local/apache2/icons/autoindex-custom.css
|
||||
|
||||
# Configure autoindex + icons + .htaccess + ignore "_" entries + UTF-8
|
||||
RUN printf '\n# --- Custom for file listing + .htaccess ---\n\
|
||||
AddDefaultCharset UTF-8\n\
|
||||
\n\
|
||||
Include conf/extra/httpd-autoindex.conf\n\
|
||||
\n\
|
||||
Alias /icons/ "/usr/local/apache2/icons/"\n\
|
||||
@@ -15,8 +32,12 @@ Alias /icons/ "/usr/local/apache2/icons/"\n\
|
||||
Require all granted\n\
|
||||
</Directory>\n\
|
||||
\n\
|
||||
# Inject CSS for directory listings\n\
|
||||
IndexHeadInsert "<link rel=\\"stylesheet\\" href=\\"/icons/autoindex-custom.css\\" type=\\"text/css\\">"\n\
|
||||
\n\
|
||||
<Directory "/usr/local/apache2/htdocs">\n\
|
||||
Options Indexes FollowSymLinks\n\
|
||||
IndexIgnore _*\n\
|
||||
AllowOverride All\n\
|
||||
Require all granted\n\
|
||||
</Directory>\n' >> /usr/local/apache2/conf/httpd.conf
|
||||
|
||||
Reference in New Issue
Block a user