Brendan Chen 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
2026-03-03 16:36:06 -08:00

Nanodrop

Vibe coded alternative to Google Drive for sharing files

  • Server-rendered HTML, no client-side framework
  • SQLite database, files stored on disk
  • JWT authentication via httpOnly cookies
  • REST API alongside the web UI

Stack

  • Runtime: Node.js 22
  • Framework: Fastify
  • Database: SQLite (better-sqlite3)
  • Language: TypeScript (ESM, run directly via tsx)

Quick start (local)

npm install
JWT_SECRET=changeme npm run dev

Create a user first:

npm run register-user -- --username alice --password secret

Then open http://localhost:3000.


Docker

Build and run

docker compose up -d

Requires a .env file (or environment variables) with at minimum:

JWT_SECRET=your-secret-here

All data (database + uploads) is stored in the nanodrop-data Docker volume.

Register a user in Docker

docker compose run --rm register-user --username alice --password secret

Environment variables

Variable Default Description
JWT_SECRET (required) Secret key for signing JWTs
JWT_EXPIRY 7d JWT token lifetime
PORT 3000 Port to listen on
HOST 0.0.0.0 Host to bind
BASE_URL http://localhost:3000 Public base URL (used in share links)
DB_PATH ./data/nanodrop.db SQLite database path
UPLOAD_DIR ./data/uploads Upload storage directory
LOG_FILE ./data/nanodrop.log Auth and access log path
MAX_FILE_SIZE 104857600 Max upload size in bytes (100 MB)
COOKIE_SECURE false Set true when serving over HTTPS
TRUST_PROXY false Set true when behind a reverse proxy

Reverse proxy

Set TRUST_PROXY=true when running behind a reverse proxy so Nanodrop sees the real client IP in logs.

Caddy (Caddyfile):

files.example.com {
    reverse_proxy localhost:3000

    request_body {
        max_size 110MB
    }
}

Caddy sets X-Forwarded-For and handles TLS automatically. Set COOKIE_SECURE=true since traffic to the app arrives over HTTPS.

nginx (sites-available/nanodrop):

server {
    server_name files.example.com;

    location / {
        proxy_pass         http://127.0.0.1:3000;
        proxy_set_header   Host              $host;
        proxy_set_header   X-Real-IP         $remote_addr;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
        client_max_body_size 110M;
    }
}

Fail2ban

Nanodrop logs failed login attempts to LOG_FILE (default ./data/nanodrop.log) in this format:

[2026-03-03T12:00:00.000Z] AUTH_FAILURE ip=203.0.113.42 user-agent="curl/8.0" username="admin"

1. Create a filter

/etc/fail2ban/filter.d/nanodrop.conf:

[Definition]
failregex = ^.* AUTH_FAILURE ip=<HOST> .*$
ignoreregex =

2. Create a jail

/etc/fail2ban/jail.d/nanodrop.conf:

[nanodrop]
enabled  = true
filter   = nanodrop
logpath  = /path/to/nanodrop.log
maxretry = 5
findtime = 60
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:

# docker-compose.yml override
volumes:
  - /var/lib/nanodrop:/app/data

Then set logpath = /var/lib/nanodrop/nanodrop.log.

3. Reload fail2ban

sudo fail2ban-client reload
sudo fail2ban-client status nanodrop

Development

npm run dev      # start with hot reload via tsx
npm test         # run all tests (vitest)
npm run build    # type check (tsc --noEmit)
Description
Vibe coded file sharing platform
Readme 362 KiB
Languages
TypeScript 87.9%
CSS 8.7%
EJS 3.1%
Dockerfile 0.3%