Mercurial
comparison 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 |
comparison
equal
deleted
inserted
replaced
| 36:84672efec192 | 37:fb9bcd3145cb |
|---|---|
| 1 import { Router } from 'express'; | |
| 2 import * as fs from 'node:fs/promises'; | |
| 3 import path from 'node:path'; | |
| 4 import crypto from 'node:crypto'; | |
| 5 | |
| 6 type Todo = { id: string; value: string; status: number }; | |
| 7 | |
| 8 /** Idk aynthing about encryption but this seemed fine to me so just going to use this... **/ | |
| 9 const ALGO = 'aes-256-gcm'; | |
| 10 const KEYLEN = 32; | |
| 11 const IVLEN = 12; | |
| 12 const SALTLEN = 16; | |
| 13 const PEPPER = process.env.TODO_PEPPER ?? ''; // optional server-side secret | |
| 14 | |
| 15 type EncryptedPayload = { | |
| 16 v: 1; | |
| 17 kdf: 'scrypt'; | |
| 18 salt: string; // base64 | |
| 19 iv: string; // base64 | |
| 20 tag: string; // base64 | |
| 21 ciphertext: string;// base64 | |
| 22 }; | |
| 23 | |
| 24 // ---- crypto helpers -------------------------------------------------------- | |
| 25 | |
| 26 function kdf(passphrase: string, salt: Buffer): Buffer { | |
| 27 // scryptSync is fine for small payloads; use async/promisified if you prefer. | |
| 28 return crypto.scryptSync(passphrase, salt, KEYLEN); | |
| 29 } | |
| 30 | |
| 31 function encryptJSON(data: unknown, keyword: string): EncryptedPayload { | |
| 32 const salt = crypto.randomBytes(SALTLEN); | |
| 33 const key = kdf(keyword + PEPPER, salt); | |
| 34 const iv = crypto.randomBytes(IVLEN); | |
| 35 | |
| 36 const cipher = crypto.createCipheriv(ALGO, key, iv); | |
| 37 const plaintext = Buffer.from(JSON.stringify(data), 'utf8'); | |
| 38 const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); | |
| 39 const tag = cipher.getAuthTag(); | |
| 40 | |
| 41 return { | |
| 42 v: 1, | |
| 43 kdf: 'scrypt', | |
| 44 salt: salt.toString('base64'), | |
| 45 iv: iv.toString('base64'), | |
| 46 tag: tag.toString('base64'), | |
| 47 ciphertext: ciphertext.toString('base64'), | |
| 48 }; | |
| 49 } | |
| 50 | |
| 51 function decryptJSON(payload: EncryptedPayload, keyword: string): any { | |
| 52 const salt = Buffer.from(payload.salt, 'base64'); | |
| 53 const iv = Buffer.from(payload.iv, 'base64'); | |
| 54 const tag = Buffer.from(payload.tag, 'base64'); | |
| 55 const ciphertext = Buffer.from(payload.ciphertext, 'base64'); | |
| 56 | |
| 57 const key = kdf(keyword + PEPPER, salt); | |
| 58 const decipher = crypto.createDecipheriv(ALGO, key, iv); | |
| 59 decipher.setAuthTag(tag); | |
| 60 | |
| 61 const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]); | |
| 62 return JSON.parse(plaintext.toString('utf8')); | |
| 63 } | |
| 64 | |
| 65 // ---- path helper: derive a file path from keyword -------------------------- | |
| 66 | |
| 67 function pathForKeyword(baseDir: string, keyword: string): string { | |
| 68 // Hash the keyword so you don’t leak it in the filename and to avoid FS-unsafe chars. | |
| 69 const h = crypto.createHash('sha256').update(keyword, 'utf8').digest('hex'); | |
| 70 // Optional sharding to keep directories small: | |
| 71 return path.join(baseDir, h.slice(0, 2), `${h}.json`); | |
| 72 } | |
| 73 | |
| 74 // ---- router ---------------------------------------------------------------- | |
| 75 const getInitialTodos = () => [ | |
| 76 { | |
| 77 id: crypto.randomUUID(), | |
| 78 value: "Hello from TODO", | |
| 79 status: 0, | |
| 80 } | |
| 81 ] | |
| 82 export function createTodoRouter(dataFileOrDir: string) { | |
| 83 const router = Router(); | |
| 84 | |
| 85 // Support both a file path (old) or a directory (new). We’ll store under that directory. | |
| 86 const baseDir = path.extname(dataFileOrDir) | |
| 87 ? path.dirname(dataFileOrDir) | |
| 88 : dataFileOrDir; | |
| 89 | |
| 90 const requireKeyword = (req: any): string => { | |
| 91 const key = (typeof req.query.key === 'string' && req.query.key) || req.header('x-keyword'); | |
| 92 if (typeof key !== 'string' || !key.trim()) { | |
| 93 throw Object.assign(new Error('Missing ?key= query or x-keyword header'), { status: 400 }); | |
| 94 } | |
| 95 return key.trim(); | |
| 96 }; | |
| 97 | |
| 98 const readTodos = async (userKeyword: string): Promise<Todo[]> => { | |
| 99 const filePath = pathForKeyword(baseDir, userKeyword); | |
| 100 try { | |
| 101 const raw = await fs.readFile(filePath, 'utf8'); | |
| 102 const parsed = JSON.parse(raw); | |
| 103 | |
| 104 // If this looks like an encrypted payload, decrypt; else treat as plaintext array for backward compat. | |
| 105 if (parsed && parsed.ciphertext && parsed.iv && parsed.tag && parsed.salt) { | |
| 106 return decryptJSON(parsed as EncryptedPayload, userKeyword) as Todo[]; | |
| 107 } | |
| 108 // fallback (legacy plaintext) | |
| 109 return parsed as Todo[]; | |
| 110 } catch (e: any) { | |
| 111 if (e?.code === 'ENOENT') { | |
| 112 const initial = getInitialTodos(); | |
| 113 await writeTodos(initial, userKeyword); // create the file immediately | |
| 114 return initial; | |
| 115 } | |
| 116 throw e; | |
| 117 } | |
| 118 }; | |
| 119 | |
| 120 const writeTodos = async (todos: Todo[], userKeyword: string) => { | |
| 121 const filePath = pathForKeyword(baseDir, userKeyword); | |
| 122 await fs.mkdir(path.dirname(filePath), { recursive: true }); | |
| 123 const payload = encryptJSON(todos, userKeyword); | |
| 124 await fs.writeFile(filePath, JSON.stringify(payload, null, 2), { encoding: 'utf8', mode: 0o600 }); | |
| 125 }; | |
| 126 | |
| 127 router.get('/', async (req, res) => { | |
| 128 try { | |
| 129 const keyword = requireKeyword(req); | |
| 130 const todos = await readTodos(keyword); | |
| 131 res.json({ todos }); | |
| 132 } catch (err: any) { | |
| 133 res.status(err?.status || 500).json({ error: err?.message || 'Internal error' }); | |
| 134 } | |
| 135 }); | |
| 136 | |
| 137 router.post('/', async (req, res) => { | |
| 138 try { | |
| 139 const keyword = requireKeyword(req); | |
| 140 | |
| 141 const body = req.body?.newTodo; | |
| 142 if (!body || typeof body.value !== 'string' || typeof body.status !== 'number') { | |
| 143 return res.status(400).json({ newTodo: null }); | |
| 144 } | |
| 145 | |
| 146 const todos = await readTodos(keyword); | |
| 147 const todo: Todo = { | |
| 148 id: body.id ?? crypto.randomUUID(), | |
| 149 value: body.value, | |
| 150 status: body.status, | |
| 151 }; | |
| 152 todos.push(todo); | |
| 153 | |
| 154 // Optional chaos test: | |
| 155 // await new Promise(r => setTimeout(r, 1000)); | |
| 156 // if (Math.random() > 0.5) return res.status(400).json({ newTodo: null }); | |
| 157 | |
| 158 await writeTodos(todos, keyword); | |
| 159 res.status(200).json({ newTodo: todo, todos }); // keep your shape if you prefer | |
| 160 } catch (err: any) { | |
| 161 res.status(err?.status || 500).json({ error: err?.message || 'Internal error' }); | |
| 162 } | |
| 163 }); | |
| 164 | |
| 165 router.patch('/', async (req, res) => { | |
| 166 try { | |
| 167 const keyword = requireKeyword(req); | |
| 168 | |
| 169 const body = req.body; | |
| 170 if (!body || typeof body.todoId !== 'string' || typeof body.status !== 'number') { | |
| 171 return res.status(400).json({ newTodo: null }); | |
| 172 } | |
| 173 | |
| 174 const todos = (await readTodos(keyword)).map( | |
| 175 (todo) => todo.id === body.todoId ? {...todo, status: body.status } : todo); | |
| 176 | |
| 177 await writeTodos(todos, keyword); | |
| 178 res.status(200).json({ todos }); | |
| 179 } catch (err: any) { | |
| 180 res.status(err?.status || 500).json({ error: err?.message || 'Internal error' }); | |
| 181 } | |
| 182 }); | |
| 183 | |
| 184 | |
| 185 return router; | |
| 186 } |