372 lines
20 KiB
JavaScript
372 lines
20 KiB
JavaScript
// Studio Notebook — bms-backbone server (Postgres + Prisma + OTM-account auth)
|
||
import { createServer } from "node:http";
|
||
import { readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs";
|
||
import { join, dirname, extname } from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
import { randomUUID, createHmac, createHash, timingSafeEqual } from "node:crypto";
|
||
import { PrismaClient } from "@prisma/client";
|
||
import bcrypt from "bcryptjs";
|
||
import { APP_VERSION } from "./lib/version.mjs";
|
||
|
||
const ROOT = dirname(fileURLToPath(import.meta.url));
|
||
const DATA = process.env.DATA_DIR || join(ROOT, "data");
|
||
const PHOTOS = join(DATA, "photos");
|
||
const FILES = join(DATA, "files");
|
||
mkdirSync(PHOTOS, { recursive: true });
|
||
mkdirSync(FILES, { recursive: true });
|
||
|
||
const db = new PrismaClient();
|
||
const PORT = Number(process.env.PORT || 3000);
|
||
const SESSION_SECRET =
|
||
process.env.SESSION_SECRET ||
|
||
createHash("sha256").update("bms:" + (process.env.DATABASE_URL || "dev")).digest("hex");
|
||
const SESSION_DAYS = 30;
|
||
|
||
// ── helpers ──────────────────────────────────────────────────────
|
||
const fmtDate = (d) => {
|
||
if (!d) return null;
|
||
const p = (n) => String(n).padStart(2, "0");
|
||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||
};
|
||
|
||
// session cookie: base64(payload).hmac
|
||
const sign = (payload) => {
|
||
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||
const mac = createHmac("sha256", SESSION_SECRET).update(body).digest("base64url");
|
||
return `${body}.${mac}`;
|
||
};
|
||
const verify = (token) => {
|
||
if (!token || !token.includes(".")) return null;
|
||
const [body, mac] = token.split(".");
|
||
const expect = createHmac("sha256", SESSION_SECRET).update(body).digest("base64url");
|
||
try {
|
||
if (!timingSafeEqual(Buffer.from(mac), Buffer.from(expect))) return null;
|
||
const p = JSON.parse(Buffer.from(body, "base64url").toString());
|
||
return p.exp > Date.now() ? p : null;
|
||
} catch { return null; }
|
||
};
|
||
const cookieOf = (req) =>
|
||
Object.fromEntries((req.headers.cookie || "").split(";").map((c) => c.trim().split("=").map(decodeURIComponent)).filter((p) => p[0]));
|
||
|
||
// ── serializers (keep the frontend's field names) ────────────────
|
||
const sNote = (n) => ({ id: n.id, text: n.text, tags: n.tags, created: fmtDate(n.created) });
|
||
const sTodo = (t) => ({ id: t.id, text: t.text, done: t.done ? 1 : 0, started: t.started ? 1 : 0, parent_id: t.parentId, created: fmtDate(t.created) });
|
||
const sShopping = (t) => ({ id: t.id, text: t.text, done: t.done ? 1 : 0, created: fmtDate(t.created) });
|
||
const sGlaze = (g) => ({ id: g.id, name: g.name, kind: g.kind, cone: g.cone, atmosphere: g.atmosphere,
|
||
surface: g.surface, source: g.source, sg: g.sg, swatch: g.swatch, created: fmtDate(g.created) });
|
||
const sMat = (m) => ({ id: m.id, glaze_id: m.glazeId, name: m.name, pct: m.pct, addition: m.addition ? 1 : 0 });
|
||
const sJournal = (j) => ({ id: j.id, glaze_id: j.glazeId, text: j.text, created: fmtDate(j.created) });
|
||
const sFiring = (f) => ({ id: f.id, type: f.type, cone: f.cone, schedule: f.schedule, status: f.status,
|
||
result_notes: f.resultNotes, started: fmtDate(f.started), unloaded: fmtDate(f.unloaded) });
|
||
const sItem = (i) => ({ id: i.id, firing_id: i.firingId, piece: i.piece, clay: i.clay, glaze: i.glaze, rating: i.rating, note: i.note });
|
||
const sPhoto = (p) => ({ id: p.id, entity: p.entity, entity_id: p.entityId, filename: p.filename, created: fmtDate(p.created) });
|
||
const sFile = (f) => ({ id: f.id, entity: f.entity, entity_id: f.entityId, stored: f.stored, name: f.name,
|
||
tag: f.tag, size: f.size, created: fmtDate(f.created) });
|
||
const sProduct = (p) => ({ id: p.id, name: p.name, kind: p.kind, category: p.category, price: p.price,
|
||
cost: p.cost, qty: p.qty, status: p.status, notes: p.notes, created: fmtDate(p.created) });
|
||
const sSale = (s) => ({ id: s.id, product_id: s.productId, qty: s.qty, price: s.price, cost: s.cost, created: fmtDate(s.created) });
|
||
const sSupplier = (s) => ({ id: s.id, name: s.name, kind: s.kind, distance: s.distance, url: s.url,
|
||
notes: s.notes, search_url: s.searchUrl, created: fmtDate(s.created) });
|
||
|
||
// ── the "plays well with" evidence table (unload ratings only) ───
|
||
async function compatibility(glazeName) {
|
||
const rows = await db.firingItem.findMany({
|
||
where: { glaze: glazeName, NOT: { rating: "" }, clay: { not: "" }, firing: { status: "unloaded" } },
|
||
});
|
||
const grouped = {};
|
||
for (const r of rows) {
|
||
const key = `${r.clay} |