commit 654eb495028d4e1e19dfade49354be1e4c101e7c Author: portakal Date: Tue Jun 16 14:16:02 2026 +0300 v1 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8b56908 --- /dev/null +++ b/.env.example @@ -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 +# 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9c67562 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.env +data/uploads/ +data/tmp/ +*.log +npm-debug.log* +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..a784fd0 --- /dev/null +++ b/README.md @@ -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 -p < 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 +``` diff --git a/deploy/fileshare.service b/deploy/fileshare.service new file mode 100644 index 0000000..5da5b60 --- /dev/null +++ b/deploy/fileshare.service @@ -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 diff --git a/deploy/nginx-fileShare.conf b/deploy/nginx-fileShare.conf new file mode 100644 index 0000000..11b2734 --- /dev/null +++ b/deploy/nginx-fileShare.conf @@ -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; +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f38b223 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2241 @@ +{ + "name": "fileshare", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fileshare", + "version": "1.0.0", + "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" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@types/node": { + "version": "25.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.2.tgz", + "integrity": "sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz", + "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==", + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.1.tgz", + "integrity": "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", + "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/clamscan": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/clamscan/-/clamscan-2.4.0.tgz", + "integrity": "sha512-XBOxUiGOcQGuKmCn5qaM5rIK153fGCwsvJMbjVtcnNJ+j/YHrSj2gKNjyP65yr/E8JsKTTDtKYFG++p7Lzigyw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-mysql-session": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/express-mysql-session/-/express-mysql-session-3.0.3.tgz", + "integrity": "sha512-sEYrzFrOs3er+Ie/uk1dt93qz4AQ9SU1mpJJ0HPs0MJ4t4hE9AcDRNq0sZQUwy2F/SbXusBt1E5+FY6KzSqXNg==", + "license": "MIT", + "dependencies": { + "debug": "4.3.4", + "mysql2": "3.10.2" + } + }, + "node_modules/express-mysql-session/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/express-mysql-session/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/express-mysql-session/node_modules/lru-cache": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-8.0.5.tgz", + "integrity": "sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==", + "license": "ISC", + "engines": { + "node": ">=16.14" + } + }, + "node_modules/express-mysql-session/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "license": "MIT" + }, + "node_modules/express-mysql-session/node_modules/mysql2": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.10.2.tgz", + "integrity": "sha512-KCXPEvAkO0RcHPr362O5N8tFY2fXvbjfkPvRY/wGumh4EOemo9Hm5FjQZqv/pCmrnuxGu5OxnSENG0gTXqKMgQ==", + "license": "MIT", + "dependencies": { + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.6.3", + "long": "^5.2.1", + "lru-cache": "^8.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-session": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", + "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", + "license": "MIT", + "dependencies": { + "cookie": "~0.7.2", + "cookie-signature": "~1.0.7", + "debug": "~2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "~5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mysql2": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.5.tgz", + "integrity": "sha512-95uZ2TrPWAZdwpB3vvvDbmEMcNG8yIeNCyu6GUcr/QnWEE/wXm7+mhOCsdQfWQDTV7qYT/PDUZ4U4UPP4AsXqQ==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.3.3" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemailer": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.0.tgz", + "integrity": "sha512-tbPTid7d/p9jAA8CRZ3iomvrMaST0o6NYuY7v6JQZHpPRZ61mLFSPKYd7342NtOFuej9/+L48SOIxwfu2uDvtw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sql-escaper": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz", + "integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT", + "peer": true + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2528930 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..aa705a6 --- /dev/null +++ b/schema.sql @@ -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; diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000..146c841 --- /dev/null +++ b/src/auth.js @@ -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; diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..4d39db1 --- /dev/null +++ b/src/config.js @@ -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 ', + }, + 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; diff --git a/src/db.js b/src/db.js new file mode 100644 index 0000000..a78c51f --- /dev/null +++ b/src/db.js @@ -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; diff --git a/src/links.js b/src/links.js new file mode 100644 index 0000000..c89bfd5 --- /dev/null +++ b/src/links.js @@ -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]); +} diff --git a/src/log.js b/src/log.js new file mode 100644 index 0000000..d21f713 --- /dev/null +++ b/src/log.js @@ -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; diff --git a/src/mailer.js b/src/mailer.js new file mode 100644 index 0000000..3bf7f98 --- /dev/null +++ b/src/mailer.js @@ -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: `

