This commit is contained in:
portakal 2026-06-16 14:16:02 +03:00
commit 654eb49502
30 changed files with 4420 additions and 0 deletions

52
.env.example Normal file
View file

@ -0,0 +1,52 @@
# ---- Server ----
PORT=8077
# Base path the app is mounted at behind nginx (used for links/redirects).
BASE_PATH=/fileShare
# Absolute public URL of the app, used to build copyable links. No trailing slash.
PUBLIC_BASE_URL=https://your.domain/fileShare
# Long random string. Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
SESSION_SECRET=
# Optional signup code required to register. Leave blank for open registration.
SIGNUP_CODE=
# 1 if running behind a reverse proxy (nginx). Enables secure cookies + correct client IPs.
TRUST_PROXY=1
# Where uploads, tmp staging and the app log live.
DATA_DIR=./data
# ---- MySQL (no auto-migration: run schema.sql yourself) ----
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=fileshare
DB_PASSWORD=
DB_NAME=fileshare
# ---- Admin ----
# The user with this email is promoted to admin on startup.
ADMIN_EMAIL=
# ---- Limits / links ----
# Reject uploads when free disk space drops below this many bytes (default 2 GiB).
MIN_FREE_BYTES=2147483648
# Per-file upload cap in bytes (default 4 GiB).
MAX_UPLOAD_BYTES=4294967296
# How long a freshly minted link stays valid, in hours.
LINK_TTL_HOURS=24
# Extensions skipped by background compression (already-compressed formats).
COMPRESS_SKIP_EXTS=jpg,jpeg,png,gif,webp,mp4,mov,mkv,avi,mp3,zip,gz,7z,rar,pdf,docx,xlsx,pptx
# ---- SMTP (email verification) ----
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
MAIL_FROM=File Share <no-reply@your.domain>
# Set to 1 to skip sending email and auto-verify accounts (handy for local dev).
DISABLE_EMAIL_VERIFICATION=0
# ---- Antivirus (ClamAV / clamd) ----
CLAMD_HOST=127.0.0.1
CLAMD_PORT=3310
# block = reject uploads when clamd is unreachable; skip = accept and mark av_status=skipped
AV_FAIL_MODE=block
# Set to 1 to disable AV scanning entirely (local dev without clamd).
DISABLE_AV=0

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
node_modules/
.env
data/uploads/
data/tmp/
*.log
npm-debug.log*
.DS_Store

99
README.md Normal file
View file

@ -0,0 +1,99 @@
# One-Time File Share
A small self-hosted file-sharing app. You upload a file, get a **one-time-use download link**, hand it to a friend — after one successful download (or once it expires) the link is dead. Files persist in your account until you delete them, so you can mint a fresh link to re-share.
Built to run on a headless Fedora box behind nginx at the sub-path `/fileShare`, alongside other apps.
## Features
- **One-time download links**, reserved atomically so exactly one download succeeds (race-safe).
- **Time-based expiry** on top of one-time use (`LINK_TTL_HOURS`).
- **Accounts** with email verification (SMTP); unverified users can't upload.
- **Per-user dashboard**: see each file's scan status, link status, expiry, copy the URL, mint a new link, or delete.
- **Multi-file uploads** are bundled into a single `.zip` behind one link.
- **Antivirus scan** on every upload via ClamAV (`clamd`); infected files are rejected and never get a link.
- **Lazy background compression** (gzip) to save disk; transparently decompressed on download.
- **Disk-space monitor** that refuses uploads when free space is low.
- **Admin dashboard** to manage all users, files and links.
- Whole site is `noindex` / unlisted.
## Requirements
- Node.js ≥ 20
- MySQL (existing server is fine)
- ClamAV `clamd` listening on TCP (optional in dev — set `DISABLE_AV=1`)
- An SMTP account for verification emails (optional in dev — set `DISABLE_EMAIL_VERIFICATION=1`)
## Setup
1. **Database** — create the schema yourself (no auto-migration):
```bash
mysql -u <user> -p <dbname> < schema.sql
```
2. **Install deps**:
```bash
npm install
```
3. **Configure**:
```bash
cp .env.example .env
# set SESSION_SECRET, DB_*, ADMIN_EMAIL, PUBLIC_BASE_URL, SMTP_*, CLAMD_*
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" # for SESSION_SECRET
```
4. **Run**:
```bash
npm start
```
The app listens on `127.0.0.1:$PORT` and mounts at `$BASE_PATH` (e.g. `http://127.0.0.1:8077/fileShare`).
## Local development (DB over SSH tunnel)
Tunnel the server's MySQL to your laptop, then point `.env` at the tunnel:
```bash
ssh -L 3307:127.0.0.1:3306 user@server
```
```
DB_HOST=127.0.0.1
DB_PORT=3307
DISABLE_AV=1 # if you don't run clamd locally
DISABLE_EMAIL_VERIFICATION=1 # auto-verifies new accounts; logs verify links
PUBLIC_BASE_URL=http://127.0.0.1:8077/fileShare
TRUST_PROXY=0
```
## Production (nginx + systemd)
- Reverse proxy: see `deploy/nginx-fileShare.conf`. The app keeps the `/fileShare` prefix, so `proxy_pass` has **no trailing slash**. Raise `client_max_body_size` to match `MAX_UPLOAD_BYTES`.
- Service: see `deploy/fileshare.service`. Set `PUBLIC_BASE_URL=https://your.domain/fileShare` and `TRUST_PROXY=1`.
- The first account whose email equals `ADMIN_EMAIL` is auto-promoted to admin on startup.
## How it behaves (worth knowing)
- **Aborted downloads still burn the link.** The token is reserved the moment a valid download starts, which is the safe reading of "one-time use." If a transfer fails, mint a new link from the dashboard.
- **Infected uploads are deleted immediately** and never stored or linked. If `clamd` is unreachable, `AV_FAIL_MODE=block` (default) rejects the upload; `skip` accepts it and marks the scan `skipped`.
- **Compression is lazy**: the upload returns instantly and a background worker gzips the file shortly after (skipping already-compressed types in `COMPRESS_SKIP_EXTS`). Downloads work whether or not compression has finished.
## Key env vars
| Var | Purpose |
|-----|---------|
| `BASE_PATH` | Sub-path mount (e.g. `/fileShare`) |
| `PUBLIC_BASE_URL` | Absolute URL used to build copyable links |
| `LINK_TTL_HOURS` | Lifetime of a freshly minted link |
| `MIN_FREE_BYTES` | Uploads refused below this free space |
| `MAX_UPLOAD_BYTES` | Per-file cap |
| `ADMIN_EMAIL` | Account promoted to admin on startup |
| `AV_FAIL_MODE` | `block` or `skip` when clamd is down |
| `DISABLE_AV` / `DISABLE_EMAIL_VERIFICATION` | Dev toggles |
## Project layout
```
src/
server.js app wiring, session, mounting, workers
config.js env parsing
db.js mysql2 pool
auth.js sessions, register/login/logout, guards, CSRF check
mailer.js SMTP verification emails
links.js link minting + per-file link state
storage.js on-disk path helpers
log.js file + stdout logger / audit trail
routes/ pages, upload, download, files, admin, verify
services/ disk, antivirus, bundle (zip), compression (gzip)
views.js server-rendered HTML
public/style.css
schema.sql run this against MySQL
deploy/ nginx + systemd samples
```

31
deploy/fileshare.service Normal file
View file

@ -0,0 +1,31 @@
# /etc/systemd/system/fileshare.service
# Adjust User, WorkingDirectory and ExecStart paths to your install.
#
# sudo cp deploy/fileshare.service /etc/systemd/system/
# sudo systemctl daemon-reload
# sudo systemctl enable --now fileshare
# sudo journalctl -u fileshare -f
[Unit]
Description=One-Time File Share
After=network.target mysqld.service
Wants=mysqld.service
[Service]
Type=simple
User=fileshare
Group=fileshare
WorkingDirectory=/opt/fileshare
EnvironmentFile=/opt/fileshare/.env
ExecStart=/usr/bin/node src/server.js
Restart=always
RestartSec=3
# Hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ReadWritePaths=/opt/fileshare/data
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,25 @@
# Add this inside the `server { ... }` block of your existing HTTPS vhost.
# The app mounts itself at /fileShare, so we must PRESERVE the path prefix —
# note there is no trailing slash / path on proxy_pass.
# Make the bare path (no trailing slash) redirect into the app.
location = /fileShare {
return 301 /fileShare/;
}
location /fileShare/ {
proxy_pass http://127.0.0.1:8077;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Must be >= MAX_UPLOAD_BYTES in .env (here: 4 GiB).
client_max_body_size 4096m;
# Stream large uploads straight to the app instead of buffering to disk first.
proxy_request_buffering off;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}

2241
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

27
package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "fileshare",
"version": "1.0.0",
"description": "One-time-use file sharing with accounts, expiry, zip bundles, AV scan, and an admin dashboard.",
"type": "module",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"archiver": "^7.0.1",
"bcryptjs": "^2.4.3",
"clamscan": "^2.4.0",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"express-mysql-session": "^3.0.3",
"express-rate-limit": "^7.4.1",
"express-session": "^1.18.1",
"multer": "^2.2.0",
"mysql2": "^3.11.5",
"nodemailer": "^9.0.0"
}
}

