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;
+}