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.
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)