72
schema.sql Normal file
View file

@ -0,0 +1,72 @@
-- One-Time File Share — database schema
-- Run this once against your MySQL database, e.g.:
-- mysql -u root -p < schema.sql
-- No auto-migration happens at app startup.
--
-- This creates the database and selects it. If you already created the database
-- yourself (and your DB_NAME differs from `fileshare`), delete the two lines below
-- and instead pick the database first (in MySQL Workbench: double-click it in the
-- SCHEMAS sidebar, or run `USE your_db_name;`).
CREATE DATABASE IF NOT EXISTS fileshare CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE fileshare;
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
username VARCHAR(64) NOT NULL,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
email_verified TINYINT(1) NOT NULL DEFAULT 0,
is_admin TINYINT(1) NOT NULL DEFAULT 0,
disabled TINYINT(1) NOT NULL DEFAULT 0,
verify_token CHAR(64) NULL,
verify_sent_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_users_username (username),
UNIQUE KEY uq_users_email (email),
KEY idx_users_verify_token (verify_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS files (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
original_name VARCHAR(512) NOT NULL,
stored_name CHAR(36) NOT NULL,
ext VARCHAR(32) NULL,
mime VARCHAR(255) NULL,
size_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0,
is_bundle TINYINT(1) NOT NULL DEFAULT 0,
compressed TINYINT(1) NOT NULL DEFAULT 0,
compressing TINYINT(1) NOT NULL DEFAULT 0,
av_status ENUM('pending','clean','infected','skipped') NOT NULL DEFAULT 'pending',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_files_stored_name (stored_name),
KEY idx_files_user (user_id),
CONSTRAINT fk_files_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS download_links (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
file_id BIGINT UNSIGNED NOT NULL,
token CHAR(43) NOT NULL,
used TINYINT(1) NOT NULL DEFAULT 0,
used_at DATETIME NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_links_token (token),
KEY idx_links_file (file_id),
CONSTRAINT fk_links_file FOREIGN KEY (file_id) REFERENCES files (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Session store table required by express-mysql-session.
CREATE TABLE IF NOT EXISTS sessions (
session_id VARCHAR(128) COLLATE utf8mb4_bin NOT NULL,
expires INT UNSIGNED NOT NULL,
data MEDIUMTEXT COLLATE utf8mb4_bin,
PRIMARY KEY (session_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

201
src/auth.js Normal file
View file

@ -0,0 +1,201 @@
import crypto from 'node:crypto';
import express from 'express';
import bcrypt from 'bcryptjs';
import rateLimit from 'express-rate-limit';
import config from './config.js';
import { queryOne, execute } from './db.js';
import { sendVerification, emailEnabled } from './mailer.js';
import { ah } from './util.js';
import log from './log.js';
// ---- Helpers ----
export function setFlash(req, type, msg) {
req.session.flash = { type, msg };
}
// Loads the logged-in user onto req.user and exposes flash to views.
export async function attachUser(req, res, next) {
res.locals.flash = req.session.flash || null;
delete req.session.flash;
if (req.session.userId) {
const user = await queryOne(
'SELECT id, username, email, email_verified, is_admin, disabled FROM users WHERE id = ?',
[req.session.userId]
);
if (user && !user.disabled) {
req.user = user;
} else {
// Session points at a deleted/disabled account — drop it.
req.session.userId = null;
}
}
next();
}
export function requireAuth(req, res, next) {
if (!req.user) {
setFlash(req, 'error', 'Please log in.');
return res.redirect(config.url('/login'));
}
next();
}
export function requireVerified(req, res, next) {
if (!req.user) {
setFlash(req, 'error', 'Please log in.');
return res.redirect(config.url('/login'));
}
if (!req.user.email_verified) {
setFlash(req, 'error', 'Verify your email before uploading.');
return res.redirect(config.url('/dashboard'));
}
next();
}
export function requireAdmin(req, res, next) {
if (!req.user) return res.redirect(config.url('/login'));
if (!req.user.is_admin) return res.status(403).send('Forbidden');
next();
}
// Lightweight CSRF defense: state-changing requests must originate same-host.
export function checkOrigin(req, res, next) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
const origin = req.get('origin') || req.get('referer');
if (origin) {
try {
const host = new URL(origin).host;
if (host === req.get('host')) return next();
} catch {
/* fall through */
}
return res.status(403).send('Bad origin');
}
// No Origin/Referer header (e.g. some curl uploads) — allow; SameSite cookie still guards browsers.
next();
}
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
});
// Constant cost reference: when the username doesn't exist we still run a bcrypt
// comparison against this dummy hash so response timing can't reveal which usernames
// are registered.
const DUMMY_HASH = bcrypt.hashSync('timing-equalizer', 12);
// ---- Router (POST login/register/logout) ----
const router = express.Router();
router.post('/login', authLimiter, checkOrigin, ah(async (req, res) => {
const identifier = (req.body.identifier || '').trim();
const password = req.body.password || '';
const user = await queryOne(
'SELECT * FROM users WHERE username = ? OR email = ? LIMIT 1',
[identifier, identifier.toLowerCase()]
);
// Always run a comparison (dummy hash when no user) to keep timing constant.
const match = await bcrypt.compare(password, user ? user.password_hash : DUMMY_HASH);
const ok = user && match;
if (!ok) {
setFlash(req, 'error', 'Invalid credentials.');
return res.redirect(config.url('/login'));
}
if (user.disabled) {
setFlash(req, 'error', 'This account is disabled.');
return res.redirect(config.url('/login'));
}
if (!user.email_verified) {
setFlash(req, 'error', 'Please verify your email first. Check your inbox.');
return res.redirect(config.url('/login'));
}
// Regenerate to prevent session fixation, then explicitly save BEFORE redirecting.
// The explicit save guarantees the session row (with userId) is committed to the
// store before the client follows the redirect — otherwise the very next request
// can race the async store write and briefly appear logged out.
req.session.regenerate((err) => {
if (err) {
setFlash(req, 'error', 'Login failed, try again.');
return res.redirect(config.url('/login'));
}
req.session.userId = user.id;
req.session.save((err2) => {
if (err2) {
log.error('session save failed on login', { err: String(err2) });
setFlash(req, 'error', 'Login failed, try again.');
return res.redirect(config.url('/login'));
}
log.info('login', { userId: user.id });
res.redirect(config.url('/dashboard'));
});
});
}));
router.post('/register', authLimiter, checkOrigin, ah(async (req, res) => {
const username = (req.body.username || '').trim();
const email = (req.body.email || '').trim().toLowerCase();
const password = req.body.password || '';
if (config.signupCode && req.body.signup_code !== config.signupCode) {
setFlash(req, 'error', 'Invalid signup code.');
return res.redirect(config.url('/register'));
}
if (username.length < 3 || username.length > 64) {
setFlash(req, 'error', 'Username must be 364 characters.');
return res.redirect(config.url('/register'));
}
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
setFlash(req, 'error', 'Enter a valid email.');
return res.redirect(config.url('/register'));
}
if (password.length < 8) {
setFlash(req, 'error', 'Password must be at least 8 characters.');
return res.redirect(config.url('/register'));
}
const existing = await queryOne(
'SELECT id FROM users WHERE username = ? OR email = ?',
[username, email]
);
if (existing) {
setFlash(req, 'error', 'Username or email already in use.');
return res.redirect(config.url('/register'));
}
const hash = await bcrypt.hash(password, 12);
const autoVerify = !emailEnabled(); // if email is disabled, verify immediately
const token = crypto.randomBytes(32).toString('hex');
const isAdmin = config.adminEmail && email === config.adminEmail ? 1 : 0;
await execute(
`INSERT INTO users (username, email, password_hash, email_verified, is_admin, verify_token, verify_sent_at)
VALUES (?, ?, ?, ?, ?, ?, NOW())`,
[username, email, hash, autoVerify ? 1 : 0, isAdmin, autoVerify ? null : token]
);
log.info('register', { username, email, autoVerify });
if (autoVerify) {
setFlash(req, 'ok', 'Account created. You can log in now.');
} else {
try {
await sendVerification(email, token);
setFlash(req, 'ok', 'Account created. Check your email to verify.');
} catch (e) {
log.error('verification email failed', { email, err: String(e) });
setFlash(req, 'ok', 'Account created, but the verification email failed to send. Ask an admin to verify you.');
}
}
res.redirect(config.url('/login'));
}));
router.post('/logout', (req, res) => {
req.session.destroy(() => res.redirect(config.url('/login')));
});
export const hashPassword = (pw) => bcrypt.hash(pw, 12);
export default router;

87
src/config.js Normal file
View file

@ -0,0 +1,87 @@
import 'dotenv/config';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..');
function bool(v, def = false) {
if (v === undefined || v === '') return def;
return ['1', 'true', 'yes', 'on'].includes(String(v).toLowerCase());
}
function int(v, def) {
const n = Number.parseInt(v, 10);
return Number.isFinite(n) ? n : def;
}
function required(name, value) {
if (!value) {
console.error(`[config] Missing required env var: ${name}`);
process.exit(1);
}
return value;
}
const dataDir = path.resolve(projectRoot, process.env.DATA_DIR || './data');
// Normalise BASE_PATH to "" or "/foo" (no trailing slash).
let basePath = process.env.BASE_PATH || '';
if (basePath && !basePath.startsWith('/')) basePath = '/' + basePath;
basePath = basePath.replace(/\/+$/, '');
const config = {
projectRoot,
port: int(process.env.PORT, 8077),
basePath,
publicBaseUrl: (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''),
sessionSecret: required('SESSION_SECRET', process.env.SESSION_SECRET),
signupCode: process.env.SIGNUP_CODE || '',
trustProxy: bool(process.env.TRUST_PROXY, false),
dataDir,
uploadsDir: path.join(dataDir, 'uploads'),
tmpDir: path.join(dataDir, 'tmp'),
logFile: path.join(dataDir, 'app.log'),
db: {
host: process.env.DB_HOST || '127.0.0.1',
port: int(process.env.DB_PORT, 3306),
user: required('DB_USER', process.env.DB_USER),
password: process.env.DB_PASSWORD || '',
database: required('DB_NAME', process.env.DB_NAME),
},
adminEmail: (process.env.ADMIN_EMAIL || '').trim().toLowerCase(),
minFreeBytes: int(process.env.MIN_FREE_BYTES, 2 * 1024 ** 3),
maxUploadBytes: int(process.env.MAX_UPLOAD_BYTES, 4 * 1024 ** 3),
linkTtlHours: int(process.env.LINK_TTL_HOURS, 24),
compressSkipExts: (process.env.COMPRESS_SKIP_EXTS || '')
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
smtp: {
host: process.env.SMTP_HOST || '',
port: int(process.env.SMTP_PORT, 587),
user: process.env.SMTP_USER || '',
pass: process.env.SMTP_PASS || '',
from: process.env.MAIL_FROM || 'File Share <no-reply@localhost>',
},
disableEmailVerification: bool(process.env.DISABLE_EMAIL_VERIFICATION, false),
clamd: {
host: process.env.CLAMD_HOST || '127.0.0.1',
port: int(process.env.CLAMD_PORT, 3310),
},
avFailMode: (process.env.AV_FAIL_MODE || 'block').toLowerCase(), // 'block' | 'skip'
disableAv: bool(process.env.DISABLE_AV, false),
};
// Build a URL under the public base (path part includes basePath for same-origin links).
config.url = (p = '') => `${config.basePath}${p.startsWith('/') ? p : '/' + p}`;
config.absoluteUrl = (p = '') =>
`${config.publicBaseUrl}${p.startsWith('/') ? p : '/' + p}`;
export default config;

43
src/db.js Normal file
View file

@ -0,0 +1,43 @@
import mysql from 'mysql2/promise';
import config from './config.js';
// Shared connection pool. The schema is created out-of-band via schema.sql.
const pool = mysql.createPool({
host: config.db.host,
port: config.db.port,
user: config.db.user,
password: config.db.password,
database: config.db.database,
waitForConnections: true,
connectionLimit: 10,
namedPlaceholders: false,
timezone: 'Z',
dateStrings: false,
});
export async function query(sql, params = []) {
const [rows] = await pool.execute(sql, params);
return rows;
}
export async function queryOne(sql, params = []) {
const rows = await query(sql, params);
return rows[0] || null;
}
// Returns the number of affected rows for INSERT/UPDATE/DELETE.
export async function execute(sql, params = []) {
const [result] = await pool.execute(sql, params);
return result;
}
export async function ping() {
const conn = await pool.getConnection();
try {
await conn.ping();
} finally {
conn.release();
}
}
export default pool;

72
src/links.js Normal file
View file

@ -0,0 +1,72 @@
import crypto from 'node:crypto';
import config from './config.js';
import { query, queryOne, execute } from './db.js';
export function newToken() {
return crypto.randomBytes(32).toString('base64url'); // 43 url-safe chars
}
// Invalidates any still-active links for a file, then mints a fresh one-time link.
export async function mintLink(fileId) {
await execute(
'UPDATE download_links SET expires_at = NOW() WHERE file_id = ? AND used = 0 AND expires_at > NOW()',
[fileId]
);
const token = newToken();
// TTL is a validated integer from config (not user input), so it is safe to inline.
// Inlining avoids the mysql2 prepared-statement quirk with `INTERVAL ? HOUR` and keeps
// both the insert and the download check on server-side NOW() (no timezone skew).
const ttl = Math.max(1, Math.trunc(config.linkTtlHours));
await execute(
`INSERT INTO download_links (file_id, token, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL ${ttl} HOUR))`,
[fileId, token]
);
return token;
}
// SQL fragment that derives the newest link per file and its state.
// Exposes columns: link_id, token, used, expires_at, link_state.
const LATEST_LINK_JOIN = `
LEFT JOIN download_links dl ON dl.id = (
SELECT id FROM download_links d2 WHERE d2.file_id = f.id ORDER BY d2.created_at DESC, d2.id DESC LIMIT 1
)
`;
const LINK_STATE_EXPR = `
CASE
WHEN dl.id IS NULL THEN 'none'
WHEN dl.used = 1 THEN 'downloaded'
WHEN dl.expires_at <= NOW() THEN 'expired'
ELSE 'active'
END AS link_state
`;
export async function filesForUser(userId) {
return query(
`SELECT f.*, dl.id AS link_id, dl.token, dl.used, dl.expires_at, ${LINK_STATE_EXPR}
FROM files f ${LATEST_LINK_JOIN}
WHERE f.user_id = ?
ORDER BY f.created_at DESC`,
[userId]
);
}
export async function allFilesWithOwner() {
return query(
`SELECT f.*, u.username, dl.id AS link_id, dl.token, dl.used, dl.expires_at, ${LINK_STATE_EXPR}
FROM files f
JOIN users u ON u.id = f.user_id
${LATEST_LINK_JOIN}
ORDER BY f.created_at DESC
LIMIT 500`
);
}
// Token is shown only when the link is genuinely active (unused & unexpired).
export function activeToken(file) {
return file.link_state === 'active' ? file.token : null;
}
export async function getFile(fileId) {
return queryOne('SELECT * FROM files WHERE id = ?', [fileId]);
}

28
src/log.js Normal file
View file

@ -0,0 +1,28 @@
import fs from 'node:fs';
import config from './config.js';
// Minimal append-only logger. Writes a timestamped line to data/app.log and stdout.
function write(level, msg, meta) {
const line =
`${new Date().toISOString()} [${level}] ${msg}` +
(meta ? ' ' + JSON.stringify(meta) : '') +
'\n';
try {
fs.appendFileSync(config.logFile, line);
} catch {
// best-effort; never crash on logging
}
if (level === 'ERROR') process.stderr.write(line);
else process.stdout.write(line);
}
export const log = {
info: (msg, meta) => write('INFO', msg, meta),
warn: (msg, meta) => write('WARN', msg, meta),
error: (msg, meta) => write('ERROR', msg, meta),
// Audit trail for admin/security-relevant actions.
audit: (actor, action, meta) =>
write('AUDIT', `${actor} ${action}`, meta),
};
export default log;

41
src/mailer.js Normal file
View file

@ -0,0 +1,41 @@
import nodemailer from 'nodemailer';
import config from './config.js';
import log from './log.js';
let transport = null;
if (!config.disableEmailVerification && config.smtp.host) {
transport = nodemailer.createTransport({
host: config.smtp.host,
port: config.smtp.port,
secure: config.smtp.port === 465,
auth: config.smtp.user
? { user: config.smtp.user, pass: config.smtp.pass }
: undefined,
});
}
export function emailEnabled() {
return !config.disableEmailVerification && !!transport;
}
export async function sendVerification(email, token) {
const link = config.absoluteUrl(`/verify/${token}`);
if (!emailEnabled()) {
// Dev fallback: log the link instead of sending.
log.warn('Email disabled — verification link', { email, link });
return;
}
await transport.sendMail({
from: config.smtp.from,
to: email,
subject: 'Verify your File Share account',
text: `Welcome!\n\nConfirm your email to start uploading:\n${link}\n\nIf you did not sign up, ignore this message.`,
html: `<p>Welcome!</p><p>Confirm your email to start uploading:</p>
<p><a href="${link}">${link}</a></p>
<p>If you did not sign up, ignore this message.</p>`,
});
log.info('Verification email sent', { email });
}
export default { sendVerification, emailEnabled };

20
src/public/app.js Normal file
View file

@ -0,0 +1,20 @@
// Tiny progressive-enhancement script. Kept external (no inline handlers) so the
// Content-Security-Policy can forbid inline scripts.
(function () {
// Select-all when a read-only link field is clicked, for easy copying.
document.addEventListener('click', function (e) {
const t = e.target;
if (t && t.classList && t.classList.contains('copyfield')) {
t.select();
}
});
// Confirmation prompt for destructive forms marked with data-confirm.
document.addEventListener('submit', function (e) {
const form = e.target;
const msg = form.getAttribute && form.getAttribute('data-confirm');
if (msg && !window.confirm(msg)) {
e.preventDefault();
}
});
})();

104
src/public/style.css Normal file
View file

@ -0,0 +1,104 @@
:root {
--bg: #0f1115;
--panel: #1a1d24;
--panel2: #232733;
--text: #e7e9ee;
--muted: #9aa3b2;
--accent: #4c8dff;
--accent2: #2f6fe0;
--ok: #2ecc71;
--danger: #ff5a5a;
--border: #2c313c;
}
* { box-sizing: border-box; }
body {
margin: 0;
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: var(--bg);
color: var(--text);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
.brand { font-weight: 700; font-size: 18px; color: var(--text); text-decoration: none; }
nav { display: flex; align-items: center; gap: 14px; }
nav a { color: var(--muted); text-decoration: none; }
nav a:hover, nav a.on { color: var(--text); }
nav .who { color: var(--accent); font-weight: 600; }
main { max-width: 920px; margin: 24px auto; padding: 0 16px; }
footer { text-align: center; color: var(--muted); padding: 30px 0; font-size: 13px; }
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
margin-bottom: 18px;
}
.card.narrow { max-width: 420px; margin: 40px auto; }
h1 { font-size: 19px; margin: 0 0 14px; }
label { display: block; margin-bottom: 12px; font-size: 13px; color: var(--muted); }
input[type=text], input[type=email], input[type=password], input:not([type]) {
display: block; width: 100%; margin-top: 4px; padding: 9px 11px;
background: var(--panel2); border: 1px solid var(--border); border-radius: 8px;
color: var(--text); font-size: 14px;
}
input[type=file] { color: var(--muted); margin-bottom: 8px; }
.btn {
display: inline-block; cursor: pointer;
background: var(--accent); color: #fff; border: 0; border-radius: 8px;
padding: 9px 16px; font-size: 14px; font-weight: 600; text-decoration: none;
}
.btn:hover { background: var(--accent2); }
.btn.small { padding: 5px 10px; font-size: 12px; }
.btn.danger { background: var(--danger); }
.btn[disabled] { opacity: .4; cursor: not-allowed; }
button.link {
background: none; border: 0; color: var(--accent); cursor: pointer;
font: inherit; padding: 0; text-decoration: underline;
}
.inline { display: inline; }
.muted { color: var(--muted); }
.flash { padding: 11px 14px; border-radius: 8px; margin-bottom: 16px; font-size: 14px; }
.flash.ok { background: rgba(46,204,113,.12); border: 1px solid var(--ok); }
.flash.error { background: rgba(255,90,90,.12); border: 1px solid var(--danger); }
table.files { width: 100%; border-collapse: collapse; font-size: 14px; }
table.files th { text-align: left; color: var(--muted); font-weight: 600; padding: 8px; border-bottom: 1px solid var(--border); }
table.files td { padding: 10px 8px; border-bottom: 1px solid var(--border); vertical-align: top; }
.fname { font-weight: 600; word-break: break-all; }
.sub { color: var(--muted); font-size: 12px; margin-top: 2px; }
.actions { white-space: nowrap; }
.actions form { display: inline-block; margin-left: 4px; }
.linkcell { width: 280px; }
.copyfield {
width: 100%; font-size: 12px; padding: 6px 8px; background: var(--panel2);
border: 1px solid var(--border); border-radius: 6px; color: var(--text);
}
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; }
.badge.active { background: rgba(46,204,113,.15); color: var(--ok); }
.badge.downloaded { background: rgba(76,141,255,.15); color: var(--accent); }
.badge.expired, .badge.none { background: rgba(154,163,178,.15); color: var(--muted); }
.badge.av-clean { background: rgba(46,204,113,.15); color: var(--ok); }
.badge.av-pending { background: rgba(154,163,178,.15); color: var(--muted); }
.badge.av-infected { background: rgba(255,90,90,.15); color: var(--danger); }
.badge.av-skipped { background: rgba(255,184,77,.15); color: #ffb84d; }
.badge.admin { background: rgba(76,141,255,.15); color: var(--accent); }
.diskbar { display: flex; align-items: center; gap: 14px; font-size: 13px; color: var(--muted); }
.meter { flex: 1; height: 10px; background: var(--panel2); border-radius: 999px; overflow: hidden; }
.meter span { display: block; height: 100%; background: var(--accent); }
.stats { display: flex; flex-wrap: wrap; gap: 14px; margin-bottom: 18px; }
.stat { background: var(--panel2); border-radius: 10px; padding: 14px 18px; min-width: 110px; }
.stat b { display: block; font-size: 22px; }
.stat span { color: var(--muted); font-size: 12px; }

171
src/routes/admin.js Normal file
View file

@ -0,0 +1,171 @@
import express from 'express';
import config from '../config.js';
import { query, queryOne, execute } from '../db.js';
import { deleteStored } from '../storage.js';
import { mintLink, allFilesWithOwner } from '../links.js';
import { getDiskInfo } from '../services/disk.js';
import { queueDepth } from '../services/compression.js';
import { requireAdmin, checkOrigin, setFlash } from '../auth.js';
import { adminOverviewPage, adminUsersPage, adminFilesPage } from '../views.js';
import { ah } from '../util.js';
import log from '../log.js';
const router = express.Router();
router.use(requireAdmin);
router.get(
'/',
ah(async (req, res) => {
const [counts, disk] = await Promise.all([
queryOne(`SELECT
(SELECT COUNT(*) FROM users) AS users,
(SELECT COUNT(*) FROM files) AS files,
(SELECT COUNT(*) FROM download_links WHERE used = 0 AND expires_at > NOW()) AS activeLinks,
(SELECT COUNT(*) FROM download_links WHERE used = 1) AS usedLinks,
(SELECT COUNT(*) FROM download_links WHERE used = 0 AND expires_at <= NOW()) AS expiredLinks,
(SELECT COUNT(*) FROM files WHERE av_status = 'infected') AS infected`),
getDiskInfo(),
]);
res.send(
adminOverviewPage({
user: req.user,
stats: { ...counts, queueDepth: queueDepth() },
disk,
flash: res.locals.flash,
})
);
})
);
router.get(
'/users',
ah(async (req, res) => {
const users = await query(
`SELECT u.id, u.username, u.email, u.is_admin, u.disabled, u.email_verified,
COUNT(f.id) AS file_count, COALESCE(SUM(f.size_bytes), 0) AS bytes_used
FROM users u
LEFT JOIN files f ON f.user_id = u.id
GROUP BY u.id
ORDER BY u.created_at DESC`
);
res.send(adminUsersPage({ user: req.user, users, flash: res.locals.flash }));
})
);
router.get(
'/files',
ah(async (req, res) => {
const files = await allFilesWithOwner();
res.send(adminFilesPage({ user: req.user, files, flash: res.locals.flash }));
})
);
// ---- Mutations ----
router.use(checkOrigin);
router.post(
'/users/:id/verify',
ah(async (req, res) => {
await execute(
'UPDATE users SET email_verified = 1, verify_token = NULL WHERE id = ?',
[req.params.id]
);
audit(req, 'admin.user.verify', { targetUser: req.params.id });
setFlash(req, 'ok', 'User verified.');
res.redirect(config.url('/admin/users'));
})
);
router.post(
'/users/:id/disable',
ah(async (req, res) => {
if (Number(req.params.id) === Number(req.user.id)) {
setFlash(req, 'error', 'You cannot disable your own account.');
return res.redirect(config.url('/admin/users'));
}
await execute('UPDATE users SET disabled = 1 WHERE id = ?', [req.params.id]);
audit(req, 'admin.user.disable', { targetUser: req.params.id });
setFlash(req, 'ok', 'User disabled.');
res.redirect(config.url('/admin/users'));
})
);
router.post(
'/users/:id/enable',
ah(async (req, res) => {
await execute('UPDATE users SET disabled = 0 WHERE id = ?', [req.params.id]);
audit(req, 'admin.user.enable', { targetUser: req.params.id });
setFlash(req, 'ok', 'User enabled.');
res.redirect(config.url('/admin/users'));
})
);
router.post(
'/users/:id/delete',
ah(async (req, res) => {
if (Number(req.params.id) === Number(req.user.id)) {
setFlash(req, 'error', 'You cannot delete your own account.');
return res.redirect(config.url('/admin/users'));
}
const files = await query('SELECT stored_name FROM files WHERE user_id = ?', [
req.params.id,
]);
for (const f of files) await deleteStored(f.stored_name);
await execute('DELETE FROM users WHERE id = ?', [req.params.id]); // cascades files+links
audit(req, 'admin.user.delete', { targetUser: req.params.id, files: files.length });
setFlash(req, 'ok', 'User and their files deleted.');
res.redirect(config.url('/admin/users'));
})
);
router.post(
'/files/:id/delete',
ah(async (req, res) => {
const file = await queryOne('SELECT stored_name FROM files WHERE id = ?', [
req.params.id,
]);
if (file) {
await deleteStored(file.stored_name);
await execute('DELETE FROM files WHERE id = ?', [req.params.id]);
audit(req, 'admin.file.delete', { fileId: req.params.id });
setFlash(req, 'ok', 'File deleted.');
}
res.redirect(config.url('/admin/files'));
})
);
router.post(
'/files/:id/link',
ah(async (req, res) => {
const file = await queryOne('SELECT id, av_status FROM files WHERE id = ?', [
req.params.id,
]);
if (!file) {
setFlash(req, 'error', 'File not found.');
return res.redirect(config.url('/admin/files'));
}
const token = await mintLink(file.id);
audit(req, 'admin.file.link', { fileId: file.id });
setFlash(req, 'ok', `New link: ${config.absoluteUrl(`/d/${token}`)}`);
res.redirect(config.url('/admin/files'));
})
);
router.post(
'/links/:id/expire',
ah(async (req, res) => {
await execute('UPDATE download_links SET expires_at = NOW() WHERE id = ?', [
req.params.id,
]);
audit(req, 'admin.link.expire', { linkId: req.params.id });
setFlash(req, 'ok', 'Link expired.');
res.redirect(config.url('/admin/files'));
})
);
function audit(req, action, meta) {
log.audit(`user#${req.user.id}(${req.user.username})`, action, meta);
}
export default router;

67
src/routes/download.js Normal file
View file

@ -0,0 +1,67 @@
import fs from 'node:fs';
import zlib from 'node:zlib';
import { pipeline } from 'node:stream/promises';
import express from 'express';
import { queryOne, execute } from '../db.js';
import { rawPath, gzPath } from '../storage.js';
import { gonePage } from '../views.js';
import { ah } from '../util.js';
import log from '../log.js';
const router = express.Router();
function contentDisposition(name) {
const ascii = name.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`;
}
router.get('/d/:token', ah(async (req, res) => {
const token = req.params.token || '';
// Atomically reserve the one-time link: succeeds only if unused AND unexpired.
const result = await execute(
'UPDATE download_links SET used = 1, used_at = NOW() WHERE token = ? AND used = 0 AND expires_at > NOW()',
[token]
);
if (result.affectedRows !== 1) {
return res.status(410).send(gonePage());
}
const file = await queryOne(
`SELECT f.* FROM files f JOIN download_links dl ON dl.file_id = f.id WHERE dl.token = ?`,
[token]
);
if (!file) {
// Link existed but file vanished — treat as gone.
return res.status(410).send(gonePage());
}
const compressed = !!file.compressed && fs.existsSync(gzPath(file.stored_name));
const srcPath = compressed ? gzPath(file.stored_name) : rawPath(file.stored_name);
if (!fs.existsSync(srcPath)) {
log.error('download: bytes missing', { fileId: file.id, srcPath });
return res.status(410).send(gonePage());
}
res.setHeader('Content-Type', file.mime || 'application/octet-stream');
res.setHeader('Content-Disposition', contentDisposition(file.original_name));
// Only set a length when serving raw bytes; decompressed size is unknown up front.
if (!compressed) res.setHeader('Content-Length', String(file.size_bytes));
log.audit('anon', 'download', { fileId: file.id });
try {
if (compressed) {
await pipeline(fs.createReadStream(srcPath), zlib.createGunzip(), res);
} else {
await pipeline(fs.createReadStream(srcPath), res);
}
} catch (e) {
// Client aborted or stream error; link is already burned by design.
log.warn('download stream ended', { fileId: file.id, err: String(e) });
if (!res.headersSent) res.status(500).end();
else res.destroy();
}
}));
export default router;

42
src/routes/files.js Normal file
View file

@ -0,0 +1,42 @@
import express from 'express';
import config from '../config.js';
import { execute } from '../db.js';
import { deleteStored } from '../storage.js';
import { mintLink, getFile } from '../links.js';
import { setFlash, requireVerified, checkOrigin } from '../auth.js';
import { ah } from '../util.js';
import log from '../log.js';
const router = express.Router();
// Owner mints a fresh one-time link for a kept file.
router.post('/files/:id/link', requireVerified, checkOrigin, ah(async (req, res) => {
const file = await getFile(req.params.id);
if (!file || file.user_id !== req.user.id) {
setFlash(req, 'error', 'File not found.');
return res.redirect(config.url('/dashboard'));
}
if (file.av_status === 'infected') {
setFlash(req, 'error', 'Cannot share a file that failed the virus scan.');
return res.redirect(config.url('/dashboard'));
}
const token = await mintLink(file.id);
req.session.newLink = config.absoluteUrl(`/d/${token}`);
log.info('link minted', { userId: req.user.id, fileId: file.id });
res.redirect(config.url('/dashboard'));
}));
router.post('/files/:id/delete', requireVerified, checkOrigin, ah(async (req, res) => {
const file = await getFile(req.params.id);
if (!file || file.user_id !== req.user.id) {
setFlash(req, 'error', 'File not found.');
return res.redirect(config.url('/dashboard'));
}
await deleteStored(file.stored_name);
await execute('DELETE FROM files WHERE id = ?', [file.id]); // cascades links
setFlash(req, 'ok', 'File deleted.');
log.info('file deleted', { userId: req.user.id, fileId: file.id });
res.redirect(config.url('/dashboard'));
}));
export default router;

44
src/routes/pages.js Normal file
View file

@ -0,0 +1,44 @@
import express from 'express';
import config from '../config.js';
import { requireAuth } from '../auth.js';
import { filesForUser } from '../links.js';
import { getDiskInfo } from '../services/disk.js';
import { loginPage, registerPage, dashboardPage } from '../views.js';
import { ah } from '../util.js';
const router = express.Router();
router.get('/', (req, res) => {
if (req.user) return res.redirect(config.url('/dashboard'));
res.redirect(config.url('/login'));
});
router.get('/login', (req, res) => {
if (req.user) return res.redirect(config.url('/dashboard'));
res.send(loginPage({ user: req.user, flash: res.locals.flash }));
});
router.get('/register', (req, res) => {
if (req.user) return res.redirect(config.url('/dashboard'));
res.send(registerPage({ user: req.user, flash: res.locals.flash }));
});
router.get('/dashboard', requireAuth, ah(async (req, res) => {
const [files, disk] = await Promise.all([
filesForUser(req.user.id),
getDiskInfo(),
]);
const newLink = req.session.newLink || null;
delete req.session.newLink;
res.send(
dashboardPage({
user: req.user,
files,
disk,
flash: res.locals.flash,
newLink,
})
);
}));
export default router;

123
src/routes/upload.js Normal file
View file

@ -0,0 +1,123 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import express from 'express';
import multer from 'multer';
import config from '../config.js';
import { execute } from '../db.js';
import { rawPath, deleteStored } from '../storage.js';
import { hasRoomFor } from '../services/disk.js';
import { scanFile } from '../services/antivirus.js';
import { bundleToZip } from '../services/bundle.js';
import { enqueue } from '../services/compression.js';
import { mintLink } from '../links.js';
import { setFlash, requireVerified, checkOrigin } from '../auth.js';
import { ah } from '../util.js';
import log from '../log.js';
const router = express.Router();
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, config.tmpDir),
filename: (req, file, cb) => cb(null, crypto.randomUUID()),
});
const MAX_FILES = 20;
const upload = multer({
storage,
limits: { fileSize: config.maxUploadBytes, files: MAX_FILES },
});
// Reject early if the disk is already below the free-space floor.
const diskGuard = ah(async (req, res, next) => {
if (!(await hasRoomFor(0))) {
return res
.status(507)
.send('Insufficient storage on the server. Please try again later.');
}
next();
});
async function cleanupTmp(files) {
for (const f of files || []) {
await fs.promises.unlink(f.path).catch(() => {});
}
}
router.post(
'/upload',
requireVerified,
checkOrigin,
diskGuard,
upload.array('files', MAX_FILES),
ah(async (req, res) => {
const files = req.files || [];
if (!files.length) {
setFlash(req, 'error', 'No files selected.');
return res.redirect(config.url('/dashboard'));
}
const storedName = crypto.randomUUID();
let originalName;
let ext;
let mime;
let isBundle = 0;
let sizeBytes;
try {
if (files.length === 1) {
const f = files[0];
await fs.promises.rename(f.path, rawPath(storedName));
originalName = f.originalname;
ext = path.extname(f.originalname).slice(1).toLowerCase();
mime = f.mimetype || 'application/octet-stream';
sizeBytes = f.size;
} else {
sizeBytes = await bundleToZip(files, rawPath(storedName));
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
originalName = `bundle-${stamp}.zip`;
ext = 'zip';
mime = 'application/zip';
isBundle = 1;
await cleanupTmp(files);
}
} catch (e) {
log.error('upload store failed', { err: String(e) });
await cleanupTmp(files);
await deleteStored(storedName);
setFlash(req, 'error', 'Upload failed while saving the file.');
return res.redirect(config.url('/dashboard'));
}
// Antivirus gate — no link is issued until the file is cleared.
let avStatus;
try {
avStatus = await scanFile(rawPath(storedName));
} catch (e) {
await deleteStored(storedName);
setFlash(req, 'error', 'Upload rejected: virus scanner is unavailable.');
return res.redirect(config.url('/dashboard'));
}
if (avStatus === 'infected') {
await deleteStored(storedName);
log.warn('upload blocked: infected', { userId: req.user.id, originalName });
setFlash(req, 'error', 'Upload rejected: the file failed the virus scan.');
return res.redirect(config.url('/dashboard'));
}
const result = await execute(
`INSERT INTO files (user_id, original_name, stored_name, ext, mime, size_bytes, is_bundle, av_status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[req.user.id, originalName, storedName, ext, mime, sizeBytes, isBundle, avStatus]
);
const fileId = result.insertId;
const token = await mintLink(fileId);
enqueue(fileId); // lazy background compression
log.info('upload', { userId: req.user.id, fileId, sizeBytes, avStatus, isBundle });
req.session.newLink = config.absoluteUrl(`/d/${token}`);
res.redirect(config.url('/dashboard'));
})
);
export default router;

71
src/routes/verify.js Normal file
View file

@ -0,0 +1,71 @@
import crypto from 'node:crypto';
import express from 'express';
import config from '../config.js';
import { queryOne, execute } from '../db.js';
import { sendVerification, emailEnabled } from '../mailer.js';
import { setFlash, checkOrigin } from '../auth.js';
import { messagePage } from '../views.js';
import { ah } from '../util.js';
import log from '../log.js';
const router = express.Router();
router.get('/verify/:token', ah(async (req, res) => {
const token = req.params.token || '';
const user = await queryOne(
'SELECT id FROM users WHERE verify_token = ? AND email_verified = 0',
[token]
);
if (!user) {
return res
.status(400)
.send(
messagePage({
title: 'Verification',
heading: 'Invalid or expired verification link',
text: 'This link is no longer valid. Try logging in, or request a new one.',
link: { href: config.url('/login'), label: 'Go to login' },
user: req.user,
})
);
}
await execute(
'UPDATE users SET email_verified = 1, verify_token = NULL WHERE id = ?',
[user.id]
);
log.info('email verified', { userId: user.id });
res.send(
messagePage({
title: 'Verified',
heading: 'Email verified 🎉',
text: 'Your account is active. You can log in and start sharing files.',
link: { href: config.url('/login'), label: 'Log in' },
user: req.user,
})
);
}));
// Resend verification for the logged-in (but unverified) user.
router.post('/verify/resend', checkOrigin, ah(async (req, res) => {
if (!req.user) return res.redirect(config.url('/login'));
if (req.user.email_verified) return res.redirect(config.url('/dashboard'));
if (!emailEnabled()) {
setFlash(req, 'error', 'Email is disabled on this server. Ask an admin to verify you.');
return res.redirect(config.url('/dashboard'));
}
const token = crypto.randomBytes(32).toString('hex');
await execute(
'UPDATE users SET verify_token = ?, verify_sent_at = NOW() WHERE id = ?',
[token, req.user.id]
);
try {
await sendVerification(req.user.email, token);
setFlash(req, 'ok', 'Verification email sent.');
} catch (e) {
log.error('resend verification failed', { err: String(e) });
setFlash(req, 'error', 'Could not send email right now.');
}
res.redirect(config.url('/dashboard'));
}));
export default router;

