16 Commits

Author SHA1 Message Date
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
26 changed files with 389 additions and 87 deletions

View File

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

View File

@@ -1,4 +1,4 @@
name: "Deploy to Homelab"
name: "Deploy to birb co. production"
on:
push:
@@ -14,6 +14,15 @@ jobs:
- name: Check out repository
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
run: |
mkdir -p ~/.ssh
@@ -34,15 +43,13 @@ jobs:
- name: Deploy on server with Docker
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 }}
export JWT_SECRET='${{ secrets.JWT_SECRET }}'
export TRUST_PROXY=true
export COOKIE_SECURE=true
export JWT_SECRET=${{ secrets.JWT_SECRET }}
export PORT=${{ vars.PORT }}
export BASE_URL=${{ vars.BASE_URL }}
export MAX_FILE_SIZE=${{ vars.MAX_FILE_SIZE }}
docker compose -f docker-compose.yml down
docker compose -f docker-compose.yml up -d --build
export PORT='${{ vars.PORT }}'
export BASE_URL='${{ vars.BASE_URL }}'
export MAX_FILE_SIZE='${{ vars.MAX_FILE_SIZE }}'
docker compose -f compose.yaml up -d --build --remove-orphans
EOF

View File

@@ -60,7 +60,6 @@ docker compose run --rm register-user --username alice --password secret
| 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) |
@@ -71,6 +70,8 @@ docker compose run --rm register-user --username alice --password secret
| `COOKIE_SECURE` | `false` | Set `true` when serving over HTTPS |
| `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
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:
```yaml
# docker-compose.yml override
# compose.yaml override
volumes:
- /var/lib/nanodrop:/app/data
```

View File

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

View File

@@ -150,7 +150,7 @@ input[type="file"] {
width: 100%;
padding: 0.6rem 0.75rem;
font-family: var(--font);
font-size: 13px;
font-size: 16px;
background: var(--white);
border: var(--border);
color: var(--black);
@@ -310,10 +310,16 @@ h1 + p {
font-size: 12px;
}
.table-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border: var(--border);
}
table {
width: 100%;
min-width: 100%;
border-collapse: collapse;
border: var(--border);
}
th {

View File

@@ -2,7 +2,6 @@ export interface Config {
port: number;
host: string;
jwtSecret: string;
jwtExpiry: string;
dbPath: string;
uploadDir: string;
logFile: string;
@@ -28,7 +27,6 @@ export function loadConfig(): Config {
port: parseInt(process.env.PORT ?? '3000', 10),
host: process.env.HOST ?? '0.0.0.0',
jwtSecret,
jwtExpiry: process.env.JWT_EXPIRY ?? '7d',
dbPath: process.env.DB_PATH ?? './data/nanodrop.db',
uploadDir: process.env.UPLOAD_DIR ?? './data/uploads',
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

@@ -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 {
await request.jwtVerify();
const payload = await request.jwtVerify<JwtPayload>();
slideSessionIfNeeded(request, reply, payload, request.server, config.cookieSecure);
} catch {
// API routes get 401, page routes get redirect
const isApi = request.url.startsWith('/api/');
if (isApi) {
reply.status(401).send({ error: 'Unauthorized' });
@@ -12,13 +43,5 @@ export async function requireAuth(request: FastifyRequest, reply: FastifyReply):
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 { LockoutService } from '../../../services/lockout.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 {
db: Database.Database;
@@ -20,6 +21,7 @@ interface LoginBody {
export const authApiRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }) => {
const { config } = deps;
const requireAuth = makeRequireAuth(config);
app.post<{ Body: LoginBody }>(
'/login',
@@ -56,15 +58,17 @@ export const authApiRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { d
return reply.status(401).send({ error: 'Invalid credentials' });
}
const token = app.jwt.sign(
issueSessionCookie(
reply,
app,
{ 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) => {
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 { createFile, getFilesByUserId, getFileById, deleteFile } from '../../../db/files.ts';
import { saveFile, deleteStoredFile } from '../../../services/storage.ts';
import { requireAuth } from '../../../middleware/auth.ts';
import { makeRequireAuth } from '../../../middleware/auth.ts';
interface Deps {
db: Database.Database;
@@ -17,6 +17,7 @@ interface Deps {
export const filesApiRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }) => {
const { db, config } = deps;
const requireAuth = makeRequireAuth(config);
app.get('/', { preHandler: requireAuth }, async (request, reply) => {
const { sub: userId } = request.user as JwtPayload;

View File

@@ -10,7 +10,8 @@ import type { LockoutService } from '../services/lockout.ts';
import { createFile, getFileById, getFilesByUserId, deleteFile } from '../db/files.ts';
import { saveFile, deleteStoredFile, getFilePath } from '../services/storage.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';
import { loginPage } from '../views/login.ts';
import { uploadPage, uploadResultPage } from '../views/upload.ts';
import { fileListPage } from '../views/file-list.ts';
@@ -49,6 +50,7 @@ function parseRangeHeader(header: string, fileSize: number): { start: number; en
export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }) => {
const { db, config, logger } = deps;
const requireAuth = makeRequireAuth(config);
const loginRateLimit = {
rateLimit: {
max: config.loginRateLimitMax,
@@ -57,6 +59,7 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps
};
// GET / — login page or redirect if authed
// opportunistic auth — does not slide the session; see middleware/session-renewal.ts
app.get('/', async (request, reply) => {
try {
await request.jwtVerify();
@@ -91,17 +94,19 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps
return reply.type('text/html').send(loginPage({ error: 'Invalid username or password' }));
}
const token = app.jwt.sign(
issueSessionCookie(
reply,
app,
{ 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
app.post('/logout', { preHandler: requireAuth }, async (_request, reply) => {
reply.clearCookie('token', { path: '/' }).redirect('/');
reply.clearCookie(SESSION_COOKIE_NAME, { path: '/' }).redirect('/');
});
// GET /upload
@@ -163,6 +168,7 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps
});
// 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) => {
const { id } = request.params;

View File

@@ -9,6 +9,7 @@ import { join } from 'path';
import { fileURLToPath } from 'url';
import type Database from 'better-sqlite3';
import type { Config } from './config.ts';
import { SESSION_COOKIE_NAME } from './constants.ts';
import { createLogger } from './middleware/logging.ts';
import { createLockoutService } from './services/lockout.ts';
import { authApiRoutes } from './routes/api/v1/auth.ts';
@@ -30,7 +31,7 @@ export function createServer({ config, db }: ServerDeps) {
app.register(fastifyCookie);
app.register(fastifyJwt, {
secret: config.jwtSecret,
cookie: { cookieName: 'token', signed: false },
cookie: { cookieName: SESSION_COOKIE_NAME, signed: false },
});
app.register(fastifyFormbody);
app.register(fastifyMultipart, { limits: { fileSize: config.maxFileSize } });

View File

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

View File

@@ -24,6 +24,7 @@ export function fileListPage(files: FileRow[], baseUrl: string): string {
return layout('My files', `
<h1>My files</h1>
<p><a href="/upload">Upload new file</a></p>
<div class="table-wrap">
<table>
<thead>
<tr>
@@ -32,6 +33,7 @@ export function fileListPage(files: FileRow[], baseUrl: string): string {
</thead>
<tbody>${rows}</tbody>
</table>
</div>
`, { authed: true });
}

View File

@@ -3,6 +3,7 @@ import { tmpdir } from 'os';
import { join } from 'path';
import { initDb } from '../../src/db/schema.ts';
import { createServer } from '../../src/server.ts';
import { SESSION_COOKIE_NAME } from '../../src/constants.ts';
import type { Config } from '../../src/config.ts';
import type Database from 'better-sqlite3';
import type { FastifyInstance } from 'fastify';
@@ -15,7 +16,7 @@ export async function loginAs(app: FastifyInstance, username: string, password:
body: JSON.stringify({ username, password }),
});
const cookie = res.headers['set-cookie'] as string;
return cookie.split(';')[0].replace('token=', '');
return cookie.split(';')[0].replace(`${SESSION_COOKIE_NAME}=`, '');
}
interface MultipartFile {
@@ -66,7 +67,6 @@ export function createTestApp(overrides: Partial<Config> = {}): TestContext {
port: 0,
host: '127.0.0.1',
jwtSecret: 'test-secret-key',
jwtExpiry: '1h',
dbPath: ':memory:',
uploadDir,
logFile,

View File

@@ -26,7 +26,8 @@ describe('POST /api/v1/auth/login', () => {
});
expect(res.statusCode).toBe(200);
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 () => {
@@ -76,7 +77,7 @@ describe('POST /api/v1/auth/logout', () => {
body: JSON.stringify({ username: 'alice', password: 'secret' }),
});
const cookie = res.headers['set-cookie'] as string;
token = cookie.split(';')[0].replace('token=', '');
token = cookie.split(';')[0].replace('nanodrop_session=', '');
});
afterEach(async () => {
@@ -88,10 +89,10 @@ describe('POST /api/v1/auth/logout', () => {
const res = await ctx.app.inject({
method: 'POST',
url: '/api/v1/auth/logout',
cookies: { token },
cookies: { nanodrop_session: token },
});
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 () => {

View File

@@ -56,14 +56,14 @@ describe('per-IP rate limit on login routes', () => {
body: JSON.stringify({ username: 'alice', password: 'correct-pw' }),
});
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
for (let i = 0; i < 6; i++) {
const r = await ctx.app.inject({
method: 'GET',
url: '/upload',
cookies: { token: cookie },
cookies: { nanodrop_session: cookie },
});
expect(r.statusCode).toBe(200);
}

View File

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

View File

@@ -25,7 +25,7 @@ describe('GET /', () => {
it('redirects to /upload when authenticated', async () => {
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.headers['location']).toBe('/upload');
});
@@ -46,7 +46,7 @@ describe('POST /login (page)', () => {
});
expect(res.statusCode).toBe(302);
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 () => {
@@ -72,7 +72,7 @@ describe('GET /upload + POST /upload', () => {
afterEach(async () => { await ctx.app.close(); ctx.cleanup(); });
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.body).toContain('Upload');
});
@@ -87,7 +87,7 @@ describe('GET /upload + POST /upload', () => {
const res = await ctx.app.inject({
method: 'POST',
url: '/upload',
cookies: { token },
cookies: { nanodrop_session: token },
...buildMultipart({ file: { filename: 'doc.txt', contentType: 'text/plain', data: Buffer.from('content') } }),
});
expect(res.statusCode).toBe(200);
@@ -106,7 +106,7 @@ describe('GET /f/:id and GET /f/:id/raw', () => {
const uploadRes = await ctx.app.inject({
method: 'POST',
url: '/upload',
cookies: { token },
cookies: { nanodrop_session: token },
...buildMultipart({ file: { filename: 'hello.txt', contentType: 'text/plain', data: Buffer.from('hello!') } }),
});
// Extract file id from response body
@@ -188,7 +188,7 @@ describe('GET /f/:id — image inline', () => {
const uploadRes = await ctx.app.inject({
method: 'POST',
url: '/upload',
cookies: { token },
cookies: { nanodrop_session: token },
...buildMultipart({ file: { filename: 'photo.png', contentType: 'image/png', data: Buffer.from('fakepng') } }),
});
const match = uploadRes.body.match(/\/f\/([^/"]+)/);
@@ -213,16 +213,17 @@ describe('GET /files — copy link', () => {
await ctx.app.inject({
method: 'POST',
url: '/upload',
cookies: { token },
cookies: { nanodrop_session: token },
...buildMultipart({ file: { filename: 'test.txt', contentType: 'text/plain', data: Buffer.from('hi') } }),
});
});
afterEach(async () => { await ctx.app.close(); ctx.cleanup(); });
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.body).toContain('Copy link');
expect(res.body).toContain('class="table-wrap"');
});
});
@@ -244,7 +245,7 @@ describe('GET /f/:id — owner-aware header', () => {
const uploadRes = await ctx.app.inject({
method: 'POST',
url: '/upload',
cookies: { token: aliceToken },
cookies: { nanodrop_session: aliceToken },
...buildMultipart({ file: { filename: 'owned.txt', contentType: 'text/plain', data: Buffer.from('data') } }),
});
const match = uploadRes.body.match(/\/f\/([^/"]+)/);
@@ -253,19 +254,19 @@ describe('GET /f/:id — owner-aware header', () => {
afterEach(async () => { await ctx.app.close(); ctx.cleanup(); });
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.body).toContain('My Files');
});
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.body).toContain('delete');
});
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.body).not.toContain('<header');
});
@@ -288,7 +289,7 @@ describe('POST /files/:id/delete', () => {
const uploadRes = await ctx.app.inject({
method: 'POST',
url: '/upload',
cookies: { token },
cookies: { nanodrop_session: token },
...buildMultipart({ file: { filename: 'del.txt', contentType: 'text/plain', data: Buffer.from('bye') } }),
});
const match = uploadRes.body.match(/\/f\/([^/"]+)/);
@@ -300,7 +301,7 @@ describe('POST /files/:id/delete', () => {
const res = await ctx.app.inject({
method: 'POST',
url: `/files/${fileId}/delete`,
cookies: { token },
cookies: { nanodrop_session: token },
});
expect(res.statusCode).toBe(302);
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,12 @@ describe('public/style.css (file contents)', () => {
expect(css).toContain('--font-mono');
expect(css).toMatch(/\.share-box input\[readonly\][\s\S]*?font-family:\s*var\(--font-mono\)/);
});
it('uses font-size >= 16px on text-entry inputs to prevent iOS focus-zoom', () => {
expect(css).toMatch(
/input\[type="text"\][\s\S]*?input\[type="password"\][\s\S]*?\{[\s\S]*?font-size:\s*(1[6-9]|[2-9]\d)px/
);
});
});
describe('GET /public/style.css', () => {

View File

@@ -15,7 +15,6 @@ describe('config', () => {
process.env.JWT_SECRET = 'test-secret';
delete process.env.PORT;
delete process.env.HOST;
delete process.env.JWT_EXPIRY;
delete process.env.DB_PATH;
delete process.env.UPLOAD_DIR;
delete process.env.LOG_FILE;
@@ -35,7 +34,6 @@ describe('config', () => {
expect(config.port).toBe(3000);
expect(config.host).toBe('0.0.0.0');
expect(config.jwtExpiry).toBe('7d');
expect(config.dbPath).toBe('./data/nanodrop.db');
expect(config.uploadDir).toBe('./data/uploads');
expect(config.logFile).toBe('./data/nanodrop.log');
@@ -55,7 +53,6 @@ describe('config', () => {
process.env.JWT_SECRET = 'my-secret';
process.env.PORT = '4000';
process.env.HOST = '127.0.0.1';
process.env.JWT_EXPIRY = '1d';
process.env.COOKIE_SECURE = 'true';
process.env.TRUST_PROXY = 'true';
process.env.MAX_FILE_SIZE = '52428800';
@@ -66,7 +63,6 @@ describe('config', () => {
expect(config.port).toBe(4000);
expect(config.host).toBe('127.0.0.1');
expect(config.jwtSecret).toBe('my-secret');
expect(config.jwtExpiry).toBe('1d');
expect(config.cookieSecure).toBe(true);
expect(config.trustProxy).toBe(true);
expect(config.maxFileSize).toBe(52428800);

View File

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

View File

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

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