Mercurial
diff react_games/backend/routes/todo.ts @ 37:fb9bcd3145cb
[ReactGames] Few games I made using react just to practice few things.
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Mon, 01 Dec 2025 20:22:47 -0800 |
| parents | |
| children |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/react_games/backend/routes/todo.ts Mon Dec 01 20:22:47 2025 -0800 @@ -0,0 +1,186 @@ +import { Router } from 'express'; +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import crypto from 'node:crypto'; + +type Todo = { id: string; value: string; status: number }; + +/** Idk aynthing about encryption but this seemed fine to me so just going to use this... **/ +const ALGO = 'aes-256-gcm'; +const KEYLEN = 32; +const IVLEN = 12; +const SALTLEN = 16; +const PEPPER = process.env.TODO_PEPPER ?? ''; // optional server-side secret + +type EncryptedPayload = { + v: 1; + kdf: 'scrypt'; + salt: string; // base64 + iv: string; // base64 + tag: string; // base64 + ciphertext: string;// base64 +}; + +// ---- crypto helpers -------------------------------------------------------- + +function kdf(passphrase: string, salt: Buffer): Buffer { + // scryptSync is fine for small payloads; use async/promisified if you prefer. + return crypto.scryptSync(passphrase, salt, KEYLEN); +} + +function encryptJSON(data: unknown, keyword: string): EncryptedPayload { + const salt = crypto.randomBytes(SALTLEN); + const key = kdf(keyword + PEPPER, salt); + const iv = crypto.randomBytes(IVLEN); + + const cipher = crypto.createCipheriv(ALGO, key, iv); + const plaintext = Buffer.from(JSON.stringify(data), 'utf8'); + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const tag = cipher.getAuthTag(); + + return { + v: 1, + kdf: 'scrypt', + salt: salt.toString('base64'), + iv: iv.toString('base64'), + tag: tag.toString('base64'), + ciphertext: ciphertext.toString('base64'), + }; +} + +function decryptJSON(payload: EncryptedPayload, keyword: string): any { + const salt = Buffer.from(payload.salt, 'base64'); + const iv = Buffer.from(payload.iv, 'base64'); + const tag = Buffer.from(payload.tag, 'base64'); + const ciphertext = Buffer.from(payload.ciphertext, 'base64'); + + const key = kdf(keyword + PEPPER, salt); + const decipher = crypto.createDecipheriv(ALGO, key, iv); + decipher.setAuthTag(tag); + + const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + return JSON.parse(plaintext.toString('utf8')); +} + +// ---- path helper: derive a file path from keyword -------------------------- + +function pathForKeyword(baseDir: string, keyword: string): string { + // Hash the keyword so you don’t leak it in the filename and to avoid FS-unsafe chars. + const h = crypto.createHash('sha256').update(keyword, 'utf8').digest('hex'); + // Optional sharding to keep directories small: + return path.join(baseDir, h.slice(0, 2), `${h}.json`); +} + +// ---- router ---------------------------------------------------------------- +const getInitialTodos = () => [ + { + id: crypto.randomUUID(), + value: "Hello from TODO", + status: 0, + } +] +export function createTodoRouter(dataFileOrDir: string) { + const router = Router(); + + // Support both a file path (old) or a directory (new). We’ll store under that directory. + const baseDir = path.extname(dataFileOrDir) + ? path.dirname(dataFileOrDir) + : dataFileOrDir; + + const requireKeyword = (req: any): string => { + const key = (typeof req.query.key === 'string' && req.query.key) || req.header('x-keyword'); + if (typeof key !== 'string' || !key.trim()) { + throw Object.assign(new Error('Missing ?key= query or x-keyword header'), { status: 400 }); + } + return key.trim(); + }; + + const readTodos = async (userKeyword: string): Promise<Todo[]> => { + const filePath = pathForKeyword(baseDir, userKeyword); + try { + const raw = await fs.readFile(filePath, 'utf8'); + const parsed = JSON.parse(raw); + + // If this looks like an encrypted payload, decrypt; else treat as plaintext array for backward compat. + if (parsed && parsed.ciphertext && parsed.iv && parsed.tag && parsed.salt) { + return decryptJSON(parsed as EncryptedPayload, userKeyword) as Todo[]; + } + // fallback (legacy plaintext) + return parsed as Todo[]; + } catch (e: any) { + if (e?.code === 'ENOENT') { + const initial = getInitialTodos(); + await writeTodos(initial, userKeyword); // create the file immediately + return initial; + } + throw e; + } + }; + + const writeTodos = async (todos: Todo[], userKeyword: string) => { + const filePath = pathForKeyword(baseDir, userKeyword); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + const payload = encryptJSON(todos, userKeyword); + await fs.writeFile(filePath, JSON.stringify(payload, null, 2), { encoding: 'utf8', mode: 0o600 }); + }; + + router.get('/', async (req, res) => { + try { + const keyword = requireKeyword(req); + const todos = await readTodos(keyword); + res.json({ todos }); + } catch (err: any) { + res.status(err?.status || 500).json({ error: err?.message || 'Internal error' }); + } + }); + + router.post('/', async (req, res) => { + try { + const keyword = requireKeyword(req); + + const body = req.body?.newTodo; + if (!body || typeof body.value !== 'string' || typeof body.status !== 'number') { + return res.status(400).json({ newTodo: null }); + } + + const todos = await readTodos(keyword); + const todo: Todo = { + id: body.id ?? crypto.randomUUID(), + value: body.value, + status: body.status, + }; + todos.push(todo); + + // Optional chaos test: + // await new Promise(r => setTimeout(r, 1000)); + // if (Math.random() > 0.5) return res.status(400).json({ newTodo: null }); + + await writeTodos(todos, keyword); + res.status(200).json({ newTodo: todo, todos }); // keep your shape if you prefer + } catch (err: any) { + res.status(err?.status || 500).json({ error: err?.message || 'Internal error' }); + } + }); + + router.patch('/', async (req, res) => { + try { + const keyword = requireKeyword(req); + + const body = req.body; + if (!body || typeof body.todoId !== 'string' || typeof body.status !== 'number') { + return res.status(400).json({ newTodo: null }); + } + + const todos = (await readTodos(keyword)).map( + (todo) => todo.id === body.todoId ? {...todo, status: body.status } : todo); + + await writeTodos(todos, keyword); + res.status(200).json({ todos }); + } catch (err: any) { + res.status(err?.status || 500).json({ error: err?.message || 'Internal error' }); + } + }); + + + return router; +}