189
src/server.js Normal file
View file

@ -0,0 +1,189 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import express from 'express';
import session from 'express-session';
import MySQLStoreFactory from 'express-mysql-session';
import config from './config.js';
import log from './log.js';
import { ping, execute } from './db.js';
import { attachUser } from './auth.js';
import authRouter from './auth.js';
import { ah } from './util.js';
import pagesRouter from './routes/pages.js';
import verifyRouter from './routes/verify.js';
import uploadRouter from './routes/upload.js';
import filesRouter from './routes/files.js';
import adminRouter from './routes/admin.js';
import downloadRouter from './routes/download.js';
import { startDiskMonitor } from './services/disk.js';
import { resumePending } from './services/compression.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
function ensureDirs() {
for (const d of [config.dataDir, config.uploadsDir, config.tmpDir]) {
fs.mkdirSync(d, { recursive: true });
}
}
async function promoteAdmin() {
if (!config.adminEmail) return;
const result = await execute(
'UPDATE users SET is_admin = 1 WHERE email = ?',
[config.adminEmail]
);
if (result.affectedRows) log.info('admin promoted', { email: config.adminEmail });
}
async function main() {
ensureDirs();
// Verify DB connectivity early — fail fast with a clear message.
try {
await ping();
} catch (e) {
log.error('Cannot connect to MySQL. Did you run schema.sql and set DB_* env vars?', {
err: String(e),
});
process.exit(1);
}
await promoteAdmin();
const app = express();
if (config.trustProxy) app.set('trust proxy', 1);
app.disable('x-powered-by');
// Session store backed by the existing `sessions` table.
const MySQLStore = MySQLStoreFactory(session);
const sessionStore = new MySQLStore({
host: config.db.host,
port: config.db.port,
user: config.db.user,
password: config.db.password,
database: config.db.database,
createDatabaseTable: false,
schema: {
tableName: 'sessions',
columnNames: { session_id: 'session_id', expires: 'expires', data: 'data' },
},
});
// Everything is mounted under BASE_PATH so links and cookies line up behind nginx.
const router = express.Router();
const mountPath = config.basePath || '/';
router.use((req, res, next) => {
res.setHeader('X-Robots-Tag', 'noindex, nofollow');
// Don't MIME-sniff: downloads are served with a user-supplied Content-Type.
res.setHeader('X-Content-Type-Options', 'nosniff');
// Clickjacking protection for the form-driven UI (login, upload, delete...).
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Referrer-Policy', 'no-referrer');
// No inline scripts (handlers live in /static/app.js); inline styles allowed for
// the small dynamic bits (disk meter width). Blocks framing and foreign resources.
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data:; object-src 'none'; base-uri 'none'; " +
"form-action 'self'; frame-ancestors 'none'"
);
next();
});
router.get('/robots.txt', (req, res) => {
res.type('text/plain').send('User-agent: *\nDisallow: /\n');
});
router.use(
session({
name: 'fs.sid',
secret: config.sessionSecret,
store: sessionStore,
resave: false,
saveUninitialized: false,
rolling: true,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: config.trustProxy,
path: config.basePath || '/',
maxAge: 7 * 24 * 60 * 60 * 1000,
},
})
);
// When a handler stashes flash/newLink in the session and then redirects, make sure
// that write is committed to the store BEFORE the response goes out — otherwise the
// immediately-following request can race the async store write and miss it.
router.use((req, res, next) => {
const original = res.redirect.bind(res);
res.redirect = (...args) => {
const url = args.length > 1 ? args[1] : args[0];
const status = args.length > 1 ? args[0] : 302;
if (req.session && (req.session.flash !== undefined || req.session.newLink !== undefined)) {
req.session.save(() => original(status, url));
} else {
original(status, url);
}
};
next();
});
router.use('/static', express.static(path.join(__dirname, 'public'), {
maxAge: '7d',
index: false,
}));
// Body parser for HTML forms (downloads/uploads use their own handling).
router.use(express.urlencoded({ extended: false, limit: '1mb' }));
router.use(ah(attachUser));
// Public download is intentionally before auth-bearing pages (still after attachUser is fine).
router.use(downloadRouter);
router.use(verifyRouter);
router.use(authRouter); // POST /login, /register, /logout
router.use(pagesRouter); // GET /, /login, /register, /dashboard
router.use(uploadRouter); // POST /upload
router.use(filesRouter); // POST /files/:id/...
router.use('/admin', adminRouter); // /admin/* (guard scoped to this mount)
router.use((req, res) => res.status(404).send('Not found'));
// eslint-disable-next-line no-unused-vars
router.use((err, req, res, next) => {
// Clean up any temp files multer wrote before failing (it does not do this itself).
if (req.files) {
for (const f of req.files) fs.promises.unlink(f.path).catch(() => {});
}
if (err && (err.code === 'LIMIT_FILE_SIZE' || err.code === 'LIMIT_FILE_COUNT')) {
return res.status(413).send('Upload too large.');
}
if (err && err.code && String(err.code).startsWith('LIMIT_')) {
return res.status(400).send('Invalid upload.');
}
log.error('unhandled error', { err: String(err && err.stack ? err.stack : err) });
if (!res.headersSent) res.status(500).send('Server error');
});
app.use(mountPath, router);
startDiskMonitor();
await resumePending();
app.listen(config.port, () => {
log.info('server started', {
port: config.port,
basePath: config.basePath || '/',
publicBaseUrl: config.publicBaseUrl,
});
console.log(`File Share listening on http://127.0.0.1:${config.port}${config.basePath || ''}`);
});
}
main().catch((e) => {
log.error('fatal startup error', { err: String(e && e.stack ? e.stack : e) });
process.exit(1);
});

44
src/services/antivirus.js Normal file
View file

@ -0,0 +1,44 @@
import NodeClam from 'clamscan';
import config from '../config.js';
import log from '../log.js';
let clamPromise = null;
function getClam() {
if (!clamPromise) {
clamPromise = new NodeClam().init({
clamdscan: {
host: config.clamd.host,
port: config.clamd.port,
timeout: 120000,
localFallback: false,
},
preference: 'clamdscan',
});
}
return clamPromise;
}
/**
* Scans a file on disk.
* Returns 'clean' | 'infected' | 'skipped'.
* On clamd errors, honours AV_FAIL_MODE: 'block' throws, 'skip' returns 'skipped'.
*/
export async function scanFile(filePath) {
if (config.disableAv) return 'skipped';
try {
const clam = await getClam();
const { isInfected, viruses } = await clam.isInfected(filePath);
if (isInfected) {
log.warn('AV infected', { filePath, viruses });
return 'infected';
}
return 'clean';
} catch (e) {
log.error('AV scan error', { err: String(e), mode: config.avFailMode });
if (config.avFailMode === 'skip') return 'skipped';
const err = new Error('Virus scanner unavailable');
err.code = 'AV_UNAVAILABLE';
throw err;
}
}

44
src/services/bundle.js Normal file
View file