Welcome!

Confirm your email to start uploading:

+

${link}

+

If you did not sign up, ignore this message.

`, + }); + log.info('Verification email sent', { email }); +} + +export default { sendVerification, emailEnabled }; diff --git a/src/public/app.js b/src/public/app.js new file mode 100644 index 0000000..72b3ee5 --- /dev/null +++ b/src/public/app.js @@ -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(); + } + }); +})(); diff --git a/src/public/style.css b/src/public/style.css new file mode 100644 index 0000000..649b9d2 --- /dev/null +++ b/src/public/style.css @@ -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; } diff --git a/src/routes/admin.js b/src/routes/admin.js new file mode 100644 index 0000000..03b857a --- /dev/null +++ b/src/routes/admin.js @@ -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; diff --git a/src/routes/download.js b/src/routes/download.js new file mode 100644 index 0000000..a3a7067 --- /dev/null +++ b/src/routes/download.js @@ -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; diff --git a/src/routes/files.js b/src/routes/files.js new file mode 100644 index 0000000..4f5f362 --- /dev/null +++ b/src/routes/files.js @@ -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; diff --git a/src/routes/pages.js b/src/routes/pages.js new file mode 100644 index 0000000..87981db --- /dev/null +++ b/src/routes/pages.js @@ -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; diff --git a/src/routes/upload.js b/src/routes/upload.js new file mode 100644 index 0000000..2eb783a --- /dev/null +++ b/src/routes/upload.js @@ -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; diff --git a/src/routes/verify.js b/src/routes/verify.js new file mode 100644 index 0000000..3599d72 --- /dev/null +++ b/src/routes/verify.js @@ -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; diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..d3805fb --- /dev/null +++ b/src/server.js @@ -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); +}); diff --git a/src/services/antivirus.js b/src/services/antivirus.js new file mode 100644 index 0000000..c513e7c --- /dev/null +++ b/src/services/antivirus.js @@ -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; + } +} diff --git a/src/services/bundle.js b/src/services/bundle.js new file mode 100644 index 0000000..aeb5e40 --- /dev/null +++ b/src/services/bundle.js @@ -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(); + }); +} diff --git a/src/services/compression.js b/src/services/compression.js new file mode 100644 index 0000000..50c2d6e --- /dev/null +++ b/src/services/compression.js @@ -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 }); +} diff --git a/src/services/disk.js b/src/services/disk.js new file mode 100644 index 0000000..22233f8 --- /dev/null +++ b/src/services/disk.js @@ -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; +} diff --git a/src/storage.js b/src/storage.js new file mode 100644 index 0000000..05c4881 --- /dev/null +++ b/src/storage.js @@ -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; + } + } +} diff --git a/src/util.js b/src/util.js new file mode 100644 index 0000000..038e56d --- /dev/null +++ b/src/util.js @@ -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; diff --git a/src/views.js b/src/views.js new file mode 100644 index 0000000..fbf4b13 --- /dev/null +++ b/src/views.js @@ -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 `
${esc(flash.msg)}
`; +} + +export function layout({ title, body, user, flash, active }) { + const nav = user + ? `My files + ${user.is_admin ? `Admin` : ''} + ${esc(user.username)} +
+ +
` + : `Log in Register`; + + return ` + + + + + +${esc(title)} · File Share + + + + +
+ 📦 File Share + +
+
+${flashHtml(flash)} +${body} +
+
One-time-use file sharing · unlisted
+ +`; +} + +export function messagePage(opts) { + const { heading, text, link } = opts; + const body = `
+

${esc(heading)}

+

${esc(text)}

+ ${link ? `

${esc(link.label)}

` : ''} +
`; + return layout({ ...opts, body }); +} + +export function loginPage(opts = {}) { + const body = `
+

Log in

+
+ + + +
+

No account? Register

