diff --git a/.env.example b/.env.example index a1e2ea7..1a4a08d 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,4 @@ SERVICE_FQDN=files.example.com LETSENCRYPT_EMAIL=user@example.com +DATA_DIR=/storagebox +UPLOAD_TTL_SECONDS=604800 diff --git a/docker-compose.yml b/docker-compose.yml index 82ec578..078aa12 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/expressjs/Dockerfile b/expressjs/Dockerfile new file mode 100644 index 0000000..3599684 --- /dev/null +++ b/expressjs/Dockerfile @@ -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"] diff --git a/expressjs/package.json b/expressjs/package.json new file mode 100644 index 0000000..3472872 --- /dev/null +++ b/expressjs/package.json @@ -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" + } +} diff --git a/expressjs/src/server.js b/expressjs/src/server.js new file mode 100644 index 0000000..6b0062c --- /dev/null +++ b/expressjs/src/server.js @@ -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 ` + + + + + ${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 = ` +
+
+

File Vault

+
Sign in to manage shared uploads.
+
+
+
+
+ + + +
+
+ `; + 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 = ` +
+
+

File Vault

+
Sign in to manage shared uploads.
+
+
+
+
Login failed
+
+ + + +
+
+ `; + 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 ` + + +
${item.original_name}
+
${item.stored_name}
+ + ${formatBytes(item.size_bytes)} + +
${formatTimestamp(item.expires_at)}
+
${formatCountdown(item.expires_at)} left
+ + +
+ +
+
+ + +
+ + + `; + }).join(''); + + const body = ` +
+
+

File Vault

+
Signed in as ${req.user.username}
+
+
+ +
+
+ +
+

Upload new file

+
+ + + + +
+
+
+ +
+

Current uploads

+ ${uploads.length ? ` + + + + + + + + + + + ${rows} + +
FileSizeExpiresActions
+ ` : '
No uploads yet.
'} +
+ + + `; + + 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', '

Upload not found.

')); + 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', '

Upload not found.

')); + 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', '

Not found.

')); +}); + +app.listen(port, () => { + console.log(`Express server listening on ${port} with base path ${basePath}`); +}); diff --git a/initialize.sh b/initialize.sh index dee63b9..493726a 100755 --- a/initialize.sh +++ b/initialize.sh @@ -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 diff --git a/webserver.Dockerfile b/webserver.Dockerfile index 72b1cfe..2cd1a86 100644 --- a/webserver.Dockerfile +++ b/webserver.Dockerfile @@ -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\ \n\ \n\ +# Inject CSS for directory listings\n\ +IndexHeadInsert ""\n\ +\n\ \n\ Options Indexes FollowSymLinks\n\ + IndexIgnore _*\n\ AllowOverride All\n\ Require all granted\n\ \n' >> /usr/local/apache2/conf/httpd.conf