@ -0,0 +1,44 @@
import fs from 'node:fs';
import archiver from 'archiver';
import log from '../log.js';
// Sanitises a filename for use as a zip entry (no path traversal, no separators).
function safeEntryName(name) {
const base = String(name).replace(/[\\/]+/g, '_').replace(/^\.+/, '_');
return base.slice(0, 255) || 'file';
}
/**
* Streams multiple uploaded files into a single zip at destPath.
* `files` is an array of multer file objects ({ path, originalname }).
* Resolves to the resulting zip size in bytes.
*/
export function bundleToZip(files, destPath) {
return new Promise((resolve, reject) => {
const output = fs.createWriteStream(destPath);
const archive = archiver('zip', { zlib: { level: 6 } });
const seen = new Map();
output.on('close', () => resolve(archive.pointer()));
output.on('error', reject);
archive.on('error', reject);
archive.on('warning', (err) => log.warn('archiver warning', { err: String(err) }));
archive.pipe(output);
for (const f of files) {
let entry = safeEntryName(f.originalname);
// De-duplicate identical names within the bundle.
const count = seen.get(entry) || 0;
seen.set(entry, count + 1);
if (count > 0) {
const dot = entry.lastIndexOf('.');
entry =
dot > 0
? `${entry.slice(0, dot)} (${count})${entry.slice(dot)}`
: `${entry} (${count})`;
}
archive.file(f.path, { name: entry });
}
archive.finalize();
});
}

