Initial commit

This commit is contained in:
Aaro Koivukangas
2026-10-11 03:26:15 +03:00
commit b6af96cf6d
49 changed files with 9627 additions and 0 deletions
+312
View File
@@ -0,0 +1,312 @@
import express from 'express';
import fs from 'node:fs';
import { isData, type Data } from '../shared/types';
import { createSessionToken, hashPassword, RateLimiter, verifyPassword, verifySessionToken } from './auth';
import * as config from './config';
import { UserStore, type User } from './store';
const COOKIE = 'workh_session';
const HOUR_MS = 3_600_000;
export const USERNAME_RULE = /^[a-z0-9][a-z0-9._-]{2,31}$/;
export const MIN_PASSWORD = 12;
const MAX_PASSWORD = 1024;
function readCookie(header: string | undefined, name: string): string | undefined {
for (const part of header?.split(';') ?? []) {
const i = part.indexOf('=');
if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
}
return undefined;
}
type AuthedRequest = express.Request & { user: User };
/** Defaults come from config.ts; tests override individual values. */
export interface AppOptions {
dataDir: string;
distDir: string;
legacyDataFile: string;
sessionSecret: string;
sessionTtlMs: number;
registrationCode: string;
maxUsers: number;
registrationsPerIpPerHour: number;
loginMaxFailures: number;
loginWindowMs: number;
}
export function createApp(overrides: Partial<AppOptions> = {}) {
const o: AppOptions = {
dataDir: config.DATA_DIR,
distDir: config.DIST_DIR,
legacyDataFile: config.LEGACY_DATA_FILE,
sessionSecret: config.SESSION_SECRET,
sessionTtlMs: config.SESSION_TTL_MS,
registrationCode: config.REGISTRATION_CODE,
maxUsers: config.MAX_USERS,
registrationsPerIpPerHour: config.REGISTRATIONS_PER_IP_PER_HOUR,
loginMaxFailures: config.LOGIN_MAX_FAILURES,
loginWindowMs: config.LOGIN_WINDOW_MS,
...overrides,
};
const store = new UserStore(o.dataDir);
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', config.TRUST_PROXY);
const loginLimiter = new RateLimiter(o.loginMaxFailures, o.loginWindowMs);
const signupLimiter = new RateLimiter(o.registrationsPerIpPerHour, HOUR_MS);
// Logins for unknown usernames still run scrypt against this, so they take as long as real ones.
const dummyHash = hashPassword('dummy password for timing');
app.use((_req, res, next) => {
res.set({
'Content-Security-Policy':
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; " +
"frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'",
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'no-referrer',
'Cross-Origin-Opener-Policy': 'same-origin',
'Strict-Transport-Security': 'max-age=31536000',
});
next();
});
app.use(express.json({ limit: '1mb' }));
// State-changing API calls must send JSON. Together with the SameSite=Strict cookie
// this stops cross-site form posts (CSRF): browsers can't send JSON cross-origin
// without a CORS preflight, which this server never approves.
app.use('/api', (req, res, next) => {
if (req.method !== 'GET' && req.method !== 'HEAD' && !req.is('application/json')) {
res.status(415).json({ error: 'Content-Type must be application/json' });
return;
}
next();
});
app.get('/api/health', (_req, res) => {
res.json({ ok: true });
});
const cookieOptions: express.CookieOptions = {
httpOnly: true,
sameSite: 'strict',
secure: true, // browsers also accept Secure cookies on http://localhost
path: '/',
};
const startSession = (res: express.Response, user: User) => {
const token = createSessionToken(user.id, o.sessionSecret, o.sessionTtlMs);
res.cookie(COOKIE, token, { ...cookieOptions, maxAge: o.sessionTtlMs });
};
/** The logged-in user, or undefined. A deleted account's cookie no longer matches anyone. */
const currentUser = (req: express.Request): User | undefined => {
const token = readCookie(req.headers.cookie, COOKIE);
const id = token ? verifySessionToken(token, o.sessionSecret) : null;
return id ? store.findById(id) : undefined;
};
const credentials = (body: unknown) => {
const { username, password } = (body ?? {}) as { username?: unknown; password?: unknown };
if (typeof username !== 'string' || typeof password !== 'string' || password.length > MAX_PASSWORD) return null;
return { username: username.trim().toLowerCase(), password };
};
const tooMany = (res: express.Response, limiter: RateLimiter, key: string, what: string) => {
const wait = limiter.retryAfter(key);
if (wait === 0) return false;
res
.set('Retry-After', String(wait))
.status(429)
.json({ error: `Too many ${what}. Try again later.` });
return true;
};
// ---------- Registration ----------
app.get('/api/registration', (_req, res) => {
res.json({ open: store.count() < o.maxUsers, inviteRequired: o.registrationCode !== '' });
});
app.post('/api/register', async (req, res) => {
const ip = req.ip ?? 'unknown';
if (tooMany(res, signupLimiter, ip, 'new accounts from your address')) return;
const creds = credentials(req.body);
if (!creds) {
res.status(400).json({ error: 'username and password are required' });
return;
}
if (!USERNAME_RULE.test(creds.username)) {
res.status(400).json({
error: 'Username must be 3–32 characters: letters, numbers, dots, dashes or underscores',
});
return;
}
if (creds.password.length < MIN_PASSWORD) {
res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD} characters` });
return;
}
const { inviteCode } = (req.body ?? {}) as { inviteCode?: unknown };
if (o.registrationCode !== '' && inviteCode !== o.registrationCode) {
signupLimiter.hit(ip); // guessing invite codes counts against the sign-up limit
res.status(403).json({ error: 'Invalid invite code' });
return;
}
if (store.count() >= o.maxUsers) {
res.status(403).json({ error: 'No more accounts can be created' });
return;
}
if (store.findByName(creds.username)) {
res.status(409).json({ error: 'That username is taken' });
return;
}
const passwordHash = await hashPassword(creds.password);
if (store.count() >= o.maxUsers) {
res.status(403).json({ error: 'No more accounts can be created' }); // filled up while hashing
return;
}
// The very first account inherits hours logged before accounts existed.
const legacy = store.count() === 0 ? readLegacyData(o.legacyDataFile) : undefined;
const user = store.create(creds.username, passwordHash, legacy);
if (!user) {
res.status(409).json({ error: 'That username is taken' }); // lost a race with a parallel sign-up
return;
}
if (legacy) {
fs.renameSync(o.legacyDataFile, `${o.legacyDataFile}.migrated`);
console.log(`workh: moved ${o.legacyDataFile} into the new account "${user.username}"`);
}
signupLimiter.hit(ip);
startSession(res, user);
res.status(201).json({ username: user.username });
});
// ---------- Sessions ----------
app.post('/api/login', async (req, res) => {
const ip = req.ip ?? 'unknown';
if (tooMany(res, loginLimiter, ip, 'failed logins')) return;
const creds = credentials(req.body);
if (!creds) {
res.status(400).json({ error: 'username and password are required' });
return;
}
const user = store.findByName(creds.username);
const ok = await verifyPassword(creds.password, user?.passwordHash ?? (await dummyHash));
if (!user || !ok) {
loginLimiter.hit(ip);
res.status(401).json({ error: 'Wrong username or password' });
return;
}
loginLimiter.reset(ip);
startSession(res, user);
res.json({ username: user.username });
});
app.post('/api/logout', (_req, res) => {
res.clearCookie(COOKIE, cookieOptions);
res.status(204).end();
});
// Everything below requires a logged-in user.
const requireAuth: express.RequestHandler = (req, res, next) => {
const user = currentUser(req);
if (!user) {
res.status(401).json({ error: 'Not logged in' });
return;
}
(req as AuthedRequest).user = user;
next();
};
app.get('/api/session', requireAuth, (req, res) => {
res.json({ username: (req as AuthedRequest).user.username });
});
app.delete('/api/account', requireAuth, async (req, res) => {
const { user } = req as AuthedRequest;
const ip = req.ip ?? 'unknown';
if (tooMany(res, loginLimiter, ip, 'failed attempts')) return;
const { password } = (req.body ?? {}) as { password?: unknown };
if (typeof password !== 'string' || !(await verifyPassword(password, user.passwordHash))) {
loginLimiter.hit(ip);
res.status(403).json({ error: 'Wrong password' });
return;
}
store.delete(user.id);
console.log(`workh: deleted account "${user.username}"`);
res.clearCookie(COOKIE, cookieOptions);
res.status(204).end();
});
// ---------- Data ----------
// The data version travels as an ETag. Saves must send it back in If-Match; a stale
// version gets 412 with the current data so the client can merge and retry.
app.get('/api/data', requireAuth, (req, res) => {
res.set('Cache-Control', 'no-store');
try {
const { data, rev } = store.readData((req as AuthedRequest).user.id);
res.set('ETag', `"${rev}"`).json(data);
} catch (err) {
console.error('Could not read data file:', err);
res.status(500).json({ error: 'Could not read data file' });
}
});
app.put('/api/data', requireAuth, (req, res) => {
const ifMatch = req.get('If-Match')?.replace(/^"|"$/g, '');
if (!ifMatch) {
res.status(428).json({ error: 'If-Match header with the data version is required' });
return;
}
if (!isData(req.body)) {
res.status(400).json({ error: 'Invalid data shape' });
return;
}
const result = store.writeData((req as AuthedRequest).user.id, req.body, ifMatch);
if (!result.ok) {
res.status(412).set('ETag', `"${result.current.rev}"`).json(result.current.data);
return;
}
res.set('ETag', `"${result.rev}"`).json({ ok: true });
});
app.use('/api', (_req, res) => {
res.status(404).json({ error: 'Not found' });
});
// Malformed / oversized JSON bodies end up here; answer with JSON instead of an HTML error page.
app.use((err: unknown, _req: express.Request, res: express.Response, next: express.NextFunction) => {
if (err instanceof SyntaxError) {
res.status(400).json({ error: 'Malformed JSON' });
return;
}
if (typeof err === 'object' && err !== null && 'status' in err && err.status === 413) {
res.status(413).json({ error: 'Request too large' });
return;
}
next(err);
});
if (fs.existsSync(o.distDir)) app.use(express.static(o.distDir));
return app;
}
function readLegacyData(file: string): Data | undefined {
if (!fs.existsSync(file)) return undefined;
try {
const data: unknown = JSON.parse(fs.readFileSync(file, 'utf8'));
return isData(data) ? data : undefined;
} catch {
return undefined;
}
}
+116
View File
@@ -0,0 +1,116 @@
import { createHmac, randomBytes, scrypt as scryptCb, timingSafeEqual, type ScryptOptions } from 'node:crypto';
// ---------- Password hashing (scrypt) ----------
// Stored format: scrypt$N$r$p$<salt base64>$<hash base64>
const SCRYPT = { N: 2 ** 15, r: 8, p: 1, keylen: 64 };
// 128 * N * r = 32 MiB per hash; give scrypt a little headroom above that.
const MAXMEM = 64 * 1024 * 1024;
function scrypt(password: string, salt: Buffer, keylen: number, opts: ScryptOptions): Promise<Buffer> {
return new Promise((resolve, reject) =>
scryptCb(password, salt, keylen, opts, (err, key) => (err ? reject(err) : resolve(key))),
);
}
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16);
const { N, r, p, keylen } = SCRYPT;
const hash = await scrypt(password, salt, keylen, { N, r, p, maxmem: MAXMEM });
return ['scrypt', N, r, p, salt.toString('base64'), hash.toString('base64')].join('$');
}
export function isPasswordHash(stored: string): boolean {
return /^scrypt\$\d+\$\d+\$\d+\$[A-Za-z0-9+/=]+\$[A-Za-z0-9+/=]+$/.test(stored);
}
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
if (!isPasswordHash(stored)) return false;
const [, n, r, p, saltB64, hashB64] = stored.split('$');
const expected = Buffer.from(hashB64, 'base64');
const actual = await scrypt(password, Buffer.from(saltB64, 'base64'), expected.length, {
N: Number(n),
r: Number(r),
p: Number(p),
maxmem: MAXMEM,
});
return timingSafeEqual(actual, expected);
}
// ---------- Session tokens ----------
// Stateless: base64url(JSON {u, exp}) + "." + base64url(HMAC-SHA256). Rotating
// SESSION_SECRET invalidates every session.
interface SessionPayload {
u: string;
exp: number;
}
const sign = (payload: string, secret: string) => createHmac('sha256', secret).update(payload).digest('base64url');
export function createSessionToken(userId: string, secret: string, ttlMs: number, now = Date.now()): string {
const payload = Buffer.from(JSON.stringify({ u: userId, exp: now + ttlMs } satisfies SessionPayload)).toString(
'base64url',
);
return `${payload}.${sign(payload, secret)}`;
}
/** Returns the user id for a valid, unexpired token, otherwise null. */
export function verifySessionToken(token: string, secret: string, now = Date.now()): string | null {
const parts = token.split('.');
if (parts.length !== 2) return null;
const [payload, sig] = parts;
const expected = Buffer.from(sign(payload, secret));
const actual = Buffer.from(sig);
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) return null;
try {
const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Partial<SessionPayload>;
if (typeof data.u !== 'string' || typeof data.exp !== 'number' || data.exp <= now) return null;
return data.u;
} catch {
return null;
}
}
// ---------- Rate limiting ----------
/**
* Counts events (failed logins, sign-ups) per key (client IP) in a sliding window. After
* `maxHits` events the key is blocked until its oldest event leaves the window.
*/
export class RateLimiter {
private hits = new Map<string, number[]>();
constructor(
private maxHits: number,
private windowMs: number,
private now: () => number = Date.now,
) {}
private recent(key: string): number[] {
const cutoff = this.now() - this.windowMs;
const list = (this.hits.get(key) ?? []).filter((t) => t > cutoff);
if (list.length) this.hits.set(key, list);
else this.hits.delete(key);
return list;
}
/** Seconds until the key may try again; 0 if it is not blocked. */
retryAfter(key: string): number {
const list = this.recent(key);
if (list.length < this.maxHits) return 0;
return Math.max(1, Math.ceil((list[0] + this.windowMs - this.now()) / 1000));
}
hit(key: string): void {
this.hits.set(key, [...this.recent(key), this.now()]);
// Keep memory bounded if someone sprays many source IPs.
if (this.hits.size > 10_000) {
for (const k of this.hits.keys()) this.recent(k);
}
}
reset(key: string): void {
this.hits.delete(key);
}
}
+28
View File
@@ -0,0 +1,28 @@
import { randomBytes } from 'node:crypto';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
// ---------- From the environment ----------
export const PORT = Number(process.env.PORT ?? 3001);
/** Holds users.json and users/<id>.json. */
export const DATA_DIR = process.env.DATA_DIR ?? path.join(ROOT, 'data');
/** Signs session cookies. Without one, a random secret is used and everyone is logged out on restart. */
export const SESSION_SECRET = process.env.SESSION_SECRET ?? randomBytes(32).toString('base64url');
/** If set, creating an account requires this invite code. */
export const REGISTRATION_CODE = process.env.REGISTRATION_CODE ?? '';
// ---------- Fixed ----------
export const DIST_DIR = path.join(ROOT, 'dist');
/** Pre-accounts single-user data; adopted by the first account created, if present. */
export const LEGACY_DATA_FILE = path.join(ROOT, 'data.json');
export const SESSION_TTL_MS = 30 * 86_400_000;
export const MAX_USERS = 100;
export const REGISTRATIONS_PER_IP_PER_HOUR = 5;
export const LOGIN_MAX_FAILURES = 10;
export const LOGIN_WINDOW_MS = 15 * 60_000;
/** Trust X-Forwarded-For only from private-network peers, i.e. the ingress proxy. */
export const TRUST_PROXY = 'loopback, linklocal, uniquelocal';
+9
View File
@@ -0,0 +1,9 @@
import { createApp } from './app';
import { DATA_DIR, PORT } from './config';
const server = createApp().listen(PORT, () => {
console.log(`workh listening on http://localhost:${PORT} (data: ${DATA_DIR})`);
});
// Exit promptly when Kubernetes / Docker stops the container.
process.on('SIGTERM', () => server.close(() => process.exit(0)));
+118
View File
@@ -0,0 +1,118 @@
import { createHash, randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { defaultData, type Data } from '../shared/types';
export interface User {
id: string;
/** Stored lowercase; usernames are case-insensitive. */
username: string;
passwordHash: string;
createdAt: string;
}
interface UsersFile {
users: User[];
}
/** Version tag of a user's stored data; changes whenever the file's content does. */
const revOf = (content: string | null) =>
content === null ? 'none' : createHash('sha256').update(content).digest('base64url').slice(0, 22);
export type WriteResult = { ok: true; rev: string } | { ok: false; current: { data: Data; rev: string } };
/** Writes via a temp file + rename so a crash never leaves a half-written file. */
function writeAtomic(file: string, content: string) {
const tmp = `${file}.tmp`;
fs.writeFileSync(tmp, content);
fs.renameSync(tmp, file);
}
/**
* Users live in <dataDir>/users.json; each user's hours in <dataDir>/users/<id>.json.
* All access goes through this one process, and writes are synchronous, so the
* in-memory copy of users.json is always the truth.
*/
export class UserStore {
private users: User[];
private readonly usersFile: string;
private readonly userDir: string;
constructor(dataDir: string) {
this.usersFile = path.join(dataDir, 'users.json');
this.userDir = path.join(dataDir, 'users');
fs.mkdirSync(this.userDir, { recursive: true });
this.users = fs.existsSync(this.usersFile)
? (JSON.parse(fs.readFileSync(this.usersFile, 'utf8')) as UsersFile).users
: [];
}
private persist() {
writeAtomic(this.usersFile, JSON.stringify({ users: this.users } satisfies UsersFile, null, 2) + '\n');
}
private dataFile(id: string) {
return path.join(this.userDir, `${id}.json`);
}
count(): number {
return this.users.length;
}
findById(id: string): User | undefined {
return this.users.find((u) => u.id === id);
}
findByName(username: string): User | undefined {
const name = username.toLowerCase();
return this.users.find((u) => u.username === name);
}
/** Returns null if the username is already taken. */
create(username: string, passwordHash: string, initialData?: Data): User | null {
if (this.findByName(username)) return null;
const user: User = {
id: randomUUID(),
username: username.toLowerCase(),
passwordHash,
createdAt: new Date().toISOString(),
};
if (initialData) this.writeData(user.id, initialData);
this.users.push(user);
this.persist();
return user;
}
/** Removes the user and every file holding their hours. */
delete(id: string): void {
this.users = this.users.filter((u) => u.id !== id);
this.persist();
for (const suffix of ['', '.bak', '.tmp']) fs.rmSync(`${this.dataFile(id)}${suffix}`, { force: true });
}
private readRaw(id: string): string | null {
const file = this.dataFile(id);
return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
}
readData(id: string): { data: Data; rev: string } {
const raw = this.readRaw(id);
return { data: raw === null ? defaultData() : (JSON.parse(raw) as Data), rev: revOf(raw) };
}
/**
* Saves only if the stored data is still at `ifRev` — the version the client started
* from — so an outdated tab or device can't overwrite newer changes. Omit `ifRev` to
* write unconditionally (new accounts).
*/
writeData(id: string, data: Data, ifRev?: string): WriteResult {
if (ifRev !== undefined && revOf(this.readRaw(id)) !== ifRev) {
return { ok: false, current: this.readData(id) };
}
const file = this.dataFile(id);
const content = JSON.stringify(data, null, 2) + '\n';
if (fs.existsSync(file)) fs.copyFileSync(file, `${file}.bak`);
writeAtomic(file, content);
return { ok: true, rev: revOf(content) };
}
}