+
`; + return layout({ title: 'Log in', body, ...opts }); +} + +export function registerPage(opts = {}) { + const needCode = !!config.signupCode; + const body = `
+

Register

+
+ + + + ${needCode ? `` : ''} + +
+

Already have an account? Log in

+
`; + 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 `${labels[s] || s}`; +} + +function avBadge(file) { + return `${esc(file.av_status)}`; +} + +function fileRow(file) { + const link = file.token ? config.absoluteUrl(`/d/${file.token}`) : ''; + const expires = file.expires_at + ? new Date(file.expires_at).toLocaleString() + : ''; + return ` + +
${esc(file.original_name)}
+
${humanBytes(file.size_bytes)}${file.is_bundle ? ' · bundle' : ''}${file.compressed ? ' · compressed' : ''}
+ + ${avBadge(file)} + ${linkStatusBadge(file)}${file.link_state === 'active' ? `
expires ${esc(expires)}
` : ''} + + ${ + file.link_state === 'active' + ? `` + : '' + } + + +
+ +
+
+ +
+ + `; +} + +export function dashboardPage({ user, files, disk, flash, newLink }) { + const diskPct = disk ? Math.round(disk.usedPct) : 0; + const rows = files.length + ? files.map(fileRow).join('') + : `No files yet. Upload one below.`; + + const newLinkBanner = newLink + ? `
+ One-time link ready: + +
` + : ''; + + const verifyBanner = !user.email_verified + ? `
+ Your email isn't verified yet — you can't upload until it is. +
+ +
+
` + : ''; + + const uploadCard = user.email_verified + ? `
+

Upload

+
+ +

Select multiple files to bundle them into a single .zip behind one link.

+ +
+
` + : ''; + + const body = ` + ${verifyBanner} + ${newLinkBanner} +
+
+
Disk: ${disk ? `${humanBytes(disk.freeBytes)} free of ${humanBytes(disk.totalBytes)}` : 'n/a'}
+
+
${diskPct}% used
+
+
+ + ${uploadCard} + +
+

My files

+ + + ${rows} +
FileScanLinkDownload URL
+
`; + 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 = ` +
+

Admin overview

+
+
${stats.users}users
+
${stats.files}files
+
${stats.activeLinks}active links
+
${stats.usedLinks}used links
+
${stats.expiredLinks}expired links
+
${stats.queueDepth}compress queue
+
${stats.infected}infected blocked
+
+
+
Disk: ${disk ? `${humanBytes(disk.freeBytes)} free of ${humanBytes(disk.totalBytes)}` : 'n/a'}
+
+
${diskPct}% used
+
+

Manage users + Manage files

+
`; + return layout({ title: 'Admin', body, user, flash, active: 'admin' }); +} + +export function adminUsersPage({ user, users, flash }) { + const rows = users + .map( + (r) => ` + ${esc(r.username)}${r.is_admin ? ' admin' : ''} + ${r.disabled ? ' disabled' : ''} + ${!r.email_verified ? ' unverified' : ''} +
${esc(r.email)}
+ ${r.file_count}
${humanBytes(r.bytes_used)}
+ + ${!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)} + + ` + ) + .join(''); + const body = `
+

Users

+ + + ${rows} +
UserFiles
+

← Overview

+
`; + return layout({ title: 'Admin · Users', body, user, flash, active: 'admin' }); +} + +export function adminFilesPage({ user, files, flash }) { + const rows = files + .map( + (f) => ` +
${esc(f.original_name)}
+
${humanBytes(f.size_bytes)}${f.is_bundle ? ' · bundle' : ''}${f.compressed ? ' · compressed' : ''}
+ ${esc(f.username)} + ${esc(f.av_status)} + ${f.link_state} + + ${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)} + + ` + ) + .join(''); + const body = `
+

All files

+ + + ${rows || ``} +
FileOwnerScanLink
No files.
+

← Overview

+
`; + return layout({ title: 'Admin · Files', body, user, flash, active: 'admin' }); +} + +function actionBtn(path, label, disabled = false, danger = false) { + if (disabled) return ``; + const confirmAttr = danger ? ` data-confirm="Are you sure?"` : ''; + return `
+ +
`; +}