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 }