Files
2026-07-12 17:16:34 -04:00

149 lines
6.5 KiB
JavaScript

import { get, put } from "@vercel/blob";
import crypto from "node:crypto";
const INDEX_PATH = process.env.PMA_ACCOUNTS_INDEX_PATH || "accounts/index.json";
const PBKDF2_ITERATIONS = 210000;
function configured() {
return Boolean(process.env.BLOB_READ_WRITE_TOKEN || process.env.VERCEL_OIDC_TOKEN);
}
async function readJson(path, fallback) {
const blob = await get(path, { access: "private" });
if (!blob || blob.statusCode !== 200 || !blob.stream) return fallback;
const text = await new Response(blob.stream).text();
return text ? JSON.parse(text) : fallback;
}
async function writeJson(path, data) {
await put(path, JSON.stringify(data), {
access: "private",
allowOverwrite: true,
contentType: "application/json",
cacheControlMaxAge: 0,
});
}
function normalizeName(name) {
return String(name || "").trim().replace(/\s+/g, " ").slice(0, 48);
}
function accountKey(name) {
return normalizeName(name).toLowerCase();
}
function hashPassword(password, salt = crypto.randomBytes(16).toString("hex")) {
const hash = crypto.pbkdf2Sync(String(password), salt, PBKDF2_ITERATIONS, 32, "sha256").toString("hex");
return { salt, hash };
}
function timingSafeEqualHex(a, b) {
const ab = Buffer.from(String(a || ""), "hex");
const bb = Buffer.from(String(b || ""), "hex");
return ab.length === bb.length && crypto.timingSafeEqual(ab, bb);
}
function tokenSecret() {
return process.env.ACCOUNT_SESSION_SECRET || process.env.SESSION_SECRET || process.env.BLOB_READ_WRITE_TOKEN || "local-dev-only";
}
function makeSessionToken(accountId) {
const issued = Date.now();
const payload = `${accountId}.${issued}`;
const sig = crypto.createHmac("sha256", tokenSecret()).update(payload).digest("hex");
return `${payload}.${sig}`;
}
function verifySessionToken(accountId, token) {
const parts = String(token || "").split(".");
if (parts.length !== 3 || parts[0] !== accountId) return false;
const issued = Number(parts[1]);
if (!Number.isFinite(issued) || Date.now() - issued > 1000 * 60 * 60 * 24 * 30) return false;
const payload = `${parts[0]}.${parts[1]}`;
const expected = crypto.createHmac("sha256", tokenSecret()).update(payload).digest("hex");
return timingSafeEqualHex(parts[2], expected);
}
function sanitizeAccount(account) {
const clean = account && typeof account === "object" ? JSON.parse(JSON.stringify(account)) : {};
clean.id = String(clean.id || "");
clean.name = normalizeName(clean.name || "Paper Trader");
clean.cash = Number.isFinite(Number(clean.cash)) ? Number(clean.cash) : 10000;
clean.starting_balance = Number.isFinite(Number(clean.starting_balance)) ? Number(clean.starting_balance) : 10000;
clean.positions = Array.isArray(clean.positions) ? clean.positions.slice(0, 500) : [];
clean.history = Array.isArray(clean.history) ? clean.history.slice(-1000) : [];
clean.snapshots = Array.isArray(clean.snapshots) ? clean.snapshots.slice(-2000) : [];
clean.passwordHash = "cloud";
clean.cloudBacked = true;
return clean;
}
function accountPath(accountId) {
return `accounts/${accountId}.json`;
}
export default async function handler(req, res) {
res.setHeader("Cache-Control", "no-store");
try {
if (!configured()) return res.status(503).json({ ok: false, error: "Account storage is not configured" });
if (req.method !== "POST") {
res.setHeader("Allow", "POST");
return res.status(405).json({ ok: false, error: "Method not allowed" });
}
const body = typeof req.body === "string" ? JSON.parse(req.body || "{}") : (req.body || {});
const action = String(body.action || "");
const index = await readJson(INDEX_PATH, { version: 1, accounts: {} });
if (action === "create") {
const name = normalizeName(body.name);
const password = String(body.password || "");
if (name.length < 2) return res.status(400).json({ ok: false, error: "Account name is too short" });
if (password.length < 4) return res.status(400).json({ ok: false, error: "Use at least 4 characters for a cloud paper password" });
const key = accountKey(name);
if (index.accounts[key]) return res.status(409).json({ ok: false, error: "That paper account name already exists" });
const id = `acct_${crypto.randomUUID().replace(/-/g, "").slice(0, 20)}`;
const passwordRecord = hashPassword(password);
const now = new Date().toISOString();
index.accounts[key] = { id, name, created_at: now, updated_at: now, password: passwordRecord };
const account = sanitizeAccount(body.account || {});
account.id = id;
account.name = name;
account.history = [{ date: now.slice(0, 10), action: "CREATE", detail: "Created cloud-synced paper account." }].concat(account.history || []).slice(-1000);
await writeJson(INDEX_PATH, index);
await writeJson(accountPath(id), { version: 1, updated_at: now, account });
return res.status(200).json({ ok: true, account, session_token: makeSessionToken(id) });
}
if (action === "login") {
const key = accountKey(body.name);
const record = index.accounts[key];
if (!record) return res.status(404).json({ ok: false, error: "Paper account not found" });
const passwordRecord = record.password || {};
const attempt = hashPassword(String(body.password || ""), passwordRecord.salt);
if (!timingSafeEqualHex(attempt.hash, passwordRecord.hash)) return res.status(401).json({ ok: false, error: "Incorrect paper account password" });
const saved = await readJson(accountPath(record.id), null);
const account = sanitizeAccount(saved && saved.account ? saved.account : {});
account.id = record.id;
account.name = record.name;
return res.status(200).json({ ok: true, account, session_token: makeSessionToken(record.id) });
}
if (action === "save") {
const accountId = String(body.account_id || "");
if (!verifySessionToken(accountId, body.session_token)) return res.status(401).json({ ok: false, error: "Cloud paper session expired. Log in again." });
const account = sanitizeAccount(body.account || {});
account.id = accountId;
const now = new Date().toISOString();
await writeJson(accountPath(accountId), { version: 1, updated_at: now, account });
return res.status(200).json({ ok: true, updated_at: now });
}
return res.status(400).json({ ok: false, error: "Unknown account action" });
} catch (err) {
const message = err && err.message ? err.message : "Account request failed";
return res.status(500).json({ ok: false, error: message });
}
}