26 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
48 changed files with 1693 additions and 894 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}"

1374
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",
@@ -20,14 +23,18 @@
"@fastify/multipart": "^9.4.0", "@fastify/multipart": "^9.4.0",
"@fastify/rate-limit": "^10.3.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

@@ -7,29 +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: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--border: 1px solid var(--black); --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);
line-height: 1.5; line-height: 1.5;
background: var(--white); background: var(--bg);
color: var(--black); 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;
} }
@@ -266,14 +298,14 @@ a.btn:hover {
.share-box input[readonly] { .share-box input[readonly] {
flex: 1; flex: 1;
font-family: var(--font-mono); font-family: var(--font-mono);
background: var(--gray-100); background: var(--bg-elevated);
color: var(--gray-600); 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 */
@@ -283,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;
@@ -292,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 ──────────────────────────────────────────────── */
@@ -310,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 {
@@ -323,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;
@@ -356,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 ──────────────────────────────────────────── */
@@ -382,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;
@@ -406,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;
@@ -28,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',

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']);

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,38 +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'))
);
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);
`);
return db; return db;
} }

View File

@@ -1,10 +1,41 @@
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): {
httpOnly: boolean;
sameSite: 'strict';
secure: boolean;
path: string;
maxAge: number;
} {
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 { try {
await request.jwtVerify(); const payload = await request.jwtVerify<JwtPayload>();
slideSessionIfNeeded(request, reply, payload, request.server, config.cookieSecure);
} catch { } catch {
// API routes get 401, page routes get redirect
const isApi = request.url.startsWith('/api/'); const isApi = request.url.startsWith('/api/');
if (isApi) { if (isApi) {
reply.status(401).send({ error: 'Unauthorized' }); reply.status(401).send({ error: 'Unauthorized' });
@@ -12,13 +43,5 @@ export async function requireAuth(request: FastifyRequest, reply: FastifyReply):
reply.redirect('/'); reply.redirect('/');
} }
} }
} };
export function tokenCookieOptions(secure: boolean): {
httpOnly: boolean;
sameSite: 'strict';
secure: boolean;
path: string;
} {
return { httpOnly: true, sameSite: 'strict', secure, path: '/' };
} }

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

@@ -4,7 +4,8 @@ import type { Config } from '../../../config.ts';
import type { Logger } from '../../../middleware/logging.ts'; import type { Logger } from '../../../middleware/logging.ts';
import type { LockoutService } from '../../../services/lockout.ts'; import type { LockoutService } from '../../../services/lockout.ts';
import { attemptLogin } from '../../../services/login-handler.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;
@@ -20,6 +21,7 @@ interface LoginBody {
export const authApiRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }) => { export const authApiRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }) => {
const { config } = deps; const { config } = deps;
const requireAuth = makeRequireAuth(config);
app.post<{ Body: LoginBody }>( app.post<{ Body: LoginBody }>(
'/login', '/login',
@@ -56,15 +58,17 @@ export const authApiRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { d
return reply.status(401).send({ error: 'Invalid credentials' }); return reply.status(401).send({ error: 'Invalid credentials' });
} }
const token = app.jwt.sign( issueSessionCookie(
reply,
app,
{ sub: result.user.id, username: result.user.username }, { sub: result.user.id, username: result.user.username },
{ expiresIn: config.jwtExpiry }, config.cookieSecure,
); );
reply.setCookie('token', token, tokenCookieOptions(config.cookieSecure)).send({ ok: true }); 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

@@ -10,12 +10,14 @@ 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 { saveFile, deleteStoredFile, getFilePath } from '../services/storage.ts'; import { saveFile, deleteStoredFile, getFilePath } from '../services/storage.ts';
import { attemptLogin } from '../services/login-handler.ts'; import { attemptLogin } from '../services/login-handler.ts';
import { requireAuth, tokenCookieOptions } from '../middleware/auth.ts'; import { makeRequireAuth, issueSessionCookie } from '../middleware/auth.ts';
import { loginPage } from '../views/login.ts'; import { SESSION_COOKIE_NAME } from '../constants.ts';
import { uploadPage, uploadResultPage } from '../views/upload.ts';
import { fileListPage } from '../views/file-list.ts'; function formatBytes(bytes: number): string {
import { fileViewPage } from '../views/file-view.ts'; if (bytes < 1024) return `${bytes} B`;
import { notFoundPage } from '../views/not-found.ts'; 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;
@@ -49,6 +51,7 @@ 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 = { const loginRateLimit = {
rateLimit: { rateLimit: {
max: config.loginRateLimitMax, max: config.loginRateLimitMax,
@@ -57,12 +60,13 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps
}; };
// 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 });
} }
}); });
@@ -82,31 +86,32 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps
if (result.kind === 'locked') { if (result.kind === 'locked') {
return reply return reply
.type('text/html')
.header('Retry-After', String(result.retryAfterSeconds)) .header('Retry-After', String(result.retryAfterSeconds))
.send(loginPage({ error: 'Invalid username or password' })); .view('login.ejs', { title: 'Login', error: 'Invalid username or password' });
} }
if (result.kind !== 'success') { if (result.kind !== 'success') {
return reply.type('text/html').send(loginPage({ error: 'Invalid username or password' })); return reply.view('login.ejs', { title: 'Login', error: 'Invalid username or password' });
} }
const token = app.jwt.sign( issueSessionCookie(
reply,
app,
{ sub: result.user.id, username: result.user.username }, { sub: result.user.id, username: result.user.username },
{ expiresIn: config.jwtExpiry }, config.cookieSecure,
); );
reply.setCookie('token', token, tokenCookieOptions(config.cookieSecure)).redirect('/upload'); 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
@@ -115,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();
@@ -136,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
@@ -163,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;
@@ -176,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
@@ -229,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

@@ -5,10 +5,13 @@ 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 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 { createLockoutService } from './services/lockout.ts';
import { authApiRoutes } from './routes/api/v1/auth.ts'; import { authApiRoutes } from './routes/api/v1/auth.ts';
@@ -30,7 +33,7 @@ export function createServer({ config, db }: ServerDeps) {
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 } });
@@ -38,6 +41,12 @@ 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, { app.register(fastifyRateLimit, {
global: false, global: false,
keyGenerator: (req) => req.ip, keyGenerator: (req) => req.ip,

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 {
@@ -66,7 +67,6 @@ export function createTestApp(overrides: Partial<Config> = {}): 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,

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

@@ -56,14 +56,14 @@ describe('per-IP rate limit on login routes', () => {
body: JSON.stringify({ username: 'alice', password: 'correct-pw' }), body: JSON.stringify({ username: 'alice', password: 'correct-pw' }),
}); });
expect(loginRes.statusCode).toBe(200); expect(loginRes.statusCode).toBe(200);
const cookie = (loginRes.headers['set-cookie'] as string).split(';')[0].replace('token=', ''); const cookie = (loginRes.headers['set-cookie'] as string).split(';')[0].replace('nanodrop_session=', '');
// Now hit /upload (GET) repeatedly past the login-route limit threshold // Now hit /upload (GET) repeatedly past the login-route limit threshold
for (let i = 0; i < 6; i++) { for (let i = 0; i < 6; i++) {
const r = await ctx.app.inject({ const r = await ctx.app.inject({
method: 'GET', method: 'GET',
url: '/upload', url: '/upload',
cookies: { token: cookie }, cookies: { nanodrop_session: cookie },
}); });
expect(r.statusCode).toBe(200); 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

@@ -25,6 +25,29 @@ describe('public/style.css (file contents)', () => {
expect(css).toContain('--font-mono'); expect(css).toContain('--font-mono');
expect(css).toMatch(/\.share-box input\[readonly\][\s\S]*?font-family:\s*var\(--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', () => { describe('GET /public/style.css', () => {

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;
@@ -35,7 +34,6 @@ describe('config', () => {
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');
@@ -55,7 +53,6 @@ describe('config', () => {
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';
@@ -66,7 +63,6 @@ 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);

View File

@@ -10,7 +10,6 @@ function makeConfig(overrides: Partial<Config> = {}): Config {
port: 0, port: 0,
host: '127.0.0.1', host: '127.0.0.1',
jwtSecret: 'x', jwtSecret: 'x',
jwtExpiry: '1h',
dbPath: ':memory:', dbPath: ':memory:',
uploadDir: '/tmp', uploadDir: '/tmp',
logFile: '/tmp/x.log', logFile: '/tmp/x.log',

View File

@@ -17,7 +17,6 @@ function makeConfig(overrides: Partial<Config> = {}): Config {
port: 0, port: 0,
host: '127.0.0.1', host: '127.0.0.1',
jwtSecret: 'x', jwtSecret: 'x',
jwtExpiry: '1h',
dbPath: ':memory:', dbPath: ':memory:',
uploadDir: '/tmp', uploadDir: '/tmp',
logFile: '/tmp/x.log', logFile: '/tmp/x.log',

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>