mirror of
https://github.com/iritmaximus/workh.git
synced 2026-10-12 19:13:33 +03:00
Initial commit
This commit is contained in:
+312
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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)));
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user