diff --git a/apps/api/src/app.test.ts b/apps/api/src/app.test.ts index d7df47a..76f0a40 100644 --- a/apps/api/src/app.test.ts +++ b/apps/api/src/app.test.ts @@ -25,4 +25,16 @@ describe('API', () => { expect(login.json().csrfToken).toBeTruthy(); await app.close(); }); + + it('registers a reader and stores private favorites', async () => { + const app = await buildApp({ sqlite: context.sqlite, sessionSecret: 'a-secret-with-at-least-thirty-two-characters' }); + const register = await app.inject({ method: 'POST', url: '/api/v1/auth/register', payload: { username: 'reader_one', password: 'correct horse' } }); + expect(register.statusCode).toBe(200); + const cookie = register.cookies.find((item) => item.name === 'tech_library_user')!; + const favorite = await app.inject({ method: 'PUT', url: '/api/v1/favorites/typescript', headers: { cookie: `${cookie.name}=${cookie.value}`, 'x-csrf-token': register.json().csrfToken } }); + expect(favorite.statusCode).toBe(200); + const list = await app.inject({ method: 'GET', url: '/api/v1/favorites?lang=da', headers: { cookie: `${cookie.name}=${cookie.value}` } }); + expect(list.json()[0]).toMatchObject({ slug: 'typescript', language: 'da', isFavorite: true }); + await app.close(); + }); }); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index ea7bd41..a7a2a0c 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -7,7 +7,7 @@ import { randomBytes, randomUUID } from 'node:crypto'; import type Database from 'better-sqlite3'; import { LibraryRepository } from './repository.js'; -declare module 'fastify' { interface FastifyRequest { adminSession?: { id: string; csrfToken: string } } } +declare module 'fastify' { interface FastifyRequest { adminSession?: { id: string; csrfToken: string }; userSession?: { id: string; userId: string; username: string; csrfToken: string } } } export interface AppOptions { sqlite: Database.Database; adminUsername?: string; adminPasswordHash?: string; sessionSecret?: string; webOrigin?: string } @@ -22,15 +22,34 @@ export async function buildApp(options: AppOptions) { app.get('/health', async () => ({ status: 'ok', time: new Date().toISOString() })); app.get('/api/v1/topics', async (request) => { const query = request.query as Record; - return repo.listTopics({ q: query.q, domain: query.domain, type: query.type, page: Math.max(1, Number(query.page) || 1), pageSize: Math.min(100, Math.max(1, Number(query.pageSize) || 24)) }); + return repo.listTopics({ q: query.q, domain: query.domain, type: query.type, language: query.lang === 'da' ? 'da' : 'en', userId: readUserSession(request, options.sqlite)?.userId, page: Math.max(1, Number(query.page) || 1), pageSize: Math.min(100, Math.max(1, Number(query.pageSize) || 24)) }); }); app.get('/api/v1/topics/:slug', async (request, reply) => { - const result = repo.getTopic((request.params as { slug: string }).slug); + const result = repo.getTopic((request.params as { slug: string }).slug, (request.query as { lang?: string }).lang === 'da' ? 'da' : 'en', readUserSession(request, options.sqlite)?.userId); return result ?? reply.code(404).send({ error: 'Topic not found' }); }); app.get('/api/v1/topics/:slug/revisions', async (request) => repo.revisions((request.params as { slug: string }).slug)); app.get('/api/v1/facets', async () => repo.facets()); + app.post('/api/v1/auth/register', async (request, reply) => { + const body = request.body as { username?: string; password?: string }; const username = (body.username ?? '').trim(); + if (!/^[a-zA-Z0-9_-]{3,32}$/.test(username)) return reply.code(400).send({ error: 'Username must be 3–32 letters, numbers, _ or -' }); + if ((body.password ?? '').length < 8) return reply.code(400).send({ error: 'Password must be at least 8 characters' }); + if (options.sqlite.prepare('SELECT 1 FROM users WHERE username=? COLLATE NOCASE').get(username)) return reply.code(409).send({ error: 'Username is already taken' }); + const userId = randomUUID(); options.sqlite.prepare('INSERT INTO users(id,username,password_hash,created_at) VALUES(?,?,?,?)').run(userId, username, await argon2.hash(body.password!), new Date().toISOString()); + return createUserSession(reply, options.sqlite, userId, username); + }); + app.post('/api/v1/auth/login', async (request, reply) => { + const body = request.body as { username?: string; password?: string }; const user = options.sqlite.prepare('SELECT id,username,password_hash passwordHash FROM users WHERE username=? COLLATE NOCASE').get((body.username ?? '').trim()) as { id: string; username: string; passwordHash: string } | undefined; + if (!user || !(await argon2.verify(user.passwordHash, body.password ?? ''))) return reply.code(401).send({ error: 'Invalid credentials' }); + return createUserSession(reply, options.sqlite, user.id, user.username); + }); + app.get('/api/v1/auth/me', { preHandler: requireUser(options.sqlite, false) }, async (request) => ({ username: request.userSession!.username, csrfToken: request.userSession!.csrfToken })); + app.post('/api/v1/auth/logout', { preHandler: requireUser(options.sqlite) }, async (request, reply) => { options.sqlite.prepare('DELETE FROM user_sessions WHERE id=?').run(request.userSession!.id); reply.clearCookie('tech_library_user', { path: '/' }); return { authenticated: false }; }); + app.get('/api/v1/favorites', { preHandler: requireUser(options.sqlite, false) }, async (request) => repo.listTopics({ pageSize: 100, language: (request.query as { lang?: string }).lang === 'da' ? 'da' : 'en', userId: request.userSession!.userId }).items.filter((item) => item.isFavorite)); + app.put('/api/v1/favorites/:slug', { preHandler: requireUser(options.sqlite) }, async (request, reply) => { const topic = options.sqlite.prepare('SELECT id FROM topics WHERE slug=?').get((request.params as { slug: string }).slug) as { id: string } | undefined; if (!topic) return reply.code(404).send({ error: 'Topic not found' }); options.sqlite.prepare('INSERT OR IGNORE INTO favorites(user_id,topic_id,created_at) VALUES(?,?,?)').run(request.userSession!.userId, topic.id, new Date().toISOString()); return { favorite: true }; }); + app.delete('/api/v1/favorites/:slug', { preHandler: requireUser(options.sqlite) }, async (request) => { options.sqlite.prepare('DELETE FROM favorites WHERE user_id=? AND topic_id=(SELECT id FROM topics WHERE slug=?)').run(request.userSession!.userId, (request.params as { slug: string }).slug); return { favorite: false }; }); + app.post('/api/v1/admin/login', async (request, reply) => { const body = request.body as { username?: string; password?: string }; const username = options.adminUsername ?? process.env.ADMIN_USERNAME ?? 'admin'; @@ -42,7 +61,7 @@ export async function buildApp(options: AppOptions) { const expires = new Date(now.getTime() + 8 * 60 * 60 * 1000); options.sqlite.prepare('DELETE FROM sessions WHERE expires_at < ?').run(now.toISOString()); options.sqlite.prepare('INSERT INTO sessions(id,csrf_token,expires_at,created_at) VALUES(?,?,?,?)').run(id, csrfToken, expires.toISOString(), now.toISOString()); - reply.setCookie('tech_library_session', id, { path: '/', httpOnly: true, sameSite: 'strict', secure: process.env.NODE_ENV === 'production', signed: true, expires }); + reply.setCookie('tech_library_session', id, { path: '/', httpOnly: true, sameSite: 'strict', secure: isSecureCookie(), signed: true, expires }); return { authenticated: true, csrfToken, expiresAt: expires.toISOString() }; }); @@ -78,6 +97,25 @@ export async function buildApp(options: AppOptions) { return app; } +function createUserSession(reply: FastifyReply, sqlite: Database.Database, userId: string, username: string) { + const id = randomUUID(); const csrfToken = randomBytes(24).toString('base64url'); const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + sqlite.prepare('INSERT INTO user_sessions(id,user_id,csrf_token,expires_at,created_at) VALUES(?,?,?,?,?)').run(id, userId, csrfToken, expires.toISOString(), new Date().toISOString()); + reply.setCookie('tech_library_user', id, { path: '/', httpOnly: true, sameSite: 'lax', secure: isSecureCookie(), signed: true, expires }); + return { username, csrfToken, expiresAt: expires.toISOString() }; +} + +function readUserSession(request: FastifyRequest, sqlite: Database.Database) { + const signed = request.unsignCookie(request.cookies.tech_library_user ?? ''); + if (!signed.valid || !signed.value) return undefined; + return sqlite.prepare('SELECT s.id,s.user_id userId,s.csrf_token csrfToken,u.username FROM user_sessions s JOIN users u ON u.id=s.user_id WHERE s.id=? AND s.expires_at>?').get(signed.value, new Date().toISOString()) as { id: string; userId: string; username: string; csrfToken: string } | undefined; +} + +function requireUser(sqlite: Database.Database, csrf = true) { + return async (request: FastifyRequest, reply: FastifyReply) => { const session = readUserSession(request, sqlite); if (!session) return reply.code(401).send({ error: 'Authentication required' }); if (csrf && request.headers['x-csrf-token'] !== session.csrfToken) return reply.code(403).send({ error: 'Invalid CSRF token' }); request.userSession = session; }; +} + +const isSecureCookie = () => (process.env.WEB_ORIGIN ?? '').startsWith('https://'); + function requireAdmin(sqlite: Database.Database, csrf = true) { return async (request: FastifyRequest, reply: FastifyReply) => { const signed = request.unsignCookie(request.cookies.tech_library_session ?? '');