v1
This commit is contained in:
commit
654eb49502
30 changed files with 4420 additions and 0 deletions
52
.env.example
Normal file
52
.env.example
Normal 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
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules/
|
||||
.env
|
||||
data/uploads/
|
||||
data/tmp/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
99
README.md
Normal file
99
README.md
Normal 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
31
deploy/fileshare.service
Normal 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
|
||||
25
deploy/nginx-fileShare.conf
Normal file
25
deploy/nginx-fileShare.conf
Normal 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
2241
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
27
package.json
Normal file
27
package.json
Normal 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
72
schema.sql
Normal 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
201
src/auth.js
Normal 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 3–64 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
87
src/config.js
Normal 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
43
src/db.js
Normal 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
72
src/links.js
Normal 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
28
src/log.js
Normal 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
41
src/mailer.js
Normal 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
20
src/public/app.js
Normal 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
104
src/public/style.css
Normal 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
171
src/routes/admin.js
Normal 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
67
src/routes/download.js
Normal 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
42
src/routes/files.js
Normal 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
44
src/routes/pages.js
Normal 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
123
src/routes/upload.js
Normal 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
71
src/routes/verify.js
Normal 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
189
src/server.js
Normal 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
44
src/services/antivirus.js
Normal 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
44
src/services/bundle.js
Normal 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();
|
||||
});
|
||||
}
|
||||
83
src/services/compression.js
Normal file
83
src/services/compression.js
Normal 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
52
src/services/disk.js
Normal 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
29
src/storage.js
Normal 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
8
src/util.js
Normal 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
303
src/views.js
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import config from './config.js';
|
||||
|
||||
export function esc(s) {
|
||||
return String(s ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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 & 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')}">← 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')}">← 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>`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue