32 Commits

Author SHA1 Message Date
829037f89c Merge pull request 'chore: replace hand-rolled layout() with @fastify/view + EJS' (#20) from chore/ejs-view-templates into main
All checks were successful
Deploy to birb co. production / deploy (push) Successful in 30s
2026-05-15 05:55:22 +00:00
5586a8cf19 refactor: remove redundant FileRow type annotation in pages.ts map callback 2026-05-14 22:51:27 -07:00
118ea15b4a chore: replace hand-rolled layout() with @fastify/view + EJS
Convert all src/views/*.ts template-literal modules to .ejs templates
under views/. Register @fastify/view plugin in server.ts with EJS
engine and _layout.ejs as the layout file. Update route handlers to
use reply.view() instead of reply.send(layout(...)). Delete the old
TypeScript view modules and layout.ts.

Closes #19
2026-05-14 22:48:41 -07:00
6e6f4b1acf Merge pull request 'chore: bump Dockerfile base to node:24-alpine and add git for npm git-URL deps' (#10) from chore/dockerfile-git-and-node24 into main
All checks were successful
Deploy to birb co. production / deploy (push) Successful in 47s
2026-05-12 18:48:49 +00:00
2df7546779 chore: bump Dockerfile base to node:24-alpine and add git for npm git-URL deps
Deploy CI was failing on `npm ci` because the base image lacked `git`,
which npm needs to resolve `bchen-sqlite-migrate` from its git URL.
Bumping the base from node:22-alpine to node:24-alpine in the same change
aligns the image with authd's stack (npm 11.x) — both native deps support
Node 24 prebuilds.
2026-05-12 11:45:02 -07:00
83a128f917 Merge pull request 'feat: adopt bchen-sqlite-migrate package; replace inline SCHEMA_DDL' (#9) from feat/adopt-sqlite-migrate into main
Some checks failed
Deploy to birb co. production / deploy (push) Failing after 10s
2026-05-12 15:08:33 +00:00
436f7417be feat: adopt bchen-sqlite-migrate package; replace inline SCHEMA_DDL
Phase 3 of the cross-project sqlite-migrate adoption — port nanodrop to
consume bchen-sqlite-migrate@v0.1.0. Replaces the inline db.exec(...)
block in src/db/schema.ts with applyMigrations(db, MIGRATIONS_DIR,
{ genesisProbeTable: 'users' }).

The genesis-probe (table 'users' exists) handles pre-existing prod DBs
automatically — first deploy after merge stamps 0001_init as applied
without re-executing, subsequent boots are no-ops.

Adds three npm scripts (db:migrate, db:status, db:stamp) and a
byte-stability test pinning sha256(0001_init.sql) so the migration is
treated as immutable history.
2026-05-12 08:02:36 -07:00
3f8da8c12c Merge pull request 'feat: auto OS-driven dark mode (prefers-color-scheme)' (#8) from feat/auto-dark-mode into main
All checks were successful
Deploy to birb co. production / deploy (push) Successful in 18s
2026-05-11 16:56:44 +00:00
38e7c0c4a1 refactor: restore var(--border) shorthand after dark-mode tokenization
The dark-mode refactor expanded `border: var(--border);` to
`1px solid var(--border-color);` in three rules (.share-box button,
th, button.copy-link). The rest of the file still uses the
var(--border) shorthand for the same value, so restore it for
consistency.

`--border` is defined as `1px solid var(--border-color)`, so the
substitution is byte-equivalent at the cascade level: same border
in light mode, same border in dark mode, identical to the prior
commit.

Behavior unchanged. 130/130 tests pass; tsc --noEmit clean.
2026-05-11 09:53:24 -07:00
012c544bdc feat: auto OS-driven dark mode (prefers-color-scheme)
Refactor public/style.css to semantic CSS custom properties under :root
(light palette by default) and add a @media (prefers-color-scheme: dark)
override block that flips background/foreground/accent/danger tokens to
a soft near-black palette. The original --black/--white/--gray-*/--red
literal tokens and the lone #fff5f5 floating literal are gone — every
color now derives from a semantic token, so the dark override is the
only place that re-declares them.

Add <meta name="color-scheme" content="light dark"> to the shared
layout <head> so the browser renders its UA backdrop, native scrollbars,
and form-control popups in the user's preferred scheme before the
stylesheet parses — this is what avoids a white flash on initial paint
in dark mode.

No toggle, no JS, no preference persistence — the page follows the OS
setting directly via the media query. The 16px font-size rule on
text-entry inputs is preserved (still asserted by tests/integration
/style.test.ts).
2026-05-11 09:50:44 -07:00
398c008c32 Merge pull request 'chore: drop pre-emptive 'compose down' so failed builds don't take prod offline' (#7) from chore/nanodrop-deploy-skip-preemptive-down into main
All checks were successful
Deploy to birb co. production / deploy (push) Successful in 18s
2026-05-11 07:50:11 +00:00
95201eb406 chore: drop pre-emptive 'compose down' so failed builds don't take prod offline
Matches the cross-project pattern already applied to authd PR #12, buchinese
PR #8, inventory PR #19, and movement PR #16. The pre-emptive `docker compose
down` destroyed the running container BEFORE `docker compose up -d --build`
had a chance to verify the new image builds — a single npm ci lockfile
mismatch (buchinese PR #6, 2026-05-10) was enough to put prod to HTTP 502 for
the entire human-intervention turnaround. `docker compose up -d --build`
builds first; only on successful build does compose recreate the container.
On build failure compose exits non-zero with the previous container still
serving traffic. `--remove-orphans` is a drop-in cleanup for renamed/removed
services.

No test added (no CI-yaml tests exist in this project; matches 4 precedent
PRs). Refactor pass expected to be a noop.
2026-05-11 00:47:25 -07:00
4df874b695 Merge pull request 'chore: bump form input font-size to 16px to prevent iOS focus-zoom' (#6) from chore/nanodrop-ios-input-no-zoom into main
All checks were successful
Deploy to birb co. production / deploy (push) Successful in 17s
2026-05-11 03:12:44 +00:00
5d6cb390a4 chore: bump form input font-size to 16px to prevent iOS focus-zoom
Final retrofit in the cross-project iOS focus-zoom chore (after authd PR
#11, buchinese PR #5, inventory PR #18, movement PR #15). Enforces the
standing rule in ~/.claude/CLAUDE.md: text-entry inputs must compute to
font-size >= 16px so iOS Safari does not auto-zoom on focus.
2026-05-10 20:08:53 -07:00
bee3cd2e98 Merge pull request 'chore: align nanodrop deploy workflow with inventory canonical' (#5) from chore/align-deploy-yaml-with-inventory into main
All checks were successful
Deploy to birb co. production / deploy (push) Successful in 18s
2026-05-10 10:56:46 +00:00
512300f475 chore: align deploy workflow with inventory canonical
Brings nanodrop into parity with ~/inventory/.github/workflows/deploy.yml,
the cross-project canonical:

- Rename .github/workflows/deploy-homelab.yml -> deploy.yml
- Update workflow name to "Deploy to birb co. production"
- Add validate-secrets gate (SSH_PRIVATE_KEY, JWT_SECRET) using
  ${VAR:?msg} no-op expansion (does not echo secret values)
- Switch deploy heredoc from << 'EOF' (quoted) to << EOF (unquoted)
  to match canonical; functional no-op since the body contains no
  bash $VAR refs, only GitHub Actions ${{ ... }} interpolations
- Single-quote the right-hand side of interpolated export values to
  prevent shell-metacharacter re-interpretation server-side
- Reorder exports: secret first, then hardcoded literals, then vars
- Rename docker-compose.yml -> compose.yaml (pure rename) and update
  the workflow's compose invocations to reference compose.yaml
- Update one README example to match the new compose filename

The env-var block remains nanodrop-specific (JWT_SECRET +
TRUST_PROXY/COOKIE_SECURE literals + PORT/BASE_URL/MAX_FILE_SIZE);
that delta is allowed by the bug spec.

No app-code changes. Build and tests green.

Manual deploy verification (push to main / "Run workflow" -> hit the
deployed instance, log in, upload a test file, confirm share link)
is the user's job post-merge.
2026-05-10 03:51:40 -07:00
aed9931e14 Merge pull request 'feat: persistent session cookies (30d sliding) — nanodrop tier' (#4) from feat/persistent-session-cookies into main
All checks were successful
Deploy to Homelab / deploy (push) Successful in 39s
2026-05-09 17:34:57 +00:00
3b3a56cd94 docs: drop JWT_EXPIRY from .env.example, docker-compose, README (family TTL is canonical) 2026-05-09 10:29:32 -07:00
cbc22dcac4 refactor: drop redundant iat intersections and reuse JwtPayload in tests
JwtPayload already declares iat?: number, so the & { iat?: number } in
makeRequireAuth and slideSessionIfNeeded was a no-op. The unit test had a
local TestPayload duplicating the same shape — replaced with the canonical
import.
2026-05-09 10:23:24 -07:00
a4355e1ef3 refactor(auth): drop JWT_EXPIRY env var (family TTL is canonical)
The JWT lifetime is now pinned to SESSION_TTL_SECONDS (30 days)
inside issueSessionCookie, so JWT_EXPIRY had no effect after the
prior commits. Removing the field from Config and the env read in
loadConfig lets the TypeScript compiler verify no caller was still
relying on it.

Per the family invariant ('coherence across apps is a feature'),
the per-app override is intentionally gone — a deploy with stale
JWT_EXPIRY in .env will now silently use the 30-day family default
regardless of the value.

Test fixtures (Config literals in setup.ts, login-handler.test.ts,
lockout-service.test.ts) and config.test.ts assertions updated to
match the new shape.
2026-05-09 10:17:04 -07:00
0f0c2f0e96 feat(auth): sliding session renewal middleware
Adds src/middleware/session-renewal.ts with slideSessionIfNeeded —
a pure function that re-mints the session cookie iff the JWT is
older than SESSION_RENEW_THRESHOLD_SECONDS (1 hour). Wired into
makeRequireAuth so every authenticated request bumps the cookie's
expiration window forward, giving the 'stay signed in unless you
clear cookies' UX.

Logout paths are explicitly guarded via LOGOUT_PATHS so the
renewer never resurrects a session the user is actively
terminating. Query-string strip prevents /logout?next=foo bypass.
Opportunistic-auth blocks (GET /, GET /f/:id) verify JWT directly
without going through makeRequireAuth, so they don't slide — by
design, a public file view shouldn't extend the owner's session.

Tests cover threshold semantics, both logout paths, query-string
handling, missing iat (legacy token forces refresh), and a full
integration suite simulating 25-day and 31-day jumps via
vi.setSystemTime.
2026-05-09 10:15:31 -07:00
623a3374cf feat(auth): rename session cookie to nanodrop_session
Flips SESSION_COOKIE_NAME from 'token' to 'nanodrop_session' per the
family per-app naming convention (<app>_session). fastify-jwt's
cookieName in server.ts is now sourced from the constant so a future
rename only needs to touch constants.ts.

Hard-cut migration with no dual-cookie shim: the existing 'token'
cookie has no Max-Age so it dies on browser close anyway, and this
is a single-user deployment per CLAUDE.md. Users re-log in once
after deploy.

Test files updated mechanically: cookies: { token } → cookies: {
nanodrop_session: token } (variable name 'token' kept locally),
clearCookie regex updated, login response now also asserts
Max-Age=2592000 from the family TTL.
2026-05-09 10:12:25 -07:00
86870db726 feat(auth): family-wide session constants + mint primitive + auth factory
Adds src/constants.ts exporting the family-wide session policy
(SESSION_TTL_DAYS=30, SESSION_TTL_SECONDS=2_592_000,
SESSION_RENEW_THRESHOLD_SECONDS=3600, LOGOUT_PATHS) so every
bchen.dev app shares the same persistence window.

Introduces issueSessionCookie as the single mint site for
fastify-jwt sign + setCookie, replacing inlined jwt.sign +
setCookie calls in pages.ts and api/v1/auth.ts. The cookie now
carries Max-Age=SESSION_TTL_SECONDS so it persists across browser
restarts.

Converts requireAuth into a makeRequireAuth(config) factory; route
plugins build their own preHandler at registration time. Threads
through pages.ts, api/v1/auth.ts, and api/v1/files.ts.

SESSION_COOKIE_NAME stays 'token' in this commit so existing tests
remain green; the rename to 'nanodrop_session' lands in a follow-up.
JWT_EXPIRY env var is still read; its removal also lands in a
follow-up so each commit builds cleanly.
2026-05-09 10:10:47 -07:00
42a9530ed0 Merge pull request 'fix(views): wrap My Files table for mobile horizontal scroll' (#3) from chore/mobile-files-table-overflow into main
All checks were successful
Deploy to Homelab / deploy (push) Successful in 18s
2026-05-04 00:15:22 +00:00
d9592100cc fix(views): wrap My Files table for mobile horizontal scroll
The table at /files was wider than the viewport on iPhone-class widths
(~375 px) — the rightmost column with the Copy and Delete buttons clipped
off-screen with no scroll affordance. Wrapping the table in a
.table-wrap div with overflow-x: auto lets the table scroll within
itself; moving the outer border to the wrapper preserves the desktop
visual unchanged.

- public/style.css: add .table-wrap rule, move border off table, add
  min-width: 100% so the table still fills wide viewports.
- src/views/file-list.ts: wrap <table> in <div class="table-wrap">.
- tests/integration/pages.test.ts: assert rendered HTML contains
  class="table-wrap".
2026-05-03 17:06:14 -07:00
49eeb1cc49 Merge pull request 'refactor(style): drop custom webfont, use system fonts' (#2) from feat/system-font-stack into main
All checks were successful
Deploy to Homelab / deploy (push) Successful in 19s
2026-05-03 22:42:54 +00:00
c6aa030e54 refactor(style): drop custom webfont, use system fonts
Removes the IBM Plex Mono Google Fonts @import, replaces the --font CSS
variable with a cross-platform system sans-serif stack, and adds a
--font-mono variable for the share-URL readonly input so copy/share text
stays monospace. Also adds line-height: 1.5 to body to compensate for
the system fonts' tighter default leading.

No font asset files exist in /public; layout.ts has no font <link> tags
to remove. Acceptance check: build + 112 tests pass; new
tests/integration/style.test.ts asserts no @import / IBM Plex / external
font URL remains and that the system stack is wired up.
2026-05-03 15:20:22 -07:00
bbd292c085 feat(auth): wire lockout, rate-limit, and constant-time login into both routes
All checks were successful
Deploy to Homelab / deploy (push) Successful in 18s
Both POST /login (HTML form) and POST /api/v1/auth/login now flow through
the shared attemptLogin() handler. Locked accounts respond with 401 +
Retry-After (generic body "Invalid credentials" / "Invalid username or
password") so attackers can't use lockout state as a username-existence
oracle.

@fastify/rate-limit registered with global=false; only the two login
routes opt in via per-route rateLimit config. File uploads and downloads
keep full throughput. Custom errorResponseBuilder logs AUTH_RATE_LIMITED
fire-and-forget so fail2ban can pick it up.

createTestApp now accepts Partial<Config> overrides so integration tests
can dial thresholds down without env-var mutation.
2026-05-03 03:41:51 -07:00
ad36b23061 feat(auth): add shared login handler with constant-time clamp
All checks were successful
Deploy to Homelab / deploy (push) Successful in 18s
Single attemptLogin() orchestrates lockout check, bcrypt verify (against
real or dummy hash), success/failure logging, and a configurable minimum
response-time clamp. Both login routes will share this — no duplication.

Adds three logger events for the operator's escalation pipeline:
AUTH_LOCKOUT_TRIGGERED, AUTH_LOCKED_ATTEMPT, and AUTH_RATE_LIMITED.
fail2ban filters can pick these up to escalate persistent attackers from
in-app lockout to IP ban.

Constant-time defense: unknown users still pay bcrypt cost (via dummy hash)
and the clamp ensures locked-vs-unknown-vs-wrong-password aren't
distinguishable by response time. Username is canonicalized (lowercase + trim)
before lookup so attackers can't bypass lockout via case variation.
2026-05-03 03:34:15 -07:00
11e87f353d feat(auth): add login-attempts DB layer and lockout service
All checks were successful
Deploy to Homelab / deploy (push) Successful in 18s
Persists per-username failed-attempt counts and computed locked_until
timestamps. Lockout service computes exponential-backoff durations
(min(base * 2^(count-threshold), max)) with auto-unlock once locked_until
passes. Successful login deletes the row, resetting the counter.

Pure DB-keyed lockout — survives server restarts and shares state across
both login routes (HTML and JSON) when wired in a later step.
2026-05-03 03:29:09 -07:00
f4eaf88495 feat(auth): add login_attempts schema, lockout config, dummy-hash helper
All checks were successful
Deploy to Homelab / deploy (push) Successful in 29s
Lays the foundation for brute-force defense: per-username attempt tracking
table, configurable lockout/rate-limit thresholds, and a memoized dummy
bcrypt hash so unknown-user paths can be timed identically to wrong-password
paths in a later step.

Adds @fastify/rate-limit dependency for upcoming per-IP rate-limit on
login routes.
2026-05-03 03:26:46 -07:00
d30f40ca71 Merge pull request 'chore: patch dependency vulnerabilities via npm audit fix' (#1) from chore/dependency-security-audit into main
All checks were successful
Deploy to Homelab / deploy (push) Successful in 29s
Reviewed-on: #1
2026-05-03 10:08:43 +00:00
57 changed files with 2866 additions and 912 deletions

View File

@@ -1,7 +1,6 @@
PORT=3000 PORT=3000
HOST=0.0.0.0 HOST=0.0.0.0
JWT_SECRET=change-me-to-a-long-random-secret JWT_SECRET=change-me-to-a-long-random-secret
JWT_EXPIRY=7d
DB_PATH=./data/nanodrop.db DB_PATH=./data/nanodrop.db
UPLOAD_DIR=./data/uploads UPLOAD_DIR=./data/uploads
LOG_FILE=./data/nanodrop.log LOG_FILE=./data/nanodrop.log

View File

@@ -1,4 +1,4 @@
name: "Deploy to Homelab" name: "Deploy to birb co. production"
on: on:
push: push:
@@ -14,6 +14,15 @@ jobs:
- name: Check out repository - name: Check out repository
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Validate required secrets
run: |
set -euo pipefail
: "${SSH_PRIVATE_KEY:?SSH_PRIVATE_KEY secret must be set}"
: "${JWT_SECRET:?JWT_SECRET secret must be set}"
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
- name: Set up SSH key - name: Set up SSH key
run: | run: |
mkdir -p ~/.ssh mkdir -p ~/.ssh
@@ -34,15 +43,13 @@ jobs:
- name: Deploy on server with Docker - name: Deploy on server with Docker
run: | run: |
ssh -i ~/.ssh/id_ed25519 ${{ vars.USERNAME }}@${{ vars.HOST }} << 'EOF' ssh -i ~/.ssh/id_ed25519 ${{ vars.USERNAME }}@${{ vars.HOST }} << EOF
cd ~/${{ vars.DIRECTORY_NAME }} cd ~/${{ vars.DIRECTORY_NAME }}
export JWT_SECRET='${{ secrets.JWT_SECRET }}'
export TRUST_PROXY=true export TRUST_PROXY=true
export COOKIE_SECURE=true export COOKIE_SECURE=true
export JWT_SECRET=${{ secrets.JWT_SECRET }} export PORT='${{ vars.PORT }}'
export PORT=${{ vars.PORT }} export BASE_URL='${{ vars.BASE_URL }}'
export BASE_URL=${{ vars.BASE_URL }} export MAX_FILE_SIZE='${{ vars.MAX_FILE_SIZE }}'
export MAX_FILE_SIZE=${{ vars.MAX_FILE_SIZE }} docker compose -f compose.yaml up -d --build --remove-orphans
docker compose -f docker-compose.yml down
docker compose -f docker-compose.yml up -d --build
EOF EOF

View File

@@ -1,7 +1,7 @@
FROM node:22-alpine FROM node:24-alpine
# Install native build tools for bcrypt # Install native build tools for bcrypt/better-sqlite3 + git for npm git-URL deps
RUN apk add --no-cache python3 make g++ RUN apk add --no-cache python3 make g++ git
WORKDIR /app WORKDIR /app

View File

@@ -60,7 +60,6 @@ docker compose run --rm register-user --username alice --password secret
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `JWT_SECRET` | *(required)* | Secret key for signing JWTs | | `JWT_SECRET` | *(required)* | Secret key for signing JWTs |
| `JWT_EXPIRY` | `7d` | JWT token lifetime |
| `PORT` | `3000` | Port to listen on | | `PORT` | `3000` | Port to listen on |
| `HOST` | `0.0.0.0` | Host to bind | | `HOST` | `0.0.0.0` | Host to bind |
| `BASE_URL` | `http://localhost:3000` | Public base URL (used in share links) | | `BASE_URL` | `http://localhost:3000` | Public base URL (used in share links) |
@@ -71,6 +70,8 @@ docker compose run --rm register-user --username alice --password secret
| `COOKIE_SECURE` | `false` | Set `true` when serving over HTTPS | | `COOKIE_SECURE` | `false` | Set `true` when serving over HTTPS |
| `TRUST_PROXY` | `false` | Set `true` when behind a reverse proxy | | `TRUST_PROXY` | `false` | Set `true` when behind a reverse proxy |
Session lifetime is family-pinned to 30 days with sliding renewal (see `src/constants.ts`). Not configurable per deployment.
### Reverse proxy ### Reverse proxy
Set `TRUST_PROXY=true` when running behind a reverse proxy so Nanodrop sees the real client IP in logs. Set `TRUST_PROXY=true` when running behind a reverse proxy so Nanodrop sees the real client IP in logs.
@@ -143,7 +144,7 @@ bantime = 600
Adjust `logpath` to wherever your `LOG_FILE` is. With Docker, the log file lives inside the `nanodrop-data` volume — mount it to a host path or bind-mount a host directory instead of the named volume to make it accessible to fail2ban: Adjust `logpath` to wherever your `LOG_FILE` is. With Docker, the log file lives inside the `nanodrop-data` volume — mount it to a host path or bind-mount a host directory instead of the named volume to make it accessible to fail2ban:
```yaml ```yaml
# docker-compose.yml override # compose.yaml override
volumes: volumes:
- /var/lib/nanodrop:/app/data - /var/lib/nanodrop:/app/data
``` ```

View File

@@ -2,7 +2,6 @@ x-env: &env
PORT: "${PORT:-3000}" PORT: "${PORT:-3000}"
HOST: "${HOST:-0.0.0.0}" HOST: "${HOST:-0.0.0.0}"
JWT_SECRET: "${JWT_SECRET}" JWT_SECRET: "${JWT_SECRET}"
JWT_EXPIRY: "${JWT_EXPIRY:-7d}"
DB_PATH: "${DB_PATH:-./data/nanodrop.db}" DB_PATH: "${DB_PATH:-./data/nanodrop.db}"
UPLOAD_DIR: "${UPLOAD_DIR:-./data/uploads}" UPLOAD_DIR: "${UPLOAD_DIR:-./data/uploads}"
LOG_FILE: "${LOG_FILE:-./data/nanodrop.log}" LOG_FILE: "${LOG_FILE:-./data/nanodrop.log}"

1396
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,10 @@
"dev": "tsx src/index.ts", "dev": "tsx src/index.ts",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"register-user": "tsx src/cli/register-user.ts" "register-user": "tsx src/cli/register-user.ts",
"db:migrate": "tsx src/scripts/db-migrate.ts",
"db:status": "tsx src/scripts/db-status.ts",
"db:stamp": "tsx src/scripts/db-stamp.ts"
}, },
"author": "", "author": "",
"license": "ISC", "license": "ISC",
@@ -18,15 +21,20 @@
"@fastify/formbody": "^8.0.2", "@fastify/formbody": "^8.0.2",
"@fastify/jwt": "^10.0.0", "@fastify/jwt": "^10.0.0",
"@fastify/multipart": "^9.4.0", "@fastify/multipart": "^9.4.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^9.0.0", "@fastify/static": "^9.0.0",
"@fastify/view": "^11.1.1",
"bchen-sqlite-migrate": "git+https://gitea.bchen.dev/brendan/sqlite-migrate.git#v0.1.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"better-sqlite3": "^12.6.2", "better-sqlite3": "^12.6.2",
"ejs": "^5.0.2",
"fastify": "^5.7.4", "fastify": "^5.7.4",
"nanoid": "^5.1.6" "nanoid": "^5.1.6"
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/ejs": "^3.1.5",
"@types/node": "^25.3.3", "@types/node": "^25.3.3",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",

