view react_games/backend/routes/todo.ts @ 103:f6d2f2eaaf84

Removed unneeded files.
author June Park <parkjune1995@gmail.com>
date Sat, 03 Jan 2026 10:10:40 -0800
parents fb9bcd3145cb
children
line wrap: on
line source

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