View file

@ -0,0 +1,83 @@
import fs from 'node:fs';
import zlib from 'node:zlib';
import { pipeline } from 'node:stream/promises';
import config from '../config.js';
import { query, execute } from '../db.js';
import { rawPath, gzPath } from '../storage.js';
import log from '../log.js';
const queue = [];
let running = false;
export function queueDepth() {
return queue.length + (running ? 1 : 0);
}
export function enqueue(fileId) {
if (!queue.includes(fileId)) queue.push(fileId);
pump();
}
function shouldSkip(ext) {
return !ext || config.compressSkipExts.includes(ext.toLowerCase());
}
async function pump() {
if (running) return;
running = true;
try {
while (queue.length) {
const fileId = queue.shift();
try {
await compressOne(fileId);
} catch (e) {
log.error('compression failed', { fileId, err: String(e) });
await execute('UPDATE files SET compressing = 0 WHERE id = ?', [fileId]).catch(() => {});
}
}
} finally {
running = false;
}
}
async function compressOne(fileId) {
const rows = await query(
'SELECT id, stored_name, ext, compressed FROM files WHERE id = ?',
[fileId]
);
const file = rows[0];
if (!file || file.compressed) return;
if (shouldSkip(file.ext)) return;
const src = rawPath(file.stored_name);
const dst = gzPath(file.stored_name);
if (!fs.existsSync(src)) return;
await execute('UPDATE files SET compressing = 1 WHERE id = ?', [fileId]);
// Write the .gz fully and fsync before touching the original.
await pipeline(
fs.createReadStream(src),
zlib.createGzip({ level: 6 }),
fs.createWriteStream(dst)
);
const newSize = (await fs.promises.stat(dst)).size;
// Flip the flag first so downloads start using the .gz, then remove the raw file.
await execute(
'UPDATE files SET compressed = 1, compressing = 0, size_bytes = ? WHERE id = ?',
[newSize, fileId]
);
await fs.promises.unlink(src).catch(() => {});
log.info('compressed', { fileId, newSize });
}
// On startup, resume any files that were uploaded clean but never compressed.
export async function resumePending() {
const rows = await query(
`SELECT id FROM files
WHERE compressed = 0 AND compressing = 0 AND av_status IN ('clean','skipped')`
);
for (const r of rows) enqueue(r.id);
if (rows.length) log.info('resuming compression', { count: rows.length });
}

52
src/services/disk.js Normal file
View file

@ -0,0 +1,52 @@
import fs from 'node:fs';
import config from '../config.js';
import log from '../log.js';
// Returns { totalBytes, freeBytes, usedBytes, usedPct } for the data directory's filesystem.
export async function getDiskInfo() {
try {
const s = await fs.promises.statfs(config.dataDir);
const totalBytes = s.blocks * s.bsize;
const freeBytes = s.bavail * s.bsize;
const usedBytes = totalBytes - freeBytes;
return {
totalBytes,
freeBytes,
usedBytes,
usedPct: totalBytes ? (usedBytes / totalBytes) * 100 : 0,
};
} catch (e) {
log.warn('statfs failed', { err: String(e) });
return null;
}
}
// True if there is at least `incomingBytes` headroom above the configured minimum.
export async function hasRoomFor(incomingBytes = 0) {
const info = await getDiskInfo();
if (!info) return true; // can't measure -> don't block
return info.freeBytes - incomingBytes >= config.minFreeBytes;
}
let warned = false;
export function startDiskMonitor(intervalMs = 5 * 60 * 1000) {
const tick = async () => {
const info = await getDiskInfo();
if (!info) return;
if (info.freeBytes < config.minFreeBytes) {
if (!warned) {
log.warn('Low disk space', {
freeBytes: info.freeBytes,
minFreeBytes: config.minFreeBytes,
});
warned = true;
}
} else {
warned = false;
}
};
tick();
const t = setInterval(tick, intervalMs);
t.unref();
return t;
}

29
src/storage.js Normal file
View file

@ -0,0 +1,29 @@
import fs from 'node:fs';
import path from 'node:path';
import config from './config.js';
// Base (uncompressed) path for a stored file.
export function rawPath(storedName) {
return path.join(config.uploadsDir, storedName);
}
// Compressed path.
export function gzPath(storedName) {
return path.join(config.uploadsDir, storedName + '.gz');
}
// The path that actually holds the bytes right now, based on the row's compressed flag.
export function activePath(file) {
return file.compressed ? gzPath(file.stored_name) : rawPath(file.stored_name);
}
// Removes both possible on-disk representations; ignores missing files.
export async function deleteStored(storedName) {
for (const p of [rawPath(storedName), gzPath(storedName)]) {
try {
await fs.promises.unlink(p);
} catch (e) {
if (e.code !== 'ENOENT') throw e;
}
}
}

8
src/util.js Normal file
View file