View File

@@ -1,5 +1,3 @@
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap');
/* ── Reset ──────────────────────────────────────────────── */ /* ── Reset ──────────────────────────────────────────────── */
*, *::before, *::after { *, *::before, *::after {
box-sizing: border-box; box-sizing: border-box;
@@ -9,27 +7,60 @@
/* ── Tokens ─────────────────────────────────────────────── */ /* ── Tokens ─────────────────────────────────────────────── */
:root { :root {
--black: #000; --bg: #fff;
--white: #fff; --bg-elevated: #f0f0f0;
--gray-50: #fafafa; --bg-hover: #fafafa;
--gray-100:#f0f0f0; --fg: #000;
--gray-200:#e0e0e0; --fg-muted: #555;
--gray-400:#999; --fg-on-accent: #fff;
--gray-600:#555; --border-color: #000;
--red: #c00; --border-subtle: #e0e0e0;
--accent: #000;
--accent-hover: #555;
--danger: #c00;
--danger-bg: #fff5f5;
--danger-fg: #fff;
--input-bg: #fff;
--input-bg-focus: #fafafa;
--input-fg: #000;
--input-border: #000;
--font: 'IBM Plex Mono', 'Courier New', monospace; --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--border: 1px solid var(--black); --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--border: 1px solid var(--border-color);
--radius: 0; --radius: 0;
} }
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1115;
--bg-elevated: #1a1d23;
--bg-hover: #1f2228;
--fg: #e5e7eb;
--fg-muted: #9ca3af;
--fg-on-accent: #0f1115;
--border-color: #2a2e36;
--border-subtle: #272b33;
--accent: #e5e7eb;
--accent-hover: #cbd0d8;
--danger: #f87171;
--danger-bg: #2a1414;
--danger-fg: #0f1115;
--input-bg: #1a1d23;
--input-bg-focus: #222630;
--input-fg: #e5e7eb;
--input-border: #3a3f48;
}
}
/* ── Base ───────────────────────────────────────────────── */ /* ── Base ───────────────────────────────────────────────── */
html { font-size: 14px; } html { font-size: 14px; }
body { body {
font-family: var(--font); font-family: var(--font);
background: var(--white); line-height: 1.5;
color: var(--black); background: var(--bg);
color: var(--fg);
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -37,11 +68,11 @@ body {
} }
a { a {
color: var(--black); color: var(--fg);
text-decoration: underline; text-decoration: underline;
text-underline-offset: 3px; text-underline-offset: 3px;
} }
a:hover { color: var(--gray-600); } a:hover { color: var(--fg-muted); }
/* ── Header ─────────────────────────────────────────────── */ /* ── Header ─────────────────────────────────────────────── */
header { header {
@@ -59,9 +90,9 @@ header {
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em; letter-spacing: 0.02em;
text-decoration: none; text-decoration: none;
color: var(--black); color: var(--fg);
} }
.logo:hover { color: var(--gray-600); } .logo:hover { color: var(--fg-muted); }
nav { nav {
display: flex; display: flex;
@@ -78,12 +109,12 @@ nav a {
font-size: 12px; font-size: 12px;
letter-spacing: 0.04em; letter-spacing: 0.04em;
text-decoration: none; text-decoration: none;
color: var(--gray-600); color: var(--fg-muted);
border-left: var(--border); border-left: var(--border);
} }
nav a:hover { nav a:hover {
color: var(--black); color: var(--fg);
background: var(--gray-100); background: var(--bg-elevated);
} }
nav form { nav form {
@@ -99,13 +130,13 @@ nav button[type="submit"] {
background: none; background: none;
border: none; border: none;
border-left: var(--border); border-left: var(--border);
color: var(--gray-600); color: var(--fg-muted);
cursor: pointer; cursor: pointer;
font-family: var(--font); font-family: var(--font);
} }
nav button[type="submit"]:hover { nav button[type="submit"]:hover {
color: var(--black); color: var(--fg);
background: var(--gray-100); background: var(--bg-elevated);
} }
/* ── Main ───────────────────────────────────────────────── */ /* ── Main ───────────────────────────────────────────────── */
@@ -141,7 +172,7 @@ label {
font-size: 11px; font-size: 11px;
letter-spacing: 0.06em; letter-spacing: 0.06em;
text-transform: uppercase; text-transform: uppercase;
color: var(--gray-600); color: var(--fg-muted);
} }
input[type="text"], input[type="text"],
@@ -150,17 +181,17 @@ input[type="file"] {
width: 100%; width: 100%;
padding: 0.6rem 0.75rem; padding: 0.6rem 0.75rem;
font-family: var(--font); font-family: var(--font);
font-size: 13px; font-size: 16px;
background: var(--white); background: var(--input-bg);
border: var(--border); border: 1px solid var(--input-border);
color: var(--black); color: var(--input-fg);
outline: none; outline: none;
transition: background 0.1s; transition: background 0.1s;
} }
input[type="text"]:focus, input[type="text"]:focus,
input[type="password"]:focus { input[type="password"]:focus {
background: var(--gray-50); background: var(--input-bg-focus);
box-shadow: inset 0 0 0 2px var(--black); box-shadow: inset 0 0 0 2px var(--accent);
} }
input[type="file"] { input[type="file"] {
@@ -171,8 +202,8 @@ input[type="file"]::-webkit-file-upload-button {
font-family: var(--font); font-family: var(--font);
font-size: 11px; font-size: 11px;
letter-spacing: 0.02em; letter-spacing: 0.02em;
background: var(--black); background: var(--accent);
color: var(--white); color: var(--fg-on-accent);
border: none; border: none;
padding: 0.3rem 0.6rem; padding: 0.3rem 0.6rem;
cursor: pointer; cursor: pointer;
@@ -182,8 +213,8 @@ input[type="file"]::file-selector-button {
font-family: var(--font); font-family: var(--font);
font-size: 11px; font-size: 11px;
letter-spacing: 0.02em; letter-spacing: 0.02em;
background: var(--black); background: var(--accent);
color: var(--white); color: var(--fg-on-accent);
border: none; border: none;
padding: 0.3rem 0.6rem; padding: 0.3rem 0.6rem;
cursor: pointer; cursor: pointer;
@@ -199,16 +230,17 @@ button[type="submit"],
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em; letter-spacing: 0.02em;
background: var(--black); background: var(--accent);
color: var(--white); color: var(--fg-on-accent);
border: var(--border); border: 1px solid var(--accent);
cursor: pointer; cursor: pointer;
transition: background 0.1s, color 0.1s; transition: background 0.1s, color 0.1s;
text-align: center; text-align: center;
} }
button[type="submit"]:hover, button[type="submit"]:hover,
.btn-primary:hover { .btn-primary:hover {
background: var(--gray-600); background: var(--accent-hover);
border-color: var(--accent-hover);
} }
/* ── Danger Button ──────────────────────────────────────── */ /* ── Danger Button ──────────────────────────────────────── */
@@ -219,13 +251,13 @@ button.danger {
font-size: 11px; font-size: 11px;
letter-spacing: 0.04em; letter-spacing: 0.04em;
background: none; background: none;
color: var(--red); color: var(--danger);
border: 1px solid var(--red); border: 1px solid var(--danger);
cursor: pointer; cursor: pointer;
} }
button.danger:hover { button.danger:hover {
background: var(--red); background: var(--danger);
color: var(--white); color: var(--danger-fg);
} }
/* ── Generic Link-button (.btn for file-view) ───────────── */ /* ── Generic Link-button (.btn for file-view) ───────────── */
@@ -236,23 +268,23 @@ a.btn {
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em; letter-spacing: 0.02em;
text-decoration: none; text-decoration: none;
background: var(--black); background: var(--accent);
color: var(--white); color: var(--fg-on-accent);
border: var(--border); border: 1px solid var(--accent);
transition: background 0.1s; transition: background 0.1s;
} }
a.btn:hover { a.btn:hover {
background: var(--gray-600); background: var(--accent-hover);
color: var(--white); color: var(--fg-on-accent);
} }
/* ── Error ──────────────────────────────────────────────── */ /* ── Error ──────────────────────────────────────────────── */
.error { .error {
font-size: 12px; font-size: 12px;
color: var(--red); color: var(--danger);
padding: 0.6rem 0.75rem; padding: 0.6rem 0.75rem;
border: 1px solid var(--red); border: 1px solid var(--danger);
background: #fff5f5; background: var(--danger-bg);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
@@ -265,14 +297,15 @@ a.btn:hover {
.share-box input[readonly] { .share-box input[readonly] {
flex: 1; flex: 1;
background: var(--gray-100); font-family: var(--font-mono);
color: var(--gray-600); background: var(--bg-elevated);
color: var(--fg-muted);
border-right: none; border-right: none;
cursor: text; cursor: text;
} }
.share-box input[readonly]:focus { .share-box input[readonly]:focus {
box-shadow: none; box-shadow: none;
background: var(--gray-100); background: var(--bg-elevated);
} }
/* Copy button — outline style, distinct from submit */ /* Copy button — outline style, distinct from submit */
@@ -282,8 +315,8 @@ a.btn:hover {
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em; letter-spacing: 0.02em;
background: var(--white); background: var(--bg);
color: var(--black); color: var(--fg);
border: var(--border); border: var(--border);
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
@@ -291,7 +324,7 @@ a.btn:hover {
flex-shrink: 0; flex-shrink: 0;
} }
.share-box button:hover { .share-box button:hover {
background: var(--gray-100); background: var(--bg-elevated);
} }
/* ── Table ──────────────────────────────────────────────── */ /* ── Table ──────────────────────────────────────────────── */
@@ -309,10 +342,16 @@ h1 + p {
font-size: 12px; font-size: 12px;
} }
.table-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border: var(--border);
}
table { table {
width: 100%; width: 100%;
min-width: 100%;
border-collapse: collapse; border-collapse: collapse;
border: var(--border);
} }
th { th {
@@ -322,21 +361,21 @@ th {
letter-spacing: 0.06em; letter-spacing: 0.06em;
text-transform: uppercase; text-transform: uppercase;
text-align: left; text-align: left;
background: var(--gray-100); background: var(--bg-elevated);
border-bottom: var(--border); border-bottom: var(--border);
color: var(--gray-600); color: var(--fg-muted);
} }
td { td {
padding: 0.6rem 1rem; padding: 0.6rem 1rem;
font-size: 12px; font-size: 12px;
border-bottom: 1px solid var(--gray-200); border-bottom: 1px solid var(--border-subtle);
vertical-align: middle; vertical-align: middle;
} }
tr:last-child td { border-bottom: none; } tr:last-child td { border-bottom: none; }
tr:hover td { background: var(--gray-50); } tr:hover td { background: var(--bg-hover); }
td a { td a {
font-weight: 500; font-weight: 500;
@@ -355,14 +394,14 @@ button.copy-link {
font-family: var(--font); font-family: var(--font);
font-size: 11px; font-size: 11px;
letter-spacing: 0.04em; letter-spacing: 0.04em;
background: var(--white); background: var(--bg);
color: var(--black); color: var(--fg);
border: var(--border); border: var(--border);
cursor: pointer; cursor: pointer;
margin-right: 0.4rem; margin-right: 0.4rem;
} }
button.copy-link:hover { button.copy-link:hover {
background: var(--gray-100); background: var(--bg-elevated);
} }
/* ── File View ──────────────────────────────────────────── */ /* ── File View ──────────────────────────────────────────── */
@@ -381,7 +420,7 @@ button.copy-link:hover {
.file-view h1 { .file-view h1 {
font-size: 13px; font-size: 13px;
font-weight: 400; font-weight: 400;
color: var(--gray-600); color: var(--fg-muted);
word-break: break-all; word-break: break-all;
border-bottom: none; border-bottom: none;
padding-bottom: 0; padding-bottom: 0;
@@ -405,14 +444,14 @@ button.copy-link:hover {
/* ── Utility ─────────────────────────────────────────────── */ /* ── Utility ─────────────────────────────────────────────── */
.form-container > p { .form-container > p {
font-size: 12px; font-size: 12px;
color: var(--gray-600); color: var(--fg-muted);
margin-top: 1rem; margin-top: 1rem;
line-height: 1.7; line-height: 1.7;
} }
.form-container > p strong { .form-container > p strong {
color: var(--black); color: var(--fg);
font-weight: 600; font-weight: 600;
} }
.form-container > p a { .form-container > p a {
color: var(--black); color: var(--fg);
} }

View File

@@ -2,7 +2,6 @@ export interface Config {
port: number; port: number;
host: string; host: string;
jwtSecret: string; jwtSecret: string;
jwtExpiry: string;
dbPath: string; dbPath: string;
uploadDir: string; uploadDir: string;
logFile: string; logFile: string;
@@ -10,6 +9,12 @@ export interface Config {
baseUrl: string; baseUrl: string;
cookieSecure: boolean; cookieSecure: boolean;
trustProxy: boolean; trustProxy: boolean;
lockoutThreshold: number;
lockoutBaseSeconds: number;
lockoutMaxSeconds: number;
loginMinResponseMs: number;
loginRateLimitMax: number;
loginRateLimitWindowSeconds: number;
} }
export function loadConfig(): Config { export function loadConfig(): Config {
@@ -22,7 +27,6 @@ export function loadConfig(): Config {
port: parseInt(process.env.PORT ?? '3000', 10), port: parseInt(process.env.PORT ?? '3000', 10),
host: process.env.HOST ?? '0.0.0.0', host: process.env.HOST ?? '0.0.0.0',
jwtSecret, jwtSecret,
jwtExpiry: process.env.JWT_EXPIRY ?? '7d',
dbPath: process.env.DB_PATH ?? './data/nanodrop.db', dbPath: process.env.DB_PATH ?? './data/nanodrop.db',
uploadDir: process.env.UPLOAD_DIR ?? './data/uploads', uploadDir: process.env.UPLOAD_DIR ?? './data/uploads',
logFile: process.env.LOG_FILE ?? './data/nanodrop.log', logFile: process.env.LOG_FILE ?? './data/nanodrop.log',
@@ -30,5 +34,11 @@ export function loadConfig(): Config {
baseUrl: process.env.BASE_URL ?? 'http://localhost:3000', baseUrl: process.env.BASE_URL ?? 'http://localhost:3000',
cookieSecure: process.env.COOKIE_SECURE === 'true', cookieSecure: process.env.COOKIE_SECURE === 'true',
trustProxy: process.env.TRUST_PROXY === 'true', trustProxy: process.env.TRUST_PROXY === 'true',
lockoutThreshold: parseInt(process.env.LOCKOUT_THRESHOLD ?? '5', 10),
lockoutBaseSeconds: parseInt(process.env.LOCKOUT_BASE_SECONDS ?? '30', 10),
lockoutMaxSeconds: parseInt(process.env.LOCKOUT_MAX_SECONDS ?? '3600', 10),
loginMinResponseMs: parseInt(process.env.LOGIN_MIN_RESPONSE_MS ?? '350', 10),
loginRateLimitMax: parseInt(process.env.LOGIN_RATE_LIMIT_MAX ?? '10', 10),
loginRateLimitWindowSeconds: parseInt(process.env.LOGIN_RATE_LIMIT_WINDOW_SECONDS ?? '60', 10),
}; };
} }

8
src/constants.ts Normal file
View File

@@ -0,0 +1,8 @@
// Family-wide session policy. Used by every bchen.dev app's session cookie.
// DO NOT diverge per-app — coherence across apps is a feature.
export const SESSION_TTL_DAYS = 30;
export const SESSION_TTL_SECONDS = SESSION_TTL_DAYS * 24 * 60 * 60;
export const SESSION_RENEW_THRESHOLD_SECONDS = 60 * 60;
export const SESSION_COOKIE_NAME = 'nanodrop_session';
export const LOGOUT_PATHS = new Set<string>(['/logout', '/api/v1/auth/logout']);

38
src/db/login-attempts.ts Normal file
View File

@@ -0,0 +1,38 @@
import type Database from 'better-sqlite3';
export interface LoginAttemptRow {
username: string;
failed_count: number;
last_failed_at: string | null;
locked_until: string | null;
}
export function getLoginAttempt(
db: Database.Database,
username: string,
): LoginAttemptRow | undefined {
const stmt = db.prepare('SELECT * FROM login_attempts WHERE username = ?');
return stmt.get(username) as LoginAttemptRow | undefined;
}
export function recordFailure(
db: Database.Database,
username: string,
lockedUntilIso: string | null,
): LoginAttemptRow {
const stmt = db.prepare(`
INSERT INTO login_attempts (username, failed_count, last_failed_at, locked_until)
VALUES (?, 1, datetime('now'), ?)
ON CONFLICT(username) DO UPDATE SET
failed_count = failed_count + 1,
last_failed_at = datetime('now'),
locked_until = excluded.locked_until
RETURNING *
`);
return stmt.get(username, lockedUntilIso) as LoginAttemptRow;
}
export function resetLoginAttempts(db: Database.Database, username: string): void {
const stmt = db.prepare('DELETE FROM login_attempts WHERE username = ?');
stmt.run(username);
}

View File

@@ -0,0 +1,26 @@
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS files (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
original_name TEXT NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
stored_name TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS login_attempts (
username TEXT PRIMARY KEY,
failed_count INTEGER NOT NULL DEFAULT 0,
last_failed_at TEXT,
locked_until TEXT
);
CREATE INDEX IF NOT EXISTS idx_login_attempts_locked_until
ON login_attempts(locked_until);

View File

@@ -1,28 +1,27 @@
import path from 'node:path';
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { applyMigrations } from 'bchen-sqlite-migrate';
export const MIGRATIONS_DIR = path.resolve(import.meta.dirname, 'migrations');
export function applySchema(db: Database.Database): void {
applyMigrations(db, MIGRATIONS_DIR, { genesisProbeTable: 'users' });
}
export function initDb(dbPath: string): Database.Database { export function initDb(dbPath: string): Database.Database {
const db = new Database(dbPath); const db = new Database(dbPath);
db.pragma('journal_mode = WAL'); db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON'); db.pragma('foreign_keys = ON');
db.exec(` const stampGenesis = process.env.DB_MIGRATIONS_STAMP_GENESIS === '1';
CREATE TABLE IF NOT EXISTS users ( const summary = applyMigrations(db, MIGRATIONS_DIR, {
id INTEGER PRIMARY KEY AUTOINCREMENT, stampGenesis,
username TEXT NOT NULL UNIQUE, genesisProbeTable: 'users',
password_hash TEXT NOT NULL, logger: (msg) => process.stdout.write(`${msg}\n`),
created_at TEXT DEFAULT (datetime('now')) });
); process.stdout.write(
`migrations: ${summary.applied + summary.alreadyApplied} applied, ${summary.pending} pending\n`,
CREATE TABLE IF NOT EXISTS files ( );
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
original_name TEXT NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
stored_name TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
`);
return db; return db;
} }

View File

@@ -1,24 +1,47 @@
import type { FastifyRequest, FastifyReply } from 'fastify'; import type { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
import type { Config } from '../config.ts';
import type { JwtPayload } from '../types.ts';
import { SESSION_COOKIE_NAME, SESSION_TTL_SECONDS } from '../constants.ts';
import { slideSessionIfNeeded } from './session-renewal.ts';
export async function requireAuth(request: FastifyRequest, reply: FastifyReply): Promise<void> { export function sessionCookieOptions(secure: boolean): {
try {
await request.jwtVerify();
} catch {
// API routes get 401, page routes get redirect
const isApi = request.url.startsWith('/api/');
if (isApi) {
reply.status(401).send({ error: 'Unauthorized' });
} else {
reply.redirect('/');
}
}
}
export function tokenCookieOptions(secure: boolean): {
httpOnly: boolean; httpOnly: boolean;
sameSite: 'strict'; sameSite: 'strict';
secure: boolean; secure: boolean;
path: string; path: string;
maxAge: number;
} { } {
return { httpOnly: true, sameSite: 'strict', secure, path: '/' }; return {
httpOnly: true,
sameSite: 'strict',
secure,
path: '/',
maxAge: SESSION_TTL_SECONDS,
};
}
export function issueSessionCookie(
reply: FastifyReply,
server: FastifyInstance,
claims: JwtPayload,
cookieSecure: boolean,
): void {
const token = server.jwt.sign(claims, { expiresIn: SESSION_TTL_SECONDS });
reply.setCookie(SESSION_COOKIE_NAME, token, sessionCookieOptions(cookieSecure));
}
export function makeRequireAuth(config: Config) {
return async function requireAuth(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const payload = await request.jwtVerify<JwtPayload>();
slideSessionIfNeeded(request, reply, payload, request.server, config.cookieSecure);
} catch {
const isApi = request.url.startsWith('/api/');
if (isApi) {
reply.status(401).send({ error: 'Unauthorized' });
} else {
reply.redirect('/');
}
}
};
} }

View File

@@ -12,9 +12,26 @@ interface FileNotFoundParams {
fileId: string; fileId: string;
} }
interface AuthLockoutTriggeredParams extends AuthLogParams {
durationSeconds: number;
}
interface AuthLockedAttemptParams extends AuthLogParams {
retryAfterSeconds: number;
}
interface AuthRateLimitedParams {
ip: string;
userAgent: string;
route: string;
}
export interface Logger { export interface Logger {
authSuccess(params: AuthLogParams): Promise<void>; authSuccess(params: AuthLogParams): Promise<void>;
authFailure(params: AuthLogParams): Promise<void>; authFailure(params: AuthLogParams): Promise<void>;
authLockoutTriggered(params: AuthLockoutTriggeredParams): Promise<void>;
authLockedAttempt(params: AuthLockedAttemptParams): Promise<void>;
authRateLimited(params: AuthRateLimitedParams): Promise<void>;
fileNotFound(params: FileNotFoundParams): Promise<void>; fileNotFound(params: FileNotFoundParams): Promise<void>;
} }
@@ -34,6 +51,12 @@ export function createLogger(logFile: string): Logger {
return { return {
authSuccess: (params) => write(authLine('AUTH_SUCCESS', params)), authSuccess: (params) => write(authLine('AUTH_SUCCESS', params)),
authFailure: (params) => write(authLine('AUTH_FAILURE', params)), authFailure: (params) => write(authLine('AUTH_FAILURE', params)),
authLockoutTriggered: ({ durationSeconds, ...auth }) =>
write(`${authLine('AUTH_LOCKOUT_TRIGGERED', auth)} duration_seconds=${durationSeconds}`),
authLockedAttempt: ({ retryAfterSeconds, ...auth }) =>
write(`${authLine('AUTH_LOCKED_ATTEMPT', auth)} retry_after_seconds=${retryAfterSeconds}`),
authRateLimited: ({ ip, userAgent, route }) =>
write(`[${timestamp()}] AUTH_RATE_LIMITED ip=${ip} user-agent="${userAgent}" route="${route}"`),
fileNotFound: ({ ip, userAgent, fileId }) => fileNotFound: ({ ip, userAgent, fileId }) =>
write(`[${timestamp()}] FILE_NOT_FOUND ip=${ip} user-agent="${userAgent}" file_id="${fileId}"`), write(`[${timestamp()}] FILE_NOT_FOUND ip=${ip} user-agent="${userAgent}" file_id="${fileId}"`),
}; };

View File

@@ -0,0 +1,27 @@
import type { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
import type { JwtPayload } from '../types.ts';
import { LOGOUT_PATHS, SESSION_RENEW_THRESHOLD_SECONDS } from '../constants.ts';
import { issueSessionCookie } from './auth.ts';
// Family pattern includes an mfa-pending early-return; nanodrop has no MFA, so this is N/A.
export function slideSessionIfNeeded(
request: FastifyRequest,
reply: FastifyReply,
payload: JwtPayload,
server: FastifyInstance,
cookieSecure: boolean,
): void {
const path = request.url.split('?', 1)[0];
if (LOGOUT_PATHS.has(path)) return;
const nowSec = Math.floor(Date.now() / 1000);
const iat = payload.iat ?? 0;
if (nowSec - iat < SESSION_RENEW_THRESHOLD_SECONDS) return;
issueSessionCookie(
reply,
server,
{ sub: payload.sub, username: payload.username },
cookieSecure,
);
}

View File

@@ -2,14 +2,16 @@ import type { FastifyPluginAsync } from 'fastify';
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import type { Config } from '../../../config.ts'; import type { Config } from '../../../config.ts';
import type { Logger } from '../../../middleware/logging.ts'; import type { Logger } from '../../../middleware/logging.ts';
import { getUserByUsername } from '../../../db/users.ts'; import type { LockoutService } from '../../../services/lockout.ts';
import { verifyPassword } from '../../../services/auth.ts'; import { attemptLogin } from '../../../services/login-handler.ts';
import { requireAuth, tokenCookieOptions } from '../../../middleware/auth.ts'; import { makeRequireAuth, issueSessionCookie } from '../../../middleware/auth.ts';
import { SESSION_COOKIE_NAME } from '../../../constants.ts';
interface Deps { interface Deps {
db: Database.Database; db: Database.Database;
config: Config; config: Config;
logger: Logger; logger: Logger;
lockout: LockoutService;
} }
interface LoginBody { interface LoginBody {
@@ -18,32 +20,55 @@ interface LoginBody {
} }
export const authApiRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }) => { export const authApiRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }) => {
const { db, config, logger } = deps; const { config } = deps;
const requireAuth = makeRequireAuth(config);
app.post<{ Body: LoginBody }>('/login', async (request, reply) => { app.post<{ Body: LoginBody }>(
const { username, password } = request.body ?? {}; '/login',
const ip = request.ip; {
const userAgent = request.headers['user-agent'] ?? ''; config: {
rateLimit: {
max: config.loginRateLimitMax,
timeWindow: config.loginRateLimitWindowSeconds * 1000,
},
},
},
async (request, reply) => {
const { username, password } = request.body ?? {};
if (!username || !password) { const result = await attemptLogin(deps, {
return reply.status(400).send({ error: 'username and password are required' }); username: username ?? '',
} password: password ?? '',
ip: request.ip,
userAgent: request.headers['user-agent'] ?? '',
});
const user = getUserByUsername(db, username); if (result.kind === 'bad_request') {
const valid = user ? await verifyPassword(password, user.password_hash) : false; return reply.status(400).send({ error: 'username and password are required' });
}
if (!user || !valid) { if (result.kind === 'locked') {
await logger.authFailure({ ip, userAgent, username }); return reply
return reply.status(401).send({ error: 'Invalid credentials' }); .status(401)
} .header('Retry-After', String(result.retryAfterSeconds))
.send({ error: 'Invalid credentials' });
}
await logger.authSuccess({ ip, userAgent, username }); if (result.kind === 'bad_credentials') {
return reply.status(401).send({ error: 'Invalid credentials' });
}
const token = app.jwt.sign({ sub: user.id, username: user.username }, { expiresIn: config.jwtExpiry }); issueSessionCookie(
reply.setCookie('token', token, tokenCookieOptions(config.cookieSecure)).send({ ok: true }); reply,
}); app,
{ sub: result.user.id, username: result.user.username },
config.cookieSecure,
);
reply.send({ ok: true });
},
);
app.post('/logout', { preHandler: requireAuth }, async (_request, reply) => { app.post('/logout', { preHandler: requireAuth }, async (_request, reply) => {
reply.clearCookie('token', { path: '/' }).send({ ok: true }); reply.clearCookie(SESSION_COOKIE_NAME, { path: '/' }).send({ ok: true });
}); });
}; };

View File

@@ -7,7 +7,7 @@ import type { Logger } from '../../../middleware/logging.ts';
import type { JwtPayload } from '../../../types.ts'; import type { JwtPayload } from '../../../types.ts';
import { createFile, getFilesByUserId, getFileById, deleteFile } from '../../../db/files.ts'; import { createFile, getFilesByUserId, getFileById, deleteFile } from '../../../db/files.ts';
import { saveFile, deleteStoredFile } from '../../../services/storage.ts'; import { saveFile, deleteStoredFile } from '../../../services/storage.ts';
import { requireAuth } from '../../../middleware/auth.ts'; import { makeRequireAuth } from '../../../middleware/auth.ts';
interface Deps { interface Deps {
db: Database.Database; db: Database.Database;
@@ -17,6 +17,7 @@ interface Deps {
export const filesApiRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }) => { export const filesApiRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }) => {
const { db, config } = deps; const { db, config } = deps;
const requireAuth = makeRequireAuth(config);
app.get('/', { preHandler: requireAuth }, async (request, reply) => { app.get('/', { preHandler: requireAuth }, async (request, reply) => {
const { sub: userId } = request.user as JwtPayload; const { sub: userId } = request.user as JwtPayload;

View File

@@ -6,21 +6,24 @@ import type Database from 'better-sqlite3';
import type { Config } from '../config.ts'; import type { Config } from '../config.ts';
import type { Logger } from '../middleware/logging.ts'; import type { Logger } from '../middleware/logging.ts';
import type { JwtPayload } from '../types.ts'; import type { JwtPayload } from '../types.ts';
import { getUserByUsername } from '../db/users.ts'; import type { LockoutService } from '../services/lockout.ts';
import { createFile, getFileById, getFilesByUserId, deleteFile } from '../db/files.ts'; import { createFile, getFileById, getFilesByUserId, deleteFile } from '../db/files.ts';
import { verifyPassword } from '../services/auth.ts';
import { saveFile, deleteStoredFile, getFilePath } from '../services/storage.ts'; import { saveFile, deleteStoredFile, getFilePath } from '../services/storage.ts';
import { requireAuth, tokenCookieOptions } from '../middleware/auth.ts'; import { attemptLogin } from '../services/login-handler.ts';
import { loginPage } from '../views/login.ts'; import { makeRequireAuth, issueSessionCookie } from '../middleware/auth.ts';
import { uploadPage, uploadResultPage } from '../views/upload.ts'; import { SESSION_COOKIE_NAME } from '../constants.ts';
import { fileListPage } from '../views/file-list.ts';
import { fileViewPage } from '../views/file-view.ts'; function formatBytes(bytes: number): string {
import { notFoundPage } from '../views/not-found.ts'; if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
interface Deps { interface Deps {
db: Database.Database; db: Database.Database;
config: Config; config: Config;
logger: Logger; logger: Logger;
lockout: LockoutService;
} }
function parseRangeHeader(header: string, fileSize: number): { start: number; end: number } | null { function parseRangeHeader(header: string, fileSize: number): { start: number; end: number } | null {
@@ -48,45 +51,67 @@ function parseRangeHeader(header: string, fileSize: number): { start: number; en
export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }) => { export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }) => {
const { db, config, logger } = deps; const { db, config, logger } = deps;
const requireAuth = makeRequireAuth(config);
const loginRateLimit = {
rateLimit: {
max: config.loginRateLimitMax,
timeWindow: config.loginRateLimitWindowSeconds * 1000,
},
};
// GET / — login page or redirect if authed // GET / — login page or redirect if authed
// opportunistic auth — does not slide the session; see middleware/session-renewal.ts
app.get('/', async (request, reply) => { app.get('/', async (request, reply) => {
try { try {
await request.jwtVerify(); await request.jwtVerify();
return reply.redirect('/upload'); return reply.redirect('/upload');
} catch { } catch {
return reply.type('text/html').send(loginPage()); return reply.view('login.ejs', { title: 'Login', error: null });
} }
}); });
// POST /login — form login // POST /login — form login
app.post<{ Body: { username?: string; password?: string } }>('/login', async (request, reply) => { app.post<{ Body: { username?: string; password?: string } }>(
const { username = '', password = '' } = request.body ?? {}; '/login',
const ip = request.ip; { config: loginRateLimit },
const userAgent = request.headers['user-agent'] ?? ''; async (request, reply) => {
const { username = '', password = '' } = request.body ?? {};
const user = getUserByUsername(db, username); const result = await attemptLogin(deps, {
const valid = user ? await verifyPassword(password, user.password_hash) : false; username,
password,
ip: request.ip,
userAgent: request.headers['user-agent'] ?? '',
});
if (!user || !valid) { if (result.kind === 'locked') {
await logger.authFailure({ ip, userAgent, username }); return reply
return reply.type('text/html').send(loginPage({ error: 'Invalid username or password' })); .header('Retry-After', String(result.retryAfterSeconds))
} .view('login.ejs', { title: 'Login', error: 'Invalid username or password' });
}
await logger.authSuccess({ ip, userAgent, username }); if (result.kind !== 'success') {
return reply.view('login.ejs', { title: 'Login', error: 'Invalid username or password' });
}
const token = app.jwt.sign({ sub: user.id, username: user.username }, { expiresIn: config.jwtExpiry }); issueSessionCookie(
reply.setCookie('token', token, tokenCookieOptions(config.cookieSecure)).redirect('/upload'); reply,
}); app,
{ sub: result.user.id, username: result.user.username },
config.cookieSecure,
);
reply.redirect('/upload');
},
);
// POST /logout // POST /logout
app.post('/logout', { preHandler: requireAuth }, async (_request, reply) => { app.post('/logout', { preHandler: requireAuth }, async (_request, reply) => {
reply.clearCookie('token', { path: '/' }).redirect('/'); reply.clearCookie(SESSION_COOKIE_NAME, { path: '/' }).redirect('/');
}); });
// GET /upload // GET /upload
app.get('/upload', { preHandler: requireAuth }, async (_request, reply) => { app.get('/upload', { preHandler: requireAuth }, async (_request, reply) => {
reply.type('text/html').send(uploadPage()); return reply.view('upload.ejs', { title: 'Upload', authed: true, error: null });
}); });
// POST /upload // POST /upload
@@ -95,7 +120,7 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps
const data = await request.file(); const data = await request.file();
if (!data) { if (!data) {
return reply.type('text/html').send(uploadPage({ error: 'No file selected' })); return reply.view('upload.ejs', { title: 'Upload', authed: true, error: 'No file selected' });
} }
const fileBuffer = await data.toBuffer(); const fileBuffer = await data.toBuffer();
@@ -116,14 +141,15 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps
}); });
const shareUrl = `${config.baseUrl}/f/${id}`; const shareUrl = `${config.baseUrl}/f/${id}`;
reply.type('text/html').send(uploadResultPage(shareUrl, data.filename)); return reply.view('upload-result.ejs', { title: 'File uploaded', authed: true, shareUrl, filename: data.filename });
}); });
// GET /files // GET /files
app.get('/files', { preHandler: requireAuth }, async (request, reply) => { app.get('/files', { preHandler: requireAuth }, async (request, reply) => {
const { sub: userId } = request.user as JwtPayload; const { sub: userId } = request.user as JwtPayload;
const files = getFilesByUserId(db, userId); const files = getFilesByUserId(db, userId);
reply.type('text/html').send(fileListPage(files, config.baseUrl)); const filesWithSize = files.map((f) => ({ ...f, sizeFormatted: formatBytes(f.size) }));
return reply.view('file-list.ejs', { title: 'My files', authed: true, files: filesWithSize, baseUrl: config.baseUrl });
}); });
// POST /files/:id/delete // POST /files/:id/delete
@@ -143,6 +169,7 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps
}); });
// GET /f/:id — public file view (owner-aware) // GET /f/:id — public file view (owner-aware)
// opportunistic auth — does not slide the session; see middleware/session-renewal.ts
app.get<{ Params: { id: string } }>('/f/:id', async (request, reply) => { app.get<{ Params: { id: string } }>('/f/:id', async (request, reply) => {
const { id } = request.params; const { id } = request.params;
@@ -156,11 +183,17 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps
if (!file) { if (!file) {
await logger.fileNotFound({ ip: request.ip, userAgent: request.headers['user-agent'] ?? '', fileId: id }); await logger.fileNotFound({ ip: request.ip, userAgent: request.headers['user-agent'] ?? '', fileId: id });
return reply.status(404).type('text/html').send(notFoundPage()); return reply.status(404).view('not-found.ejs', { title: 'Not found' });
} }
const isOwner = userId !== null && userId === file.user_id; const isOwner = userId !== null && userId === file.user_id;
reply.type('text/html').send(fileViewPage(file, isOwner)); return reply.view('file-view.ejs', {
title: file.original_name,
authed: isOwner,
hideHeader: !isOwner,
file,
isOwner,
});
}); });
// GET /f/:id/raw — serve raw file with range request support // GET /f/:id/raw — serve raw file with range request support
@@ -209,6 +242,6 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps
// 404 handler // 404 handler
app.setNotFoundHandler((_request, reply) => { app.setNotFoundHandler((_request, reply) => {
reply.status(404).type('text/html').send(notFoundPage()); return reply.status(404).view('not-found.ejs', { title: 'Not found' });
}); });
}; };

21
src/scripts/_db-cli.ts Normal file
View File

@@ -0,0 +1,21 @@
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import Database from 'better-sqlite3';
import { loadConfig } from '../config.ts';
import { MIGRATIONS_DIR } from '../db/schema.ts';
export interface DbCliContext {
db: Database.Database;
migrationsDir: string;
}
export function openDbFromConfig(): DbCliContext {
const config = loadConfig();
mkdirSync(dirname(config.dbPath), { recursive: true });
const db = new Database(config.dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
return { db, migrationsDir: MIGRATIONS_DIR };
}

26
src/scripts/db-migrate.ts Normal file
View File

@@ -0,0 +1,26 @@
import { applyMigrations } from 'bchen-sqlite-migrate';
import { openDbFromConfig } from './_db-cli.ts';
const { db, migrationsDir } = openDbFromConfig();
const stampGenesis = process.env.DB_MIGRATIONS_STAMP_GENESIS === '1';
const summary = applyMigrations(db, migrationsDir, {
stampGenesis,
genesisProbeTable: 'users',
logger: (msg) => process.stdout.write(`${msg}\n`),
});
if (summary.stamped.length > 0) {
process.stdout.write(`stamped:${summary.stamped.join(',')}\n`);
}
if (summary.applied === 0 && summary.stamped.length === 0) {
process.stdout.write(`pending:none\n`);
} else if (summary.applied > 0) {
process.stdout.write(`applied:${summary.applied}\n`);
}
process.stdout.write(
`migrations: ${summary.applied + summary.alreadyApplied} applied, ${summary.pending} pending\n`,
);
db.close();
process.exit(0);

22
src/scripts/db-stamp.ts Normal file
View File

@@ -0,0 +1,22 @@
import { stampMigration } from 'bchen-sqlite-migrate';
import { openDbFromConfig } from './_db-cli.ts';
const version = process.argv[2];
if (!version) {
process.stderr.write('Usage: npm run db:stamp -- <version>\n');
process.exit(1);
}
const { db, migrationsDir } = openDbFromConfig();
try {
const file = stampMigration(db, migrationsDir, version);
process.stdout.write(`stamped:${file.version}\n`);
db.close();
process.exit(0);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`error: ${msg}\n`);
db.close();
process.exit(1);
}

34
src/scripts/db-status.ts Normal file
View File

@@ -0,0 +1,34 @@
import { listMigrations, readAppliedRows } from 'bchen-sqlite-migrate';
import { openDbFromConfig } from './_db-cli.ts';
const { db, migrationsDir } = openDbFromConfig();
const files = listMigrations(migrationsDir);
const appliedByVersion = new Map(readAppliedRows(db).map((r) => [r.version, r]));
let exitCode = 0;
const pending: string[] = [];
const mismatched: string[] = [];
for (const file of files) {
const applied = appliedByVersion.get(file.version);
if (!applied) {
pending.push(file.version);
process.stdout.write(`pending:${file.version}\n`);
continue;
}
if (applied.checksum !== file.checksum) {
mismatched.push(file.version);
process.stdout.write(`checksum-mismatch:${file.version}\n`);
exitCode = 2;
continue;
}
process.stdout.write(`applied:${file.version}\n`);
}
if (pending.length === 0 && mismatched.length === 0) {
process.stdout.write(`pending:none\n`);
}
db.close();
process.exit(exitCode);

View File

@@ -4,11 +4,16 @@ import fastifyJwt from '@fastify/jwt';
import fastifyMultipart from '@fastify/multipart'; import fastifyMultipart from '@fastify/multipart';
import fastifyFormbody from '@fastify/formbody'; import fastifyFormbody from '@fastify/formbody';
import fastifyStatic from '@fastify/static'; import fastifyStatic from '@fastify/static';
import fastifyRateLimit from '@fastify/rate-limit';
import fastifyView from '@fastify/view';
import ejs from 'ejs';
import { join } from 'path'; import { join } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import type { Config } from './config.ts'; import type { Config } from './config.ts';
import { SESSION_COOKIE_NAME } from './constants.ts';
import { createLogger } from './middleware/logging.ts'; import { createLogger } from './middleware/logging.ts';
import { createLockoutService } from './services/lockout.ts';
import { authApiRoutes } from './routes/api/v1/auth.ts'; import { authApiRoutes } from './routes/api/v1/auth.ts';
import { filesApiRoutes } from './routes/api/v1/files.ts'; import { filesApiRoutes } from './routes/api/v1/files.ts';
import { pageRoutes } from './routes/pages.ts'; import { pageRoutes } from './routes/pages.ts';
@@ -23,11 +28,12 @@ interface ServerDeps {
export function createServer({ config, db }: ServerDeps) { export function createServer({ config, db }: ServerDeps) {
const app = Fastify({ logger: false, trustProxy: config.trustProxy }); const app = Fastify({ logger: false, trustProxy: config.trustProxy });
const logger = createLogger(config.logFile); const logger = createLogger(config.logFile);
const lockout = createLockoutService({ db, config });
app.register(fastifyCookie); app.register(fastifyCookie);
app.register(fastifyJwt, { app.register(fastifyJwt, {
secret: config.jwtSecret, secret: config.jwtSecret,
cookie: { cookieName: 'token', signed: false }, cookie: { cookieName: SESSION_COOKIE_NAME, signed: false },
}); });
app.register(fastifyFormbody); app.register(fastifyFormbody);
app.register(fastifyMultipart, { limits: { fileSize: config.maxFileSize } }); app.register(fastifyMultipart, { limits: { fileSize: config.maxFileSize } });
@@ -35,8 +41,26 @@ export function createServer({ config, db }: ServerDeps) {
root: join(__dirname, '..', 'public'), root: join(__dirname, '..', 'public'),
prefix: '/public/', prefix: '/public/',
}); });
app.register(fastifyView, {
engine: { ejs },
root: join(__dirname, '..', 'views'),
layout: '_layout.ejs',
defaultContext: { authed: false, hideHeader: false },
});
app.register(fastifyRateLimit, {
global: false,
keyGenerator: (req) => req.ip,
errorResponseBuilder: (req) => {
void logger.authRateLimited({
ip: req.ip,
userAgent: req.headers['user-agent'] ?? '',
route: req.url,
});
return { statusCode: 429, error: 'Too many requests' };
},
});
const deps = { db, config, logger }; const deps = { db, config, logger, lockout };
app.register(authApiRoutes, { prefix: '/api/v1/auth', deps }); app.register(authApiRoutes, { prefix: '/api/v1/auth', deps });
app.register(filesApiRoutes, { prefix: '/api/v1/files', deps }); app.register(filesApiRoutes, { prefix: '/api/v1/files', deps });

View File

@@ -0,0 +1,25 @@
import bcrypt from 'bcrypt';
import { hashPassword } from './auth.ts';
let cachedHash: Promise<string> | null = null;
function getDummyHash(): Promise<string> {
if (!cachedHash) {
// Hash a value no caller will ever submit (cryptographically random
// string generated once at module init). Cost factor matches real users
// because hashPassword uses the same SALT_ROUNDS.
const seed = `dummy:${Date.now()}:${Math.random()}:${process.pid}`;
cachedHash = hashPassword(seed);
}
return cachedHash;
}
export async function verifyAgainstDummy(password: string): Promise<boolean> {
const hash = await getDummyHash();
await bcrypt.compare(password, hash);
return false;
}
export function _resetDummyHashForTests(): void {
cachedHash = null;
}

78
src/services/lockout.ts Normal file
View File

@@ -0,0 +1,78 @@
import type Database from 'better-sqlite3';
import type { Config } from '../config.ts';
import {
getLoginAttempt,
recordFailure as dbRecordFailure,
resetLoginAttempts,
} from '../db/login-attempts.ts';
export interface LockoutCheckResult {
locked: boolean;
retryAfterSeconds?: number;
}
export interface LockoutFailureResult {
locked: boolean;
durationSeconds: number;
failedCount: number;
}
export interface LockoutService {
check(username: string): LockoutCheckResult;
recordFailure(username: string): LockoutFailureResult;
recordSuccess(username: string): void;
}
interface LockoutDeps {
db: Database.Database;
config: Config;
now?: () => Date;
}
function computeDurationSeconds(failedCount: number, config: Config): number {
const { lockoutThreshold, lockoutBaseSeconds, lockoutMaxSeconds } = config;
if (failedCount < lockoutThreshold) return 0;
const exponent = failedCount - lockoutThreshold;
const raw = lockoutBaseSeconds * 2 ** exponent;
return Math.min(lockoutMaxSeconds, raw);
}
export function createLockoutService(deps: LockoutDeps): LockoutService {
const { db, config } = deps;
const now = deps.now ?? ((): Date => new Date());
return {
check(username: string): LockoutCheckResult {
const row = getLoginAttempt(db, username);
if (!row?.locked_until) return { locked: false };
const lockedUntilMs = Date.parse(row.locked_until);
const remainingMs = lockedUntilMs - now().getTime();
if (remainingMs <= 0) return { locked: false };
return { locked: true, retryAfterSeconds: Math.ceil(remainingMs / 1000) };
},
recordFailure(username: string): LockoutFailureResult {
const existing = getLoginAttempt(db, username);
const nextCount = (existing?.failed_count ?? 0) + 1;
const durationSeconds = computeDurationSeconds(nextCount, config);
const lockedUntilIso =
durationSeconds > 0
? new Date(now().getTime() + durationSeconds * 1000).toISOString()
: null;
dbRecordFailure(db, username, lockedUntilIso);
return {
locked: durationSeconds > 0,
durationSeconds,
failedCount: nextCount,
};
},
recordSuccess(username: string): void {
resetLoginAttempts(db, username);
},
};
}

View File

@@ -0,0 +1,90 @@
import type Database from 'better-sqlite3';
import type { Config } from '../config.ts';
import type { Logger } from '../middleware/logging.ts';
import type { LockoutService } from './lockout.ts';
import { getUserByUsername } from '../db/users.ts';
import { verifyPassword } from './auth.ts';
import { verifyAgainstDummy } from './dummy-hash.ts';
interface UserRow {
id: number;
username: string;
password_hash: string;
created_at: string;
}
export type LoginResult =
| { kind: 'success'; user: UserRow }
| { kind: 'bad_credentials' }
| { kind: 'locked'; retryAfterSeconds: number }
| { kind: 'bad_request' };
export interface LoginHandlerDeps {
db: Database.Database;
config: Config;
logger: Logger;
lockout: LockoutService;
}
export interface LoginInput {
username: string;
password: string;
ip: string;
userAgent: string;
}
function canonicalize(username: string): string {
return username.trim().toLowerCase();
}
async function clamp(startMs: number, minMs: number): Promise<void> {
const elapsed = Date.now() - startMs;
const remaining = minMs - elapsed;
if (remaining > 0) {
await new Promise((resolve) => setTimeout(resolve, remaining));
}
}
export async function attemptLogin(
deps: LoginHandlerDeps,
input: LoginInput,
): Promise<LoginResult> {
const { db, config, logger, lockout } = deps;
const start = Date.now();
if (!input.username || !input.password) {
// No clamp on bad_request — it's a programmer/format error from the caller,
// not a credential test, so timing isn't sensitive.
return { kind: 'bad_request' };
}
const username = canonicalize(input.username);
const logBase = { ip: input.ip, userAgent: input.userAgent, username };
const lockStatus = lockout.check(username);
if (lockStatus.locked) {
await logger.authLockedAttempt({ ...logBase, retryAfterSeconds: lockStatus.retryAfterSeconds! });
await clamp(start, config.loginMinResponseMs);
return { kind: 'locked', retryAfterSeconds: lockStatus.retryAfterSeconds! };
}
const user = getUserByUsername(db, username);
const valid = user
? await verifyPassword(input.password, user.password_hash)
: await verifyAgainstDummy(input.password);
if (user && valid) {
lockout.recordSuccess(username);
await logger.authSuccess(logBase);
await clamp(start, config.loginMinResponseMs);
return { kind: 'success', user };
}
const failure = lockout.recordFailure(username);
if (failure.locked) {
await logger.authLockoutTriggered({ ...logBase, durationSeconds: failure.durationSeconds });
}
await logger.authFailure(logBase);
await clamp(start, config.loginMinResponseMs);
return { kind: 'bad_credentials' };
}

View File

@@ -1,4 +1,5 @@
export interface JwtPayload { export interface JwtPayload {
sub: number; sub: number;
username: string; username: string;
iat?: number;
} }

View File

@@ -1,42 +0,0 @@
import { layout, escHtml } from './layout.ts';
import type { FileRow } from '../db/files.ts';
export function fileListPage(files: FileRow[], baseUrl: string): string {
const rows = files.length === 0
? '<tr><td colspan="5">No files yet. <a href="/upload">Upload one.</a></td></tr>'
: files.map((f) => {
const shareUrl = `${baseUrl}/f/${escHtml(f.id)}`;
return `
<tr>
<td><a href="/f/${escHtml(f.id)}">${escHtml(f.original_name)}</a></td>
<td>${escHtml(f.mime_type)}</td>
<td>${formatBytes(f.size)}</td>
<td>${escHtml(f.created_at)}</td>
<td>
<button class="copy-link" onclick="navigator.clipboard.writeText('${shareUrl}')">Copy link</button>
<form method="POST" action="/files/${escHtml(f.id)}/delete" style="display:inline">
<button type="submit" class="danger">Delete</button>
</form>
</td>
</tr>`;
}).join('');
return layout('My files', `
<h1>My files</h1>
<p><a href="/upload">Upload new file</a></p>
<table>
<thead>
<tr>
<th>Name</th><th>Type</th><th>Size</th><th>Uploaded</th><th></th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
`, { authed: true });
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

View File

@@ -1,38 +0,0 @@
import { layout, escHtml } from './layout.ts';
import type { FileRow } from '../db/files.ts';
export function fileViewPage(file: FileRow, isOwner: boolean): string {
const rawUrl = escHtml(`/f/${file.id}/raw`);
const safeName = escHtml(file.original_name);
const actions = `
<div class="file-actions">
<a href="${rawUrl}" download="${safeName}" class="btn">Download</a>
<a href="${rawUrl}" target="_blank" class="btn">Open</a>
</div>`;
const deleteForm = isOwner
? `<form method="POST" action="/files/${escHtml(file.id)}/delete">
<button type="submit" class="danger">Delete</button>
</form>`
: '';
let media = '';
if (file.mime_type.startsWith('image/')) {
media = `<img src="${rawUrl}" alt="${safeName}">`;
} else if (file.mime_type.startsWith('video/')) {
media = `<video controls src="${rawUrl}" preload="metadata"></video>`;
} else if (file.mime_type.startsWith('audio/')) {
media = `<audio controls src="${rawUrl}" preload="metadata"></audio>`;
}
const layoutOpts = isOwner ? { authed: true } : { hideHeader: true };
return layout(file.original_name, `
<div class="file-view">
<h1>${safeName}</h1>
${media}
${actions}
${deleteForm}
</div>
`, layoutOpts);
}

View File

@@ -1,44 +0,0 @@
export function layout(title: string, body: string, opts: { authed?: boolean; hideHeader?: boolean } = {}): string {
const { authed = false, hideHeader = false } = opts;
const nav = authed
? `<nav>
<a href="/upload">Upload</a>
<a href="/files">My Files</a>
<form method="POST" action="/logout" style="display:inline">
<button type="submit">Logout</button>
</form>
</nav>`
: '';
const header = hideHeader
? ''
: ` <header>
<a href="/" class="logo">Nanodrop</a>
${nav}
</header>`;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escHtml(title)} — Nanodrop</title>
<link rel="stylesheet" href="/public/style.css">
</head>
<body>
${header}
<main>
${body}
</main>
</body>
</html>`;
}
export function escHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

View File

@@ -1,25 +0,0 @@
import { layout } from './layout.ts';
export function loginPage(opts: { error?: string } = {}): string {
const errorHtml = opts.error
? `<p class="error">${opts.error}</p>`
: '';
return layout('Login', `
<div class="form-container">
<h1>Sign in</h1>
${errorHtml}
<form method="POST" action="/login">
<label>
Username
<input type="text" name="username" required autofocus>
</label>
<label>
Password
<input type="password" name="password" required>
</label>
<button type="submit">Login</button>
</form>
</div>
`);
}

View File

@@ -1,11 +0,0 @@
import { layout } from './layout.ts';
export function notFoundPage(): string {
return layout('Not found', `
<div class="form-container">
<h1>404 — Not found</h1>
<p>The page or file you requested does not exist.</p>
<p><a href="/">Go home</a></p>
</div>
`);
}

View File

@@ -1,36 +0,0 @@
import { layout, escHtml } from './layout.ts';
export function uploadPage(opts: { error?: string } = {}): string {
const errorHtml = opts.error ? `<p class="error">${opts.error}</p>` : '';
return layout('Upload', `
<div class="form-container">
<h1>Upload a file</h1>
${errorHtml}
<form method="POST" action="/upload" enctype="multipart/form-data">
<label>
File
<input type="file" name="file" required>
</label>
<button type="submit">Upload</button>
</form>
</div>
`, { authed: true });
}
export function uploadResultPage(shareUrl: string, filename: string): string {
const safeUrl = escHtml(shareUrl);
const safeName = escHtml(filename);
return layout('File uploaded', `
<div class="form-container">
<h1>File uploaded</h1>
<p><strong>${safeName}</strong> is ready to share.</p>
<div class="share-box">
<input type="text" id="share-url" value="${safeUrl}" readonly>
<button onclick="navigator.clipboard.writeText(document.getElementById('share-url').value)">Copy link</button>
</div>
<p><a href="/upload">Upload another</a> &middot; <a href="/files">My files</a></p>
</div>
`, { authed: true });
}

View File

@@ -3,6 +3,7 @@ import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { initDb } from '../../src/db/schema.ts'; import { initDb } from '../../src/db/schema.ts';
import { createServer } from '../../src/server.ts'; import { createServer } from '../../src/server.ts';
import { SESSION_COOKIE_NAME } from '../../src/constants.ts';
import type { Config } from '../../src/config.ts'; import type { Config } from '../../src/config.ts';
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
@@ -15,7 +16,7 @@ export async function loginAs(app: FastifyInstance, username: string, password:
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),
}); });
const cookie = res.headers['set-cookie'] as string; const cookie = res.headers['set-cookie'] as string;
return cookie.split(';')[0].replace('token=', ''); return cookie.split(';')[0].replace(`${SESSION_COOKIE_NAME}=`, '');
} }
interface MultipartFile { interface MultipartFile {
@@ -53,7 +54,7 @@ export interface TestContext {
cleanup: () => void; cleanup: () => void;
} }
export function createTestApp(): TestContext { export function createTestApp(overrides: Partial<Config> = {}): TestContext {
const tmpDir = mkdtempSync(join(tmpdir(), 'nanodrop-int-')); const tmpDir = mkdtempSync(join(tmpdir(), 'nanodrop-int-'));
const uploadDir = join(tmpDir, 'uploads'); const uploadDir = join(tmpDir, 'uploads');
const logFile = join(tmpDir, 'test.log'); const logFile = join(tmpDir, 'test.log');
@@ -66,7 +67,6 @@ export function createTestApp(): TestContext {
port: 0, port: 0,
host: '127.0.0.1', host: '127.0.0.1',
jwtSecret: 'test-secret-key', jwtSecret: 'test-secret-key',
jwtExpiry: '1h',
dbPath: ':memory:', dbPath: ':memory:',
uploadDir, uploadDir,
logFile, logFile,
@@ -74,6 +74,13 @@ export function createTestApp(): TestContext {
baseUrl: 'http://localhost:3000', baseUrl: 'http://localhost:3000',
cookieSecure: false, cookieSecure: false,
trustProxy: false, trustProxy: false,
lockoutThreshold: 5,
lockoutBaseSeconds: 30,
lockoutMaxSeconds: 3600,
loginMinResponseMs: 0,
loginRateLimitMax: 1000,
loginRateLimitWindowSeconds: 60,
...overrides,
}; };
const app = createServer({ config, db }); const app = createServer({ config, db });

View File

@@ -26,7 +26,8 @@ describe('POST /api/v1/auth/login', () => {
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ ok: true }); expect(res.json()).toEqual({ ok: true });
expect(res.headers['set-cookie']).toMatch(/token=/); expect(res.headers['set-cookie']).toMatch(/nanodrop_session=/);
expect(res.headers['set-cookie']).toMatch(/Max-Age=2592000/);
}); });
it('returns 401 on wrong password', async () => { it('returns 401 on wrong password', async () => {
@@ -76,7 +77,7 @@ describe('POST /api/v1/auth/logout', () => {
body: JSON.stringify({ username: 'alice', password: 'secret' }), body: JSON.stringify({ username: 'alice', password: 'secret' }),
}); });
const cookie = res.headers['set-cookie'] as string; const cookie = res.headers['set-cookie'] as string;
token = cookie.split(';')[0].replace('token=', ''); token = cookie.split(';')[0].replace('nanodrop_session=', '');
}); });
afterEach(async () => { afterEach(async () => {
@@ -88,10 +89,10 @@ describe('POST /api/v1/auth/logout', () => {
const res = await ctx.app.inject({ const res = await ctx.app.inject({
method: 'POST', method: 'POST',
url: '/api/v1/auth/logout', url: '/api/v1/auth/logout',
cookies: { token }, cookies: { nanodrop_session: token },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(res.headers['set-cookie']).toMatch(/token=;/); expect(res.headers['set-cookie']).toMatch(/nanodrop_session=;/);
}); });
it('returns 401 without cookie', async () => { it('returns 401 without cookie', async () => {

View File

@@ -0,0 +1,135 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { readFileSync } from 'fs';
import { createTestApp, type TestContext } from '../helpers/setup.ts';
import { createUser } from '../../src/db/users.ts';
import { hashPassword } from '../../src/services/auth.ts';
async function attempt(
ctx: TestContext,
username: string,
password: string,
ip = '203.0.113.7',
) {
return ctx.app.inject({
method: 'POST',
url: '/api/v1/auth/login',
headers: { 'content-type': 'application/json', 'x-forwarded-for': ip },
body: JSON.stringify({ username, password }),
});
}
describe('account lockout — JSON login', () => {
let ctx: TestContext;
beforeEach(async () => {
ctx = createTestApp({
lockoutThreshold: 3,
lockoutBaseSeconds: 60,
loginMinResponseMs: 0,
loginRateLimitMax: 1000, // effectively off for these cases
});
const hash = await hashPassword('correct-pw');
createUser(ctx.db, { username: 'alice', passwordHash: hash });
});
afterEach(async () => {
await ctx.app.close();
ctx.cleanup();
});
it('locks after threshold failed attempts and emits AUTH_LOCKOUT_TRIGGERED', async () => {
await attempt(ctx, 'alice', 'wrong');
await attempt(ctx, 'alice', 'wrong');
const third = await attempt(ctx, 'alice', 'wrong');
expect(third.statusCode).toBe(401);
const log = readFileSync(ctx.logFile, 'utf-8');
expect(log).toMatch(/AUTH_LOCKOUT_TRIGGERED/);
expect(log).toMatch(/duration_seconds=60/);
});
it('rejects correct password while locked with Retry-After header', async () => {
await attempt(ctx, 'alice', 'wrong');
await attempt(ctx, 'alice', 'wrong');
await attempt(ctx, 'alice', 'wrong');
const blocked = await attempt(ctx, 'alice', 'correct-pw');
expect(blocked.statusCode).toBe(401);
expect(blocked.headers['retry-after']).toBeDefined();
expect(parseInt(String(blocked.headers['retry-after']), 10)).toBeGreaterThan(0);
// No success log was written for the locked attempt
const log = readFileSync(ctx.logFile, 'utf-8');
expect(log).not.toMatch(/AUTH_SUCCESS/);
});
it('successful login resets the counter', async () => {
await attempt(ctx, 'alice', 'wrong');
await attempt(ctx, 'alice', 'wrong');
const ok = await attempt(ctx, 'alice', 'correct-pw');
expect(ok.statusCode).toBe(200);
// After reset, two more wrong attempts should NOT lock (threshold is 3)
await attempt(ctx, 'alice', 'wrong');
const second = await attempt(ctx, 'alice', 'wrong');
expect(second.statusCode).toBe(401);
expect(second.headers['retry-after']).toBeUndefined();
});
it('canonicalizes username — ALICE and alice share the same lockout row', async () => {
await attempt(ctx, 'ALICE', 'wrong');
await attempt(ctx, 'Alice', 'wrong');
const third = await attempt(ctx, 'alice', 'wrong');
expect(third.statusCode).toBe(401);
const log = readFileSync(ctx.logFile, 'utf-8');
expect(log).toMatch(/AUTH_LOCKOUT_TRIGGERED/);
});
it('unknown user accumulates failures (no enumeration via bypass)', async () => {
await attempt(ctx, 'ghost', 'x');
await attempt(ctx, 'ghost', 'x');
await attempt(ctx, 'ghost', 'x');
const fourth = await attempt(ctx, 'ghost', 'x');
expect(fourth.statusCode).toBe(401);
expect(fourth.headers['retry-after']).toBeDefined();
});
});
describe('account lockout — form login', () => {
let ctx: TestContext;
beforeEach(async () => {
ctx = createTestApp({
lockoutThreshold: 2,
lockoutBaseSeconds: 30,
loginMinResponseMs: 0,
loginRateLimitMax: 1000,
});
const hash = await hashPassword('correct-pw');
createUser(ctx.db, { username: 'alice', passwordHash: hash });
});
afterEach(async () => {
await ctx.app.close();
ctx.cleanup();
});
async function formAttempt(username: string, password: string) {
return ctx.app.inject({
method: 'POST',
url: '/login',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
payload: `username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`,
});
}
it('locks the form login and renders generic error with Retry-After', async () => {
await formAttempt('alice', 'wrong');
await formAttempt('alice', 'wrong');
const blocked = await formAttempt('alice', 'correct-pw');
expect(blocked.statusCode).toBe(200); // login page re-render, not redirect
expect(blocked.body).toContain('Invalid username or password');
expect(blocked.headers['retry-after']).toBeDefined();
});
});

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { readFileSync } from 'fs';
import { createTestApp, type TestContext } from '../helpers/setup.ts';
import { createUser } from '../../src/db/users.ts';
import { hashPassword } from '../../src/services/auth.ts';
describe('per-IP rate limit on login routes', () => {
let ctx: TestContext;
beforeEach(async () => {
ctx = createTestApp({
loginRateLimitMax: 3,
loginRateLimitWindowSeconds: 60,
lockoutThreshold: 100, // disable lockout for this suite
loginMinResponseMs: 0,
});
const hash = await hashPassword('correct-pw');
createUser(ctx.db, { username: 'alice', passwordHash: hash });
});
afterEach(async () => {
await ctx.app.close();
ctx.cleanup();
});
async function loginRequest() {
return ctx.app.inject({
method: 'POST',
url: '/api/v1/auth/login',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'alice', password: 'wrong' }),
});
}
it('returns 429 once IP exceeds the per-route limit and logs AUTH_RATE_LIMITED', async () => {
expect((await loginRequest()).statusCode).toBe(401);
expect((await loginRequest()).statusCode).toBe(401);
expect((await loginRequest()).statusCode).toBe(401);
const fourth = await loginRequest();
expect(fourth.statusCode).toBe(429);
expect(fourth.json().error).toBe('Too many requests');
// Wait for fire-and-forget log write
await new Promise((r) => setTimeout(r, 50));
const log = readFileSync(ctx.logFile, 'utf-8');
expect(log).toMatch(/AUTH_RATE_LIMITED/);
expect(log).toMatch(/route="\/api\/v1\/auth\/login"/);
});
it('does NOT throttle the file upload endpoint', async () => {
// First, get a valid session
const loginRes = await ctx.app.inject({
method: 'POST',
url: '/api/v1/auth/login',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'alice', password: 'correct-pw' }),
});
expect(loginRes.statusCode).toBe(200);
const cookie = (loginRes.headers['set-cookie'] as string).split(';')[0].replace('nanodrop_session=', '');
// Now hit /upload (GET) repeatedly past the login-route limit threshold
for (let i = 0; i < 6; i++) {
const r = await ctx.app.inject({
method: 'GET',
url: '/upload',
cookies: { nanodrop_session: cookie },
});
expect(r.statusCode).toBe(200);
}
});
});

View File

@@ -23,7 +23,7 @@ describe('GET /api/v1/files', () => {
const res = await ctx.app.inject({ const res = await ctx.app.inject({
method: 'GET', method: 'GET',
url: '/api/v1/files', url: '/api/v1/files',
cookies: { token }, cookies: { nanodrop_session: token },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(res.json().files).toEqual([]); expect(res.json().files).toEqual([]);
@@ -55,7 +55,7 @@ describe('POST /api/v1/files', () => {
const res = await ctx.app.inject({ const res = await ctx.app.inject({
method: 'POST', method: 'POST',
url: '/api/v1/files', url: '/api/v1/files',
cookies: { token }, cookies: { nanodrop_session: token },
...buildMultipart({ file: { filename: 'test.txt', contentType: 'text/plain', data: Buffer.from('hello') } }), ...buildMultipart({ file: { filename: 'test.txt', contentType: 'text/plain', data: Buffer.from('hello') } }),
}); });
expect(res.statusCode).toBe(201); expect(res.statusCode).toBe(201);
@@ -84,7 +84,7 @@ describe('DELETE /api/v1/files/:id', () => {
const uploadRes = await ctx.app.inject({ const uploadRes = await ctx.app.inject({
method: 'POST', method: 'POST',
url: '/api/v1/files', url: '/api/v1/files',
cookies: { token }, cookies: { nanodrop_session: token },
...buildMultipart({ file: { filename: 'f.txt', contentType: 'text/plain', data: Buffer.from('data') } }), ...buildMultipart({ file: { filename: 'f.txt', contentType: 'text/plain', data: Buffer.from('data') } }),
}); });
fileId = uploadRes.json().file.id; fileId = uploadRes.json().file.id;
@@ -99,7 +99,7 @@ describe('DELETE /api/v1/files/:id', () => {
const res = await ctx.app.inject({ const res = await ctx.app.inject({
method: 'DELETE', method: 'DELETE',
url: `/api/v1/files/${fileId}`, url: `/api/v1/files/${fileId}`,
cookies: { token }, cookies: { nanodrop_session: token },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
}); });
@@ -108,7 +108,7 @@ describe('DELETE /api/v1/files/:id', () => {
const res = await ctx.app.inject({ const res = await ctx.app.inject({
method: 'DELETE', method: 'DELETE',
url: '/api/v1/files/doesnotexist', url: '/api/v1/files/doesnotexist',
cookies: { token }, cookies: { nanodrop_session: token },
}); });
expect(res.statusCode).toBe(404); expect(res.statusCode).toBe(404);
}); });

View File

@@ -25,10 +25,15 @@ describe('GET /', () => {
it('redirects to /upload when authenticated', async () => { it('redirects to /upload when authenticated', async () => {
const token = await loginAs(ctx.app, 'alice', 'secret'); const token = await loginAs(ctx.app, 'alice', 'secret');
const res = await ctx.app.inject({ method: 'GET', url: '/', cookies: { token } }); const res = await ctx.app.inject({ method: 'GET', url: '/', cookies: { nanodrop_session: token } });
expect(res.statusCode).toBe(302); expect(res.statusCode).toBe(302);
expect(res.headers['location']).toBe('/upload'); expect(res.headers['location']).toBe('/upload');
}); });
it('emits <meta name="color-scheme" content="light dark"> in <head>', async () => {
const res = await ctx.app.inject({ method: 'GET', url: '/' });
expect(res.body).toMatch(/<meta\s+name="color-scheme"\s+content="light dark"/);
});
}); });
describe('POST /login (page)', () => { describe('POST /login (page)', () => {
@@ -46,7 +51,7 @@ describe('POST /login (page)', () => {
}); });
expect(res.statusCode).toBe(302); expect(res.statusCode).toBe(302);
expect(res.headers['location']).toBe('/upload'); expect(res.headers['location']).toBe('/upload');
expect(res.headers['set-cookie']).toMatch(/token=/); expect(res.headers['set-cookie']).toMatch(/nanodrop_session=/);
}); });
it('shows login page with error on invalid credentials', async () => { it('shows login page with error on invalid credentials', async () => {
@@ -72,7 +77,7 @@ describe('GET /upload + POST /upload', () => {
afterEach(async () => { await ctx.app.close(); ctx.cleanup(); }); afterEach(async () => { await ctx.app.close(); ctx.cleanup(); });
it('shows upload form', async () => { it('shows upload form', async () => {
const res = await ctx.app.inject({ method: 'GET', url: '/upload', cookies: { token } }); const res = await ctx.app.inject({ method: 'GET', url: '/upload', cookies: { nanodrop_session: token } });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(res.body).toContain('Upload'); expect(res.body).toContain('Upload');
}); });
@@ -87,7 +92,7 @@ describe('GET /upload + POST /upload', () => {
const res = await ctx.app.inject({ const res = await ctx.app.inject({
method: 'POST', method: 'POST',
url: '/upload', url: '/upload',
cookies: { token }, cookies: { nanodrop_session: token },
...buildMultipart({ file: { filename: 'doc.txt', contentType: 'text/plain', data: Buffer.from('content') } }), ...buildMultipart({ file: { filename: 'doc.txt', contentType: 'text/plain', data: Buffer.from('content') } }),
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
@@ -106,7 +111,7 @@ describe('GET /f/:id and GET /f/:id/raw', () => {
const uploadRes = await ctx.app.inject({ const uploadRes = await ctx.app.inject({
method: 'POST', method: 'POST',
url: '/upload', url: '/upload',
cookies: { token }, cookies: { nanodrop_session: token },
...buildMultipart({ file: { filename: 'hello.txt', contentType: 'text/plain', data: Buffer.from('hello!') } }), ...buildMultipart({ file: { filename: 'hello.txt', contentType: 'text/plain', data: Buffer.from('hello!') } }),
}); });
// Extract file id from response body // Extract file id from response body
@@ -188,7 +193,7 @@ describe('GET /f/:id — image inline', () => {
const uploadRes = await ctx.app.inject({ const uploadRes = await ctx.app.inject({
method: 'POST', method: 'POST',
url: '/upload', url: '/upload',
cookies: { token }, cookies: { nanodrop_session: token },
...buildMultipart({ file: { filename: 'photo.png', contentType: 'image/png', data: Buffer.from('fakepng') } }), ...buildMultipart({ file: { filename: 'photo.png', contentType: 'image/png', data: Buffer.from('fakepng') } }),
}); });
const match = uploadRes.body.match(/\/f\/([^/"]+)/); const match = uploadRes.body.match(/\/f\/([^/"]+)/);
@@ -213,16 +218,17 @@ describe('GET /files — copy link', () => {
await ctx.app.inject({ await ctx.app.inject({
method: 'POST', method: 'POST',
url: '/upload', url: '/upload',
cookies: { token }, cookies: { nanodrop_session: token },
...buildMultipart({ file: { filename: 'test.txt', contentType: 'text/plain', data: Buffer.from('hi') } }), ...buildMultipart({ file: { filename: 'test.txt', contentType: 'text/plain', data: Buffer.from('hi') } }),
}); });
}); });
afterEach(async () => { await ctx.app.close(); ctx.cleanup(); }); afterEach(async () => { await ctx.app.close(); ctx.cleanup(); });
it('shows Copy link button for each file', async () => { it('shows Copy link button for each file', async () => {
const res = await ctx.app.inject({ method: 'GET', url: '/files', cookies: { token } }); const res = await ctx.app.inject({ method: 'GET', url: '/files', cookies: { nanodrop_session: token } });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(res.body).toContain('Copy link'); expect(res.body).toContain('Copy link');
expect(res.body).toContain('class="table-wrap"');
}); });
}); });
@@ -244,7 +250,7 @@ describe('GET /f/:id — owner-aware header', () => {
const uploadRes = await ctx.app.inject({ const uploadRes = await ctx.app.inject({
method: 'POST', method: 'POST',
url: '/upload', url: '/upload',
cookies: { token: aliceToken }, cookies: { nanodrop_session: aliceToken },
...buildMultipart({ file: { filename: 'owned.txt', contentType: 'text/plain', data: Buffer.from('data') } }), ...buildMultipart({ file: { filename: 'owned.txt', contentType: 'text/plain', data: Buffer.from('data') } }),
}); });
const match = uploadRes.body.match(/\/f\/([^/"]+)/); const match = uploadRes.body.match(/\/f\/([^/"]+)/);
@@ -253,19 +259,19 @@ describe('GET /f/:id — owner-aware header', () => {
afterEach(async () => { await ctx.app.close(); ctx.cleanup(); }); afterEach(async () => { await ctx.app.close(); ctx.cleanup(); });
it('shows nav when owner views their file', async () => { it('shows nav when owner views their file', async () => {
const res = await ctx.app.inject({ method: 'GET', url: `/f/${fileId}`, cookies: { token: aliceToken } }); const res = await ctx.app.inject({ method: 'GET', url: `/f/${fileId}`, cookies: { nanodrop_session: aliceToken } });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(res.body).toContain('My Files'); expect(res.body).toContain('My Files');
}); });
it('shows delete button when owner views', async () => { it('shows delete button when owner views', async () => {
const res = await ctx.app.inject({ method: 'GET', url: `/f/${fileId}`, cookies: { token: aliceToken } }); const res = await ctx.app.inject({ method: 'GET', url: `/f/${fileId}`, cookies: { nanodrop_session: aliceToken } });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(res.body).toContain('delete'); expect(res.body).toContain('delete');
}); });
it('no header when non-owner views', async () => { it('no header when non-owner views', async () => {
const res = await ctx.app.inject({ method: 'GET', url: `/f/${fileId}`, cookies: { token: bobToken } }); const res = await ctx.app.inject({ method: 'GET', url: `/f/${fileId}`, cookies: { nanodrop_session: bobToken } });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(res.body).not.toContain('<header'); expect(res.body).not.toContain('<header');
}); });
@@ -288,7 +294,7 @@ describe('POST /files/:id/delete', () => {
const uploadRes = await ctx.app.inject({ const uploadRes = await ctx.app.inject({
method: 'POST', method: 'POST',
url: '/upload', url: '/upload',
cookies: { token }, cookies: { nanodrop_session: token },
...buildMultipart({ file: { filename: 'del.txt', contentType: 'text/plain', data: Buffer.from('bye') } }), ...buildMultipart({ file: { filename: 'del.txt', contentType: 'text/plain', data: Buffer.from('bye') } }),
}); });
const match = uploadRes.body.match(/\/f\/([^/"]+)/); const match = uploadRes.body.match(/\/f\/([^/"]+)/);
@@ -300,7 +306,7 @@ describe('POST /files/:id/delete', () => {
const res = await ctx.app.inject({ const res = await ctx.app.inject({
method: 'POST', method: 'POST',
url: `/files/${fileId}/delete`, url: `/files/${fileId}/delete`,
cookies: { token }, cookies: { nanodrop_session: token },
}); });
expect(res.statusCode).toBe(302); expect(res.statusCode).toBe(302);
expect(res.headers['location']).toBe('/files'); expect(res.headers['location']).toBe('/files');

View File

@@ -0,0 +1,100 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createTestApp, type TestContext, loginAs } from '../helpers/setup.ts';
import { createUser } from '../../src/db/users.ts';
import { hashPassword } from '../../src/services/auth.ts';
describe('session persistence (sliding renewal)', () => {
let ctx: TestContext;
beforeEach(async () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
ctx = createTestApp();
const hash = await hashPassword('secret');
createUser(ctx.db, { username: 'alice', passwordHash: hash });
});
afterEach(async () => {
vi.useRealTimers();
await ctx.app.close();
ctx.cleanup();
});
it('login response includes Set-Cookie with Max-Age=2592000', async () => {
const res = await ctx.app.inject({
method: 'POST',
url: '/api/v1/auth/login',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'alice', password: 'secret' }),
});
expect(res.statusCode).toBe(200);
expect(res.headers['set-cookie']).toMatch(/nanodrop_session=/);
expect(res.headers['set-cookie']).toMatch(/Max-Age=2592000/);
});
it('request 25 days after login triggers a fresh Set-Cookie', async () => {
const token = await loginAs(ctx.app, 'alice', 'secret');
vi.setSystemTime(new Date('2026-01-26T00:00:00Z')); // +25 days
const res = await ctx.app.inject({
method: 'GET',
url: '/api/v1/files',
cookies: { nanodrop_session: token },
});
expect(res.statusCode).toBe(200);
expect(res.headers['set-cookie']).toBeTruthy();
expect(res.headers['set-cookie']).toMatch(/nanodrop_session=/);
expect(res.headers['set-cookie']).toMatch(/Max-Age=2592000/);
});
it('request within renewal threshold does NOT include a fresh Set-Cookie', async () => {
const token = await loginAs(ctx.app, 'alice', 'secret');
vi.setSystemTime(new Date('2026-01-01T00:30:00Z')); // +30 minutes (under 1 hour)
const res = await ctx.app.inject({
method: 'GET',
url: '/api/v1/files',
cookies: { nanodrop_session: token },
});
expect(res.statusCode).toBe(200);
expect(res.headers['set-cookie']).toBeFalsy();
});
it('logout still clears cookie even after renewal threshold has passed', async () => {
const token = await loginAs(ctx.app, 'alice', 'secret');
vi.setSystemTime(new Date('2026-01-01T02:00:00Z')); // +2 hours (past threshold)
const res = await ctx.app.inject({
method: 'POST',
url: '/api/v1/auth/logout',
cookies: { nanodrop_session: token },
});
expect(res.statusCode).toBe(200);
const setCookie = res.headers['set-cookie'] as string | string[];
const setCookieStr = Array.isArray(setCookie) ? setCookie.join('\n') : setCookie;
// Cookie cleared (Max-Age=0 or empty value)
expect(setCookieStr).toMatch(/nanodrop_session=;/);
// No fresh session cookie issued by the renewer
const freshIssue = /nanodrop_session=eyJ/.test(setCookieStr);
expect(freshIssue).toBe(false);
});
it('request 31 days after login is bounced (idle lapse)', async () => {
const token = await loginAs(ctx.app, 'alice', 'secret');
vi.setSystemTime(new Date('2026-02-01T01:00:00Z')); // +31 days, past 30-day TTL
const res = await ctx.app.inject({
method: 'GET',
url: '/api/v1/files',
cookies: { nanodrop_session: token },
});
expect(res.statusCode).toBe(401);
});
it('anonymous request to /api/v1/files returns 401 with no Set-Cookie', async () => {
const res = await ctx.app.inject({ method: 'GET', url: '/api/v1/files' });
expect(res.statusCode).toBe(401);
expect(res.headers['set-cookie']).toBeFalsy();
});
});

View File

@@ -0,0 +1,66 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { readFileSync } from 'fs';
import { join } from 'path';
import { createTestApp, type TestContext } from '../helpers/setup.ts';
const STYLE_PATH = join(process.cwd(), 'public', 'style.css');
describe('public/style.css (file contents)', () => {
const css = readFileSync(STYLE_PATH, 'utf8');
it('does not @import any external font CSS', () => {
expect(css).not.toContain('googleapis.com');
expect(css).not.toContain('@import');
});
it('does not reference the previous IBM Plex Mono webfont', () => {
expect(css).not.toContain('IBM Plex');
});
it('uses the system sans-serif stack as its default font', () => {
expect(css).toContain('-apple-system');
});
it('keeps a monospace stack available via --font-mono for code-like UI', () => {
expect(css).toContain('--font-mono');
expect(css).toMatch(/\.share-box input\[readonly\][\s\S]*?font-family:\s*var\(--font-mono\)/);
});
it('uses font-size >= 16px on text-entry inputs to prevent iOS focus-zoom', () => {
expect(css).toMatch(
/input\[type="text"\][\s\S]*?input\[type="password"\][\s\S]*?\{[\s\S]*?font-size:\s*(1[6-9]|[2-9]\d)px/
);
});
it('declares a prefers-color-scheme: dark override', () => {
expect(css).toMatch(/@media\s*\(\s*prefers-color-scheme:\s*dark\s*\)/);
});
it('uses CSS custom properties for the core palette', () => {
expect(css).toMatch(/--bg:/);
expect(css).toMatch(/--fg:/);
expect(css).toMatch(/--accent:/);
});
it('does not contain unreplaced literal hex colors outside the :root and @media blocks', () => {
const stripped = css
.replace(/:root\s*\{[\s\S]*?\}/g, '')
.replace(/@media\s*\([^)]*\)\s*\{[\s\S]*?\}\s*\}/g, '');
expect(stripped).not.toMatch(/#[0-9a-fA-F]{3,6}\b/);
});
});
describe('GET /public/style.css', () => {
let ctx: TestContext;
beforeEach(() => { ctx = createTestApp(); });
afterEach(async () => { await ctx.app.close(); ctx.cleanup(); });
it('serves the stylesheet as text/css with no external font import', async () => {
const res = await ctx.app.inject({ method: 'GET', url: '/public/style.css' });
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toMatch(/text\/css/);
expect(res.body).not.toContain('googleapis.com');
expect(res.body).toContain('-apple-system');
});
});

View File

@@ -15,7 +15,6 @@ describe('config', () => {
process.env.JWT_SECRET = 'test-secret'; process.env.JWT_SECRET = 'test-secret';
delete process.env.PORT; delete process.env.PORT;
delete process.env.HOST; delete process.env.HOST;
delete process.env.JWT_EXPIRY;
delete process.env.DB_PATH; delete process.env.DB_PATH;
delete process.env.UPLOAD_DIR; delete process.env.UPLOAD_DIR;
delete process.env.LOG_FILE; delete process.env.LOG_FILE;
@@ -23,13 +22,18 @@ describe('config', () => {
delete process.env.BASE_URL; delete process.env.BASE_URL;
delete process.env.COOKIE_SECURE; delete process.env.COOKIE_SECURE;
delete process.env.TRUST_PROXY; delete process.env.TRUST_PROXY;
delete process.env.LOCKOUT_THRESHOLD;
delete process.env.LOCKOUT_BASE_SECONDS;
delete process.env.LOCKOUT_MAX_SECONDS;
delete process.env.LOGIN_MIN_RESPONSE_MS;
delete process.env.LOGIN_RATE_LIMIT_MAX;
delete process.env.LOGIN_RATE_LIMIT_WINDOW_SECONDS;
const { loadConfig } = await import('../../src/config.ts'); const { loadConfig } = await import('../../src/config.ts');
const config = loadConfig(); const config = loadConfig();
expect(config.port).toBe(3000); expect(config.port).toBe(3000);
expect(config.host).toBe('0.0.0.0'); expect(config.host).toBe('0.0.0.0');
expect(config.jwtExpiry).toBe('7d');
expect(config.dbPath).toBe('./data/nanodrop.db'); expect(config.dbPath).toBe('./data/nanodrop.db');
expect(config.uploadDir).toBe('./data/uploads'); expect(config.uploadDir).toBe('./data/uploads');
expect(config.logFile).toBe('./data/nanodrop.log'); expect(config.logFile).toBe('./data/nanodrop.log');
@@ -37,13 +41,18 @@ describe('config', () => {
expect(config.baseUrl).toBe('http://localhost:3000'); expect(config.baseUrl).toBe('http://localhost:3000');
expect(config.cookieSecure).toBe(false); expect(config.cookieSecure).toBe(false);
expect(config.trustProxy).toBe(false); expect(config.trustProxy).toBe(false);
expect(config.lockoutThreshold).toBe(5);
expect(config.lockoutBaseSeconds).toBe(30);
expect(config.lockoutMaxSeconds).toBe(3600);
expect(config.loginMinResponseMs).toBe(350);
expect(config.loginRateLimitMax).toBe(10);
expect(config.loginRateLimitWindowSeconds).toBe(60);
}); });
it('reads values from env vars', async () => { it('reads values from env vars', async () => {
process.env.JWT_SECRET = 'my-secret'; process.env.JWT_SECRET = 'my-secret';
process.env.PORT = '4000'; process.env.PORT = '4000';
process.env.HOST = '127.0.0.1'; process.env.HOST = '127.0.0.1';
process.env.JWT_EXPIRY = '1d';
process.env.COOKIE_SECURE = 'true'; process.env.COOKIE_SECURE = 'true';
process.env.TRUST_PROXY = 'true'; process.env.TRUST_PROXY = 'true';
process.env.MAX_FILE_SIZE = '52428800'; process.env.MAX_FILE_SIZE = '52428800';
@@ -54,12 +63,31 @@ describe('config', () => {
expect(config.port).toBe(4000); expect(config.port).toBe(4000);
expect(config.host).toBe('127.0.0.1'); expect(config.host).toBe('127.0.0.1');
expect(config.jwtSecret).toBe('my-secret'); expect(config.jwtSecret).toBe('my-secret');
expect(config.jwtExpiry).toBe('1d');
expect(config.cookieSecure).toBe(true); expect(config.cookieSecure).toBe(true);
expect(config.trustProxy).toBe(true); expect(config.trustProxy).toBe(true);
expect(config.maxFileSize).toBe(52428800); expect(config.maxFileSize).toBe(52428800);
}); });
it('reads lockout and rate-limit values from env vars', async () => {
process.env.JWT_SECRET = 'my-secret';
process.env.LOCKOUT_THRESHOLD = '3';
process.env.LOCKOUT_BASE_SECONDS = '15';
process.env.LOCKOUT_MAX_SECONDS = '900';
process.env.LOGIN_MIN_RESPONSE_MS = '50';
process.env.LOGIN_RATE_LIMIT_MAX = '20';
process.env.LOGIN_RATE_LIMIT_WINDOW_SECONDS = '120';
const { loadConfig } = await import('../../src/config.ts');
const config = loadConfig();
expect(config.lockoutThreshold).toBe(3);
expect(config.lockoutBaseSeconds).toBe(15);
expect(config.lockoutMaxSeconds).toBe(900);
expect(config.loginMinResponseMs).toBe(50);
expect(config.loginRateLimitMax).toBe(20);
expect(config.loginRateLimitWindowSeconds).toBe(120);
});
it('throws when JWT_SECRET is missing', async () => { it('throws when JWT_SECRET is missing', async () => {
delete process.env.JWT_SECRET; delete process.env.JWT_SECRET;
const { loadConfig } = await import('../../src/config.ts'); const { loadConfig } = await import('../../src/config.ts');

View File

@@ -0,0 +1,58 @@
import { describe, it, expect, beforeEach } from 'vitest';
import bcrypt from 'bcrypt';
import { verifyAgainstDummy, _resetDummyHashForTests } from '../../src/services/dummy-hash.ts';
import { hashPassword, verifyPassword } from '../../src/services/auth.ts';
describe('dummy hash', () => {
beforeEach(() => {
_resetDummyHashForTests();
});
it('always returns false', async () => {
expect(await verifyAgainstDummy('whatever')).toBe(false);
expect(await verifyAgainstDummy('')).toBe(false);
expect(await verifyAgainstDummy('admin')).toBe(false);
});
it('takes comparable time to verifying a real bcrypt hash (within 5x)', async () => {
// Warm dummy hash so the cache is hot.
await verifyAgainstDummy('warmup');
const realHash = await hashPassword('actual-password');
const start1 = Date.now();
await verifyPassword('actual-password', realHash);
const realMs = Date.now() - start1;
const start2 = Date.now();
await verifyAgainstDummy('any-password');
const dummyMs = Date.now() - start2;
// Both should be in the same ballpark — bcrypt cost factor is the same.
// Generous bound to avoid flakes on slow CI.
expect(dummyMs).toBeGreaterThan(realMs / 5);
expect(dummyMs).toBeLessThan(realMs * 5);
}, 10_000);
it('memoizes the dummy hash across calls', async () => {
// First call computes, subsequent calls reuse — covered by cache hit
// being noticeably faster than a fresh hash. Just assert the function
// is callable repeatedly without error.
await verifyAgainstDummy('a');
await verifyAgainstDummy('b');
await verifyAgainstDummy('c');
expect(true).toBe(true);
});
it('runs a real bcrypt comparison (does not short-circuit)', async () => {
// Spy by counting bcrypt.compare calls would be nice, but bcrypt
// is a compiled module. Indirect check: the call must actually take
// bcrypt-comparison time after warmup.
await verifyAgainstDummy('warmup');
const start = Date.now();
await verifyAgainstDummy('test');
const elapsed = Date.now() - start;
// bcrypt 12 rounds takes >50ms on any modern CPU
expect(elapsed).toBeGreaterThan(20);
expect(bcrypt).toBeDefined();
}, 10_000);
});

View File

@@ -0,0 +1,126 @@
import { describe, it, expect, beforeEach } from 'vitest';
import type Database from 'better-sqlite3';
import { initDb } from '../../src/db/schema.ts';
import type { Config } from '../../src/config.ts';
import { createLockoutService } from '../../src/services/lockout.ts';
import { recordFailure as dbRecordFailure } from '../../src/db/login-attempts.ts';
function makeConfig(overrides: Partial<Config> = {}): Config {
return {
port: 0,
host: '127.0.0.1',
jwtSecret: 'x',
dbPath: ':memory:',
uploadDir: '/tmp',
logFile: '/tmp/x.log',
maxFileSize: 0,
baseUrl: '',
cookieSecure: false,
trustProxy: false,
lockoutThreshold: 3,
lockoutBaseSeconds: 10,
lockoutMaxSeconds: 80,
loginMinResponseMs: 0,
loginRateLimitMax: 0,
loginRateLimitWindowSeconds: 0,
...overrides,
};
}
describe('lockout service', () => {
let db: Database.Database;
let nowMs: number;
const now = (): Date => new Date(nowMs);
beforeEach(() => {
db = initDb(':memory:');
nowMs = Date.UTC(2026, 0, 1, 0, 0, 0);
});
describe('check', () => {
it('returns not-locked when no row exists', () => {
const svc = createLockoutService({ db, config: makeConfig(), now });
expect(svc.check('alice')).toEqual({ locked: false });
});
it('returns not-locked when locked_until is in the past', () => {
const past = new Date(nowMs - 1000).toISOString();
dbRecordFailure(db, 'alice', past);
const svc = createLockoutService({ db, config: makeConfig(), now });
expect(svc.check('alice')).toEqual({ locked: false });
});
it('returns locked with retry-after seconds when locked_until is in the future', () => {
const future = new Date(nowMs + 30_000).toISOString();
dbRecordFailure(db, 'alice', future);
const svc = createLockoutService({ db, config: makeConfig(), now });
expect(svc.check('alice')).toEqual({ locked: true, retryAfterSeconds: 30 });
});
it('rounds up sub-second remainder so retry-after is never 0', () => {
const future = new Date(nowMs + 100).toISOString();
dbRecordFailure(db, 'alice', future);
const svc = createLockoutService({ db, config: makeConfig(), now });
expect(svc.check('alice').retryAfterSeconds).toBe(1);
});
});
describe('recordFailure', () => {
it('does not lock under threshold', () => {
const svc = createLockoutService({ db, config: makeConfig(), now });
const r1 = svc.recordFailure('alice');
expect(r1).toEqual({ locked: false, durationSeconds: 0, failedCount: 1 });
const r2 = svc.recordFailure('alice');
expect(r2).toEqual({ locked: false, durationSeconds: 0, failedCount: 2 });
});
it('locks with base duration at threshold', () => {
const svc = createLockoutService({ db, config: makeConfig(), now });
svc.recordFailure('alice');
svc.recordFailure('alice');
const r3 = svc.recordFailure('alice');
expect(r3.locked).toBe(true);
expect(r3.durationSeconds).toBe(10);
expect(r3.failedCount).toBe(3);
});
it('doubles duration past threshold', () => {
const svc = createLockoutService({ db, config: makeConfig(), now });
svc.recordFailure('alice'); // 1
svc.recordFailure('alice'); // 2
expect(svc.recordFailure('alice').durationSeconds).toBe(10); // 3 -> base
expect(svc.recordFailure('alice').durationSeconds).toBe(20); // 4
expect(svc.recordFailure('alice').durationSeconds).toBe(40); // 5
expect(svc.recordFailure('alice').durationSeconds).toBe(80); // 6 -> cap
expect(svc.recordFailure('alice').durationSeconds).toBe(80); // 7 -> still cap
});
it('persists locked_until reachable via check', () => {
const svc = createLockoutService({ db, config: makeConfig(), now });
svc.recordFailure('alice');
svc.recordFailure('alice');
svc.recordFailure('alice');
const status = svc.check('alice');
expect(status.locked).toBe(true);
expect(status.retryAfterSeconds).toBe(10);
});
});
describe('recordSuccess', () => {
it('clears the attempt row', () => {
const svc = createLockoutService({ db, config: makeConfig(), now });
svc.recordFailure('alice');
svc.recordFailure('alice');
svc.recordSuccess('alice');
// Next failure starts at 1, no lock
const r = svc.recordFailure('alice');
expect(r.failedCount).toBe(1);
expect(r.locked).toBe(false);
});
it('is a no-op for unknown username', () => {
const svc = createLockoutService({ db, config: makeConfig(), now });
expect(() => svc.recordSuccess('ghost')).not.toThrow();
});
});
});

View File

@@ -50,6 +50,48 @@ describe('middleware/logging', () => {
expect(existsSync(logFile)).toBe(true); expect(existsSync(logFile)).toBe(true);
}); });
it('writes AUTH_LOCKOUT_TRIGGERED log entry with duration', async () => {
const logger = createLogger(logFile);
await logger.authLockoutTriggered({
ip: '1.2.3.4',
userAgent: 'TestAgent/1.0',
username: 'alice',
durationSeconds: 60,
});
const content = readFileSync(logFile, 'utf-8');
expect(content).toMatch(/AUTH_LOCKOUT_TRIGGERED/);
expect(content).toMatch(/ip=1\.2\.3\.4/);
expect(content).toMatch(/username="alice"/);
expect(content).toMatch(/duration_seconds=60/);
});
it('writes AUTH_LOCKED_ATTEMPT log entry with retry-after', async () => {
const logger = createLogger(logFile);
await logger.authLockedAttempt({
ip: '1.2.3.4',
userAgent: 'TestAgent/1.0',
username: 'alice',
retryAfterSeconds: 25,
});
const content = readFileSync(logFile, 'utf-8');
expect(content).toMatch(/AUTH_LOCKED_ATTEMPT/);
expect(content).toMatch(/username="alice"/);
expect(content).toMatch(/retry_after_seconds=25/);
});
it('writes AUTH_RATE_LIMITED log entry with route', async () => {
const logger = createLogger(logFile);
await logger.authRateLimited({
ip: '9.9.9.9',
userAgent: 'curl/7.0',
route: '/api/v1/auth/login',
});
const content = readFileSync(logFile, 'utf-8');
expect(content).toMatch(/AUTH_RATE_LIMITED/);
expect(content).toMatch(/ip=9\.9\.9\.9/);
expect(content).toMatch(/route="\/api\/v1\/auth\/login"/);
});
it('appends multiple entries', async () => { it('appends multiple entries', async () => {
const logger = createLogger(logFile); const logger = createLogger(logFile);
await logger.authSuccess({ ip: '1.1.1.1', userAgent: 'a', username: 'u1' }); await logger.authSuccess({ ip: '1.1.1.1', userAgent: 'a', username: 'u1' });

View File

@@ -0,0 +1,72 @@
import { describe, it, expect, beforeEach } from 'vitest';
import type Database from 'better-sqlite3';
import { initDb } from '../../src/db/schema.ts';
import {
getLoginAttempt,
recordFailure,
resetLoginAttempts,
} from '../../src/db/login-attempts.ts';
describe('login-attempts db', () => {
let db: Database.Database;
beforeEach(() => {
db = initDb(':memory:');
});
it('returns undefined for an unknown username', () => {
expect(getLoginAttempt(db, 'ghost')).toBeUndefined();
});
it('inserts a row on first failure with count=1 and no lock', () => {
const row = recordFailure(db, 'alice', null);
expect(row.username).toBe('alice');
expect(row.failed_count).toBe(1);
expect(row.last_failed_at).toBeTruthy();
expect(row.locked_until).toBeNull();
});
it('increments failed_count on subsequent failures', () => {
recordFailure(db, 'alice', null);
recordFailure(db, 'alice', null);
const row = recordFailure(db, 'alice', null);
expect(row.failed_count).toBe(3);
});
it('persists locked_until when supplied', () => {
const lockedUntil = new Date(Date.now() + 30_000).toISOString();
const row = recordFailure(db, 'alice', lockedUntil);
expect(row.locked_until).toBe(lockedUntil);
});
it('updates locked_until on subsequent failures', () => {
recordFailure(db, 'alice', null);
const newLock = new Date(Date.now() + 60_000).toISOString();
const row = recordFailure(db, 'alice', newLock);
expect(row.failed_count).toBe(2);
expect(row.locked_until).toBe(newLock);
});
it('resetLoginAttempts deletes the row', () => {
recordFailure(db, 'alice', null);
resetLoginAttempts(db, 'alice');
expect(getLoginAttempt(db, 'alice')).toBeUndefined();
});
it('reset on a missing username is a no-op', () => {
expect(() => resetLoginAttempts(db, 'ghost')).not.toThrow();
});
it('tracks failures for non-existent users (no FK to users table)', () => {
const row = recordFailure(db, 'never-existed-user', null);
expect(row.failed_count).toBe(1);
});
it('getLoginAttempt returns the stored row', () => {
recordFailure(db, 'alice', null);
recordFailure(db, 'alice', null);
const row = getLoginAttempt(db, 'alice');
expect(row?.username).toBe('alice');
expect(row?.failed_count).toBe(2);
});
});

View File

@@ -0,0 +1,211 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mkdtempSync, rmSync, readFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import type Database from 'better-sqlite3';
import { initDb } from '../../src/db/schema.ts';
import { createUser } from '../../src/db/users.ts';
import { hashPassword } from '../../src/services/auth.ts';
import { createLogger } from '../../src/middleware/logging.ts';
import { createLockoutService } from '../../src/services/lockout.ts';
import { attemptLogin } from '../../src/services/login-handler.ts';
import { _resetDummyHashForTests } from '../../src/services/dummy-hash.ts';
import type { Config } from '../../src/config.ts';
function makeConfig(overrides: Partial<Config> = {}): Config {
return {
port: 0,
host: '127.0.0.1',
jwtSecret: 'x',
dbPath: ':memory:',
uploadDir: '/tmp',
logFile: '/tmp/x.log',
maxFileSize: 0,
baseUrl: '',
cookieSecure: false,
trustProxy: false,
lockoutThreshold: 2,
lockoutBaseSeconds: 60,
lockoutMaxSeconds: 600,
loginMinResponseMs: 50,
loginRateLimitMax: 0,
loginRateLimitWindowSeconds: 0,
...overrides,
};
}
describe('login handler', () => {
let db: Database.Database;
let logDir: string;
let logFile: string;
let config: Config;
beforeEach(async () => {
_resetDummyHashForTests();
db = initDb(':memory:');
logDir = mkdtempSync(join(tmpdir(), 'nanodrop-handler-'));
logFile = join(logDir, 'test.log');
config = makeConfig({ logFile });
const passwordHash = await hashPassword('correct-pw');
createUser(db, { username: 'alice', passwordHash });
// Warm dummy hash so timing assertions don't include the first cold compute.
const { verifyAgainstDummy } = await import('../../src/services/dummy-hash.ts');
await verifyAgainstDummy('warmup');
});
function buildDeps() {
const logger = createLogger(logFile);
const lockout = createLockoutService({ db, config });
return { db, config, logger, lockout };
}
it('returns success for valid credentials and resets lockout', async () => {
const deps = buildDeps();
const result = await attemptLogin(deps, {
username: 'alice',
password: 'correct-pw',
ip: '1.1.1.1',
userAgent: 'ua',
});
expect(result.kind).toBe('success');
if (result.kind === 'success') {
expect(result.user.username).toBe('alice');
}
const log = readFileSync(logFile, 'utf-8');
expect(log).toMatch(/AUTH_SUCCESS/);
});
it('returns bad_credentials and logs failure on wrong password', async () => {
const deps = buildDeps();
const result = await attemptLogin(deps, {
username: 'alice',
password: 'wrong',
ip: '1.1.1.1',
userAgent: 'ua',
});
expect(result.kind).toBe('bad_credentials');
const log = readFileSync(logFile, 'utf-8');
expect(log).toMatch(/AUTH_FAILURE/);
expect(log).toMatch(/username="alice"/);
});
it('returns bad_credentials for unknown user (still runs bcrypt against dummy)', async () => {
const deps = buildDeps();
const start = Date.now();
const result = await attemptLogin(deps, {
username: 'ghost',
password: 'whatever',
ip: '1.1.1.1',
userAgent: 'ua',
});
const elapsed = Date.now() - start;
expect(result.kind).toBe('bad_credentials');
// Must spend bcrypt-comparable time even for unknown user.
expect(elapsed).toBeGreaterThan(20);
const log = readFileSync(logFile, 'utf-8');
expect(log).toMatch(/AUTH_FAILURE/);
expect(log).toMatch(/username="ghost"/);
}, 10_000);
it('canonicalizes username (lowercase + trim) before lookup and lockout', async () => {
const deps = buildDeps();
const result = await attemptLogin(deps, {
username: ' ALICE ',
password: 'correct-pw',
ip: '1.1.1.1',
userAgent: 'ua',
});
expect(result.kind).toBe('success');
});
it('returns bad_request when username or password is empty', async () => {
const deps = buildDeps();
const r1 = await attemptLogin(deps, { username: '', password: 'x', ip: '1', userAgent: 'ua' });
expect(r1.kind).toBe('bad_request');
const r2 = await attemptLogin(deps, { username: 'alice', password: '', ip: '1', userAgent: 'ua' });
expect(r2.kind).toBe('bad_request');
});
it('triggers lockout at threshold and logs AUTH_LOCKOUT_TRIGGERED', async () => {
const deps = buildDeps();
await attemptLogin(deps, { username: 'alice', password: 'wrong', ip: '1.1.1.1', userAgent: 'ua' });
const second = await attemptLogin(deps, { username: 'alice', password: 'wrong', ip: '1.1.1.1', userAgent: 'ua' });
expect(second.kind).toBe('bad_credentials');
const log = readFileSync(logFile, 'utf-8');
expect(log).toMatch(/AUTH_LOCKOUT_TRIGGERED/);
expect(log).toMatch(/duration_seconds=60/);
});
it('returns locked + retry-after on subsequent attempt; correct password still rejected', async () => {
const deps = buildDeps();
await attemptLogin(deps, { username: 'alice', password: 'wrong', ip: '1.1.1.1', userAgent: 'ua' });
await attemptLogin(deps, { username: 'alice', password: 'wrong', ip: '1.1.1.1', userAgent: 'ua' });
const blocked = await attemptLogin(deps, {
username: 'alice',
password: 'correct-pw',
ip: '1.1.1.1',
userAgent: 'ua',
});
expect(blocked.kind).toBe('locked');
if (blocked.kind === 'locked') {
expect(blocked.retryAfterSeconds).toBeGreaterThan(0);
expect(blocked.retryAfterSeconds).toBeLessThanOrEqual(60);
}
const log = readFileSync(logFile, 'utf-8');
expect(log).toMatch(/AUTH_LOCKED_ATTEMPT/);
// Even though password was correct, no AUTH_SUCCESS for this attempt.
const successCount = (log.match(/AUTH_SUCCESS/g) ?? []).length;
expect(successCount).toBe(0);
});
it('does NOT call bcrypt when account is locked (short-circuits)', async () => {
const deps = buildDeps();
// Lock the account.
await attemptLogin(deps, { username: 'alice', password: 'wrong', ip: '1', userAgent: 'ua' });
await attemptLogin(deps, { username: 'alice', password: 'wrong', ip: '1', userAgent: 'ua' });
// Locked attempt with constant-time clamp at 50ms — should be roughly clamp duration,
// not bcrypt time (250ms+).
const start = Date.now();
await attemptLogin(deps, { username: 'alice', password: 'correct-pw', ip: '1', userAgent: 'ua' });
const elapsed = Date.now() - start;
// Clamp is 50ms; allow generous slack but assert well under bcrypt cost.
expect(elapsed).toBeLessThan(150);
});
it('respects loginMinResponseMs clamp on bad_credentials', async () => {
config = makeConfig({ logFile, loginMinResponseMs: 200 });
const deps = buildDeps();
const start = Date.now();
await attemptLogin(deps, { username: 'alice', password: 'wrong', ip: '1', userAgent: 'ua' });
const elapsed = Date.now() - start;
expect(elapsed).toBeGreaterThanOrEqual(190);
}, 10_000);
it('successful login between failures resets the counter', async () => {
const deps = buildDeps();
await attemptLogin(deps, { username: 'alice', password: 'wrong', ip: '1', userAgent: 'ua' });
await attemptLogin(deps, { username: 'alice', password: 'correct-pw', ip: '1', userAgent: 'ua' });
const r = await attemptLogin(deps, { username: 'alice', password: 'wrong', ip: '1', userAgent: 'ua' });
// After reset, this is failure #1 — well under threshold of 2, so no lock yet.
expect(r.kind).toBe('bad_credentials');
// No AUTH_LOCKOUT_TRIGGERED in log.
const log = readFileSync(logFile, 'utf-8');
expect(log).not.toMatch(/AUTH_LOCKOUT_TRIGGERED/);
});
it('unknown username also accumulates lockout', async () => {
const deps = buildDeps();
await attemptLogin(deps, { username: 'ghost', password: 'x', ip: '1', userAgent: 'ua' });
await attemptLogin(deps, { username: 'ghost', password: 'x', ip: '1', userAgent: 'ua' });
const r = await attemptLogin(deps, { username: 'ghost', password: 'x', ip: '1', userAgent: 'ua' });
expect(r.kind).toBe('locked');
}, 10_000);
// Cleanup
beforeEach(() => {
return () => rmSync(logDir, { recursive: true, force: true });
});
});

View File

@@ -0,0 +1,18 @@
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { resolve } from 'node:path';
import { describe, it, expect } from 'vitest';
const EXPECTED_0001_SHA256 =
'34f092b4bb8544a48acfee0fad08d51b1b75fedf4ffdfbcb790d2656d0f1d57a';
describe('migrations byte stability', () => {
it('0001_init.sql sha256 is frozen — edit means new migration, not edit-in-place', () => {
const body = readFileSync(
resolve(import.meta.dirname, '..', '..', 'src', 'db', 'migrations', '0001_init.sql'),
'utf8',
);
const actual = createHash('sha256').update(body).digest('hex');
expect(actual).toBe(EXPECTED_0001_SHA256);
});
});

View File

@@ -0,0 +1,117 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import fastifyJwt from '@fastify/jwt';
import { slideSessionIfNeeded } from '../../src/middleware/session-renewal.ts';
import type { JwtPayload } from '../../src/types.ts';
import {
SESSION_COOKIE_NAME,
SESSION_RENEW_THRESHOLD_SECONDS,
SESSION_TTL_SECONDS,
} from '../../src/constants.ts';
async function buildTestServer(): Promise<FastifyInstance> {
const app = Fastify();
await app.register(fastifyCookie);
await app.register(fastifyJwt, {
secret: 'test-secret',
cookie: { cookieName: SESSION_COOKIE_NAME, signed: false },
});
await app.ready();
return app;
}
const NOW_ISO = '2026-01-01T12:00:00Z';
const NOW_SEC = Math.floor(new Date(NOW_ISO).getTime() / 1000);
describe('slideSessionIfNeeded', () => {
let app: FastifyInstance;
beforeEach(async () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(NOW_ISO));
app = await buildTestServer();
});
afterEach(async () => {
vi.useRealTimers();
await app.close();
});
function fakeReply(): { setCookieCalls: Array<{ name: string; value: string; opts: Record<string, unknown> }> } & Record<string, unknown> {
const setCookieCalls: Array<{ name: string; value: string; opts: Record<string, unknown> }> = [];
return {
setCookieCalls,
setCookie(name: string, value: string, opts: Record<string, unknown>) {
setCookieCalls.push({ name, value, opts });
return this;
},
};
}
function fakeRequest(url: string): { url: string } {
return { url };
}
it('re-issues cookie when now - iat >= SESSION_RENEW_THRESHOLD_SECONDS', () => {
const reply = fakeReply();
const payload: JwtPayload = {
sub: 1,
username: 'alice',
iat: NOW_SEC - SESSION_RENEW_THRESHOLD_SECONDS,
};
slideSessionIfNeeded(fakeRequest('/upload') as never, reply as never, payload, app, false);
expect(reply.setCookieCalls.length).toBe(1);
expect(reply.setCookieCalls[0].name).toBe(SESSION_COOKIE_NAME);
expect(reply.setCookieCalls[0].opts.maxAge).toBe(SESSION_TTL_SECONDS);
});
it('skips re-issue when now - iat < SESSION_RENEW_THRESHOLD_SECONDS', () => {
const reply = fakeReply();
const payload: JwtPayload = {
sub: 1,
username: 'alice',
iat: NOW_SEC - (SESSION_RENEW_THRESHOLD_SECONDS - 1),
};
slideSessionIfNeeded(fakeRequest('/upload') as never, reply as never, payload, app, false);
expect(reply.setCookieCalls.length).toBe(0);
});
it('skips re-issue on /logout path', () => {
const reply = fakeReply();
const payload: JwtPayload = { sub: 1, username: 'alice', iat: 0 }; // ancient
slideSessionIfNeeded(fakeRequest('/logout') as never, reply as never, payload, app, false);
expect(reply.setCookieCalls.length).toBe(0);
});
it('skips re-issue on /api/v1/auth/logout path', () => {
const reply = fakeReply();
const payload: JwtPayload = { sub: 1, username: 'alice', iat: 0 };
slideSessionIfNeeded(fakeRequest('/api/v1/auth/logout') as never, reply as never, payload, app, false);
expect(reply.setCookieCalls.length).toBe(0);
});
it('skips re-issue when /logout has a query string', () => {
const reply = fakeReply();
const payload: JwtPayload = { sub: 1, username: 'alice', iat: 0 };
slideSessionIfNeeded(fakeRequest('/logout?redirect=foo') as never, reply as never, payload, app, false);
expect(reply.setCookieCalls.length).toBe(0);
});
it('treats missing iat as 0 (forces refresh)', () => {
const reply = fakeReply();
const payload: JwtPayload = { sub: 1, username: 'alice' };
slideSessionIfNeeded(fakeRequest('/upload') as never, reply as never, payload, app, false);
expect(reply.setCookieCalls.length).toBe(1);
});
it('produces a cookie carrying Max-Age=SESSION_TTL_SECONDS', () => {
const reply = fakeReply();
const payload: JwtPayload = { sub: 1, username: 'alice', iat: 0 };
slideSessionIfNeeded(fakeRequest('/upload') as never, reply as never, payload, app, true);
expect(reply.setCookieCalls[0].opts.maxAge).toBe(SESSION_TTL_SECONDS);
expect(reply.setCookieCalls[0].opts.secure).toBe(true);
expect(reply.setCookieCalls[0].opts.httpOnly).toBe(true);
expect(reply.setCookieCalls[0].opts.sameSite).toBe('strict');
});
});

29
views/_layout.ejs Normal file
View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<title><%= title %> — Nanodrop</title>
<link rel="stylesheet" href="/public/style.css">
</head>
<body>
<% if (!hideHeader) { %>
<header>
<a href="/" class="logo">Nanodrop</a>
<% if (authed) { %>
<nav>
<a href="/upload">Upload</a>
<a href="/files">My Files</a>
<form method="POST" action="/logout" style="display:inline">
<button type="submit">Logout</button>
</form>
</nav>
<% } %>
</header>
<% } %>
<main>
<%- body %>
</main>
</body>
</html>

31
views/file-list.ejs Normal file
View File

@@ -0,0 +1,31 @@
<h1>My files</h1>
<p><a href="/upload">Upload new file</a></p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Name</th><th>Type</th><th>Size</th><th>Uploaded</th><th></th>
</tr>
</thead>
<tbody>
<% if (files.length === 0) { %>
<tr><td colspan="5">No files yet. <a href="/upload">Upload one.</a></td></tr>
<% } else { %>
<% files.forEach(function(f) { %>
<tr>
<td><a href="/f/<%= f.id %>"><%= f.original_name %></a></td>
<td><%= f.mime_type %></td>
<td><%= f.sizeFormatted %></td>
<td><%= f.created_at %></td>
<td>
<button class="copy-link" onclick="navigator.clipboard.writeText('<%= baseUrl %>/f/<%= f.id %>')">Copy link</button>
<form method="POST" action="/files/<%= f.id %>/delete" style="display:inline">
<button type="submit" class="danger">Delete</button>
</form>
</td>
</tr>
<% }); %>
<% } %>
</tbody>
</table>
</div>

19
views/file-view.ejs Normal file
View File

@@ -0,0 +1,19 @@
<div class="file-view">
<h1><%= file.original_name %></h1>
<% if (file.mime_type.startsWith('image/')) { %>
<img src="/f/<%= file.id %>/raw" alt="<%= file.original_name %>">
<% } else if (file.mime_type.startsWith('video/')) { %>
<video controls src="/f/<%= file.id %>/raw" preload="metadata"></video>
<% } else if (file.mime_type.startsWith('audio/')) { %>
<audio controls src="/f/<%= file.id %>/raw" preload="metadata"></audio>
<% } %>
<div class="file-actions">
<a href="/f/<%= file.id %>/raw" download="<%= file.original_name %>" class="btn">Download</a>
<a href="/f/<%= file.id %>/raw" target="_blank" class="btn">Open</a>
</div>
<% if (isOwner) { %>
<form method="POST" action="/files/<%= file.id %>/delete">
<button type="submit" class="danger">Delete</button>
</form>
<% } %>
</div>

17
views/login.ejs Normal file
View File

@@ -0,0 +1,17 @@
<div class="form-container">
<h1>Sign in</h1>
<% if (error) { %>
<p class="error"><%= error %></p>
<% } %>
<form method="POST" action="/login">
<label>
Username
<input type="text" name="username" required autofocus>
</label>
<label>
Password
<input type="password" name="password" required>
</label>
<button type="submit">Login</button>
</form>
</div>

5
views/not-found.ejs Normal file
View File

@@ -0,0 +1,5 @@
<div class="form-container">
<h1>404 — Not found</h1>
<p>The page or file you requested does not exist.</p>
<p><a href="/">Go home</a></p>
</div>

9
views/upload-result.ejs Normal file
View File

@@ -0,0 +1,9 @@
<div class="form-container">
<h1>File uploaded</h1>
<p><strong><%= filename %></strong> is ready to share.</p>
<div class="share-box">
<input type="text" id="share-url" value="<%= shareUrl %>" readonly>
<button onclick="navigator.clipboard.writeText(document.getElementById('share-url').value)">Copy link</button>
</div>
<p><a href="/upload">Upload another</a> &middot; <a href="/files">My files</a></p>
</div>

13
views/upload.ejs Normal file
View File

@@ -0,0 +1,13 @@
<div class="form-container">
<h1>Upload a file</h1>
<% if (error) { %>
<p class="error"><%= error %></p>
<% } %>
<form method="POST" action="/upload" enctype="multipart/form-data">
<label>
File
<input type="file" name="file" required>
</label>
<button type="submit">Upload</button>
</form>
</div>