@ -0,0 +1,8 @@
// Wraps an async Express handler/middleware so a rejected promise is forwarded
// to the error handler instead of hanging the request (Express 4 does not do this).
export const ah =
(fn) =>
(req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
export default ah;

303
src/views.js Normal file
View file

@ -0,0 +1,303 @@
import config from './config.js';
export function esc(s) {
return String(s ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
const u = (p) => esc(config.url(p));
export function humanBytes(n) {
n = Number(n) || 0;
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i++;
}
return `${n.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
function flashHtml(flash) {
if (!flash) return '';
const cls = flash.type === 'error' ? 'flash error' : 'flash ok';
return `<div class="${cls}">${esc(flash.msg)}</div>`;
}
export function layout({ title, body, user, flash, active }) {
const nav = user
? `<a class="${active === 'dash' ? 'on' : ''}" href="${u('/dashboard')}">My files</a>
${user.is_admin ? `<a class="${active === 'admin' ? 'on' : ''}" href="${u('/admin')}">Admin</a>` : ''}
<span class="who">${esc(user.username)}</span>
<form method="post" action="${u('/logout')}" class="inline">
<button class="link" type="submit">Log out</button>
</form>`
: `<a href="${u('/login')}">Log in</a> <a href="${u('/register')}">Register</a>`;
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>${esc(title)} · File Share</title>
<link rel="stylesheet" href="${u('/static/style.css')}">
<script src="${u('/static/app.js')}" defer></script>
</head>
<body>
<header>
<a class="brand" href="${u('/')}">📦 File Share</a>
<nav>${nav}</nav>
</header>
<main>
${flashHtml(flash)}
${body}
</main>
<footer>One-time-use file sharing · unlisted</footer>
</body>
</html>`;
}
export function messagePage(opts) {
const { heading, text, link } = opts;
const body = `<section class="card">
<h1>${esc(heading)}</h1>
<p>${esc(text)}</p>
${link ? `<p><a class="btn" href="${esc(link.href)}">${esc(link.label)}</a></p>` : ''}
</section>`;
return layout({ ...opts, body });
}
export function loginPage(opts = {}) {
const body = `<section class="card narrow">
<h1>Log in</h1>
<form method="post" action="${u('/login')}">
<label>Username or email<input name="identifier" autocomplete="username" required></label>
<label>Password<input name="password" type="password" autocomplete="current-password" required></label>
<button class="btn" type="submit">Log in</button>
</form>
<p class="muted">No account? <a href="${u('/register')}">Register</a></p>
</section>`;
return layout({ title: 'Log in', body, ...opts });
}
export function registerPage(opts = {}) {
const needCode = !!config.signupCode;
const body = `<section class="card narrow">
<h1>Register</h1>
<form method="post" action="${u('/register')}">
<label>Username<input name="username" autocomplete="username" required minlength="3" maxlength="64"></label>
<label>Email<input name="email" type="email" autocomplete="email" required></label>
<label>Password<input name="password" type="password" autocomplete="new-password" required minlength="8"></label>
${needCode ? `<label>Signup code<input name="signup_code" required></label>` : ''}
<button class="btn" type="submit">Create account</button>
</form>
<p class="muted">Already have an account? <a href="${u('/login')}">Log in</a></p>
</section>`;
return layout({ title: 'Register', body, ...opts });
}
function linkStatusBadge(file) {
const s = file.link_state; // 'active' | 'downloaded' | 'expired' | 'none'
const labels = {
active: 'Active link',
downloaded: 'Downloaded',
expired: 'Expired',
none: 'No link',
};
return `<span class="badge ${s}">${labels[s] || s}</span>`;
}
function avBadge(file) {
return `<span class="badge av-${file.av_status}">${esc(file.av_status)}</span>`;
}
function fileRow(file) {
const link = file.token ? config.absoluteUrl(`/d/${file.token}`) : '';
const expires = file.expires_at
? new Date(file.expires_at).toLocaleString()
: '';
return `<tr>
<td>
<div class="fname">${esc(file.original_name)}</div>
<div class="sub">${humanBytes(file.size_bytes)}${file.is_bundle ? ' · bundle' : ''}${file.compressed ? ' · compressed' : ''}</div>
</td>
<td>${avBadge(file)}</td>
<td>${linkStatusBadge(file)}${file.link_state === 'active' ? `<div class="sub">expires ${esc(expires)}</div>` : ''}</td>
<td class="linkcell">
${
file.link_state === 'active'
? `<input class="copyfield" readonly value="${esc(link)}">`
: '<span class="muted">—</span>'
}
</td>
<td class="actions">
<form method="post" action="${u(`/files/${file.id}/link`)}" class="inline">
<button class="btn small" type="submit">New link</button>
</form>
<form method="post" action="${u(`/files/${file.id}/delete`)}" class="inline"
data-confirm="Delete this file permanently?">
<button class="btn small danger" type="submit">Delete</button>
</form>
</td>
</tr>`;
}
export function dashboardPage({ user, files, disk, flash, newLink }) {
const diskPct = disk ? Math.round(disk.usedPct) : 0;
const rows = files.length
? files.map(fileRow).join('')
: `<tr><td colspan="5" class="muted">No files yet. Upload one below.</td></tr>`;
const newLinkBanner = newLink
? `<div class="flash ok">
<strong>One-time link ready:</strong>
<input class="copyfield" readonly value="${esc(newLink)}">
</div>`
: '';
const verifyBanner = !user.email_verified
? `<div class="flash error">
Your email isn't verified yet — you can't upload until it is.
<form method="post" action="${u('/verify/resend')}" class="inline">
<button class="link" type="submit">Resend verification email</button>
</form>
</div>`
: '';
const uploadCard = user.email_verified
? `<section class="card">
<h1>Upload</h1>
<form method="post" action="${u('/upload')}" enctype="multipart/form-data">
<input type="file" name="files" multiple required>
<p class="muted">Select multiple files to bundle them into a single <code>.zip</code> behind one link.</p>
<button class="btn" type="submit">Upload &amp; get link</button>
</form>
</section>`
: '';
const body = `
${verifyBanner}
${newLinkBanner}
<section class="card">
<div class="diskbar">
<div>Disk: ${disk ? `${humanBytes(disk.freeBytes)} free of ${humanBytes(disk.totalBytes)}` : 'n/a'}</div>
<div class="meter"><span style="width:${diskPct}%"></span></div>
<div>${diskPct}% used</div>
</div>
</section>
${uploadCard}
<section class="card">
<h1>My files</h1>
<table class="files">
<thead><tr><th>File</th><th>Scan</th><th>Link</th><th>Download URL</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table>
</section>`;
return layout({ title: 'My files', body, user, flash, active: 'dash' });
}
export function gonePage() {
return messagePage({
title: 'Link unavailable',
heading: 'This link is no longer available',
text: 'It may have already been used, expired, or never existed.',
});
}
// ---- Admin views ----
export function adminOverviewPage({ user, stats, disk, flash }) {
const diskPct = disk ? Math.round(disk.usedPct) : 0;
const body = `
<section class="card">
<h1>Admin overview</h1>
<div class="stats">
<div class="stat"><b>${stats.users}</b><span>users</span></div>
<div class="stat"><b>${stats.files}</b><span>files</span></div>
<div class="stat"><b>${stats.activeLinks}</b><span>active links</span></div>
<div class="stat"><b>${stats.usedLinks}</b><span>used links</span></div>
<div class="stat"><b>${stats.expiredLinks}</b><span>expired links</span></div>
<div class="stat"><b>${stats.queueDepth}</b><span>compress queue</span></div>
<div class="stat"><b>${stats.infected}</b><span>infected blocked</span></div>
</div>
<div class="diskbar">
<div>Disk: ${disk ? `${humanBytes(disk.freeBytes)} free of ${humanBytes(disk.totalBytes)}` : 'n/a'}</div>
<div class="meter"><span style="width:${diskPct}%"></span></div>
<div>${diskPct}% used</div>
</div>
<p><a class="btn" href="${u('/admin/users')}">Manage users</a>
<a class="btn" href="${u('/admin/files')}">Manage files</a></p>
</section>`;
return layout({ title: 'Admin', body, user, flash, active: 'admin' });
}
export function adminUsersPage({ user, users, flash }) {
const rows = users
.map(
(r) => `<tr>
<td>${esc(r.username)}${r.is_admin ? ' <span class="badge admin">admin</span>' : ''}
${r.disabled ? ' <span class="badge expired">disabled</span>' : ''}
${!r.email_verified ? ' <span class="badge none">unverified</span>' : ''}
<div class="sub">${esc(r.email)}</div></td>
<td>${r.file_count}<div class="sub">${humanBytes(r.bytes_used)}</div></td>
<td class="actions">
${!r.email_verified ? actionBtn(`/admin/users/${r.id}/verify`, 'Verify') : ''}
${r.disabled ? actionBtn(`/admin/users/${r.id}/enable`, 'Enable') : actionBtn(`/admin/users/${r.id}/disable`, 'Disable', r.id === user.id)}
${actionBtn(`/admin/users/${r.id}/delete`, 'Delete', r.id === user.id, true)}
</td>
</tr>`
)
.join('');
const body = `<section class="card">
<h1>Users</h1>
<table class="files">
<thead><tr><th>User</th><th>Files</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table>
<p><a href="${u('/admin')}">&larr; Overview</a></p>
</section>`;
return layout({ title: 'Admin · Users', body, user, flash, active: 'admin' });
}
export function adminFilesPage({ user, files, flash }) {
const rows = files
.map(
(f) => `<tr>
<td><div class="fname">${esc(f.original_name)}</div>
<div class="sub">${humanBytes(f.size_bytes)}${f.is_bundle ? ' · bundle' : ''}${f.compressed ? ' · compressed' : ''}</div></td>
<td>${esc(f.username)}</td>
<td><span class="badge av-${f.av_status}">${esc(f.av_status)}</span></td>
<td><span class="badge ${f.link_state}">${f.link_state}</span></td>
<td class="actions">
${actionBtn(`/admin/files/${f.id}/link`, 'New link')}
${f.link_id ? actionBtn(`/admin/links/${f.link_id}/expire`, 'Expire') : ''}
${actionBtn(`/admin/files/${f.id}/delete`, 'Delete', false, true)}
</td>
</tr>`
)
.join('');
const body = `<section class="card">
<h1>All files</h1>
<table class="files">
<thead><tr><th>File</th><th>Owner</th><th>Scan</th><th>Link</th><th></th></tr></thead>
<tbody>${rows || `<tr><td colspan="5" class="muted">No files.</td></tr>`}</tbody>
</table>
<p><a href="${u('/admin')}">&larr; Overview</a></p>
</section>`;
return layout({ title: 'Admin · Files', body, user, flash, active: 'admin' });
}
function actionBtn(path, label, disabled = false, danger = false) {
if (disabled) return `<button class="btn small" disabled>${esc(label)}</button>`;
const confirmAttr = danger ? ` data-confirm="Are you sure?"` : '';
return `<form method="post" action="${u(path)}" class="inline"${confirmAttr}>
<button class="btn small ${danger ? 'danger' : ''}" type="submit">${esc(label)}</button>
</form>`;
}