← back to Designer Portfolio Pages

src/index.ts

209 lines

import express from "express";
import cookieParser from "cookie-parser";
import basicAuth from "express-basic-auth";
import path from "node:path";
import { fileURLToPath } from "node:url";
import rateLimit from "express-rate-limit";

import { loadEnv } from "./env.js";
import { openDb, getPortfolioBySlug, insertPortfolio } from "./db.js";
import { CreatePortfolioSchema } from "./schemas.js";
import {
  createSlug,
  hashPasswordIfProvided,
  isValidPortfolioAuthCookieValue,
  makePortfolioAuthCookieValue,
  verifyPassword
} from "./auth.js";
import { getPatternsByIds, getPatternsByQuery } from "./stevesTool.js";
import { maybeGenerateAiIntro } from "./aiIntro.js";

const env = loadEnv();
const db = openDb(env.SQLITE_PATH);

const app = express();

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

app.set("view engine", "ejs");
app.set("views", path.join(__dirname, "views"));

app.use(express.json({ limit: "1mb" }));
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
app.use("/public", express.static(path.join(__dirname, "..", "public")));

// Global basic auth — protects builder & API per CLAUDE.md security rules.
// Portfolio share links (/p/:slug) are exempted so clients can view without credentials.
const auth = basicAuth({
  users: { admin: "DWSecure2024!" },
  challenge: true,
  realm: "Designer Mood Board"
});

// Apply auth to everything EXCEPT /p/ share links and static assets
app.use((req, res, next) => {
  if (req.path.startsWith("/p/") || req.path.startsWith("/public/")) {
    return next();
  }
  return auth(req, res, next);
});

const createPortfolioLimiter = rateLimit({
  windowMs: 60_000,
  limit: 30
});

const passwordLimiter = rateLimit({
  windowMs: 60_000,
  limit: 20
});

// --- Routes ---

app.get("/", (_req, res) => res.redirect("/builder"));

app.get("/builder", (_req, res) => {
  res.render("builder", { baseUrl: env.BASE_URL });
});

app.get("/api/patterns/search", async (req, res) => {
  try {
    const q = String(req.query.q ?? "");
    const patterns = await getPatternsByQuery(q);
    res.json({ patterns: patterns.slice(0, 40) });
  } catch (err: any) {
    res.status(500).json({ error: err?.message ?? "Unknown error" });
  }
});

app.post("/api/portfolios", createPortfolioLimiter, async (req, res) => {
  const parsed = CreatePortfolioSchema.safeParse(req.body);
  if (!parsed.success) {
    return res.status(400).json({ error: "Invalid request", details: parsed.error.issues });
  }

  try {
    const input = parsed.data;

    const patterns = await getPatternsByIds(input.patternIds);

    if (!patterns.length) {
      return res.status(400).json({ error: "No patterns found for provided IDs." });
    }

    const nowIso = new Date().toISOString();
    const slug = createSlug(9);

    const passwordHash = await hashPasswordIfProvided(input.password);

    const aiIntro =
      input.generateAiIntro
        ? await maybeGenerateAiIntro({
            anthropicApiKey: env.ANTHROPIC_API_KEY,
            anthropicModel: env.ANTHROPIC_MODEL,
            designerName: input.contact.name,
            businessName: input.contact.business || undefined,
            title: input.title || undefined,
            patterns
          })
        : null;

    insertPortfolio(db, {
      slug,
      title: input.title || undefined,
      createdAtIso: nowIso,
      updatedAtIso: nowIso,
      contact: {
        name: input.contact.name,
        business: input.contact.business || undefined,
        email: input.contact.email,
        phone: input.contact.phone || undefined,
        website: input.contact.website || undefined
      },
      passwordHash,
      patterns,
      aiIntro
    });

    const shareUrl = `${env.BASE_URL}/p/${slug}`;
    res.json({ slug, shareUrl });
  } catch (err: any) {
    res.status(500).json({ error: err?.message ?? "Unknown error" });
  }
});

app.get("/p/:slug", (req, res) => {
  const slug = String(req.params.slug);
  const portfolio = getPortfolioBySlug(db, slug);

  if (!portfolio) {
    return res.status(404).send("Not found");
  }

  const needsPassword = Boolean(portfolio.passwordHash);
  const authCookieName = `pp_auth_${slug}`;
  const cookieValue = req.cookies?.[authCookieName] as string | undefined;
  const isAuthed = !needsPassword || isValidPortfolioAuthCookieValue(env.COOKIE_SECRET, slug, cookieValue);

  if (needsPassword && !isAuthed) {
    return res.render("password", {
      slug,
      title: portfolio.title || "Portfolio",
      business: portfolio.contact.business || "",
      error: ""
    });
  }

  return res.render("portfolio", {
    portfolio,
    baseUrl: env.BASE_URL
  });
});

app.post("/p/:slug/auth", passwordLimiter, async (req, res) => {
  const slug = String(req.params.slug);
  const portfolio = getPortfolioBySlug(db, slug);

  if (!portfolio) {
    return res.status(404).send("Not found");
  }
  if (!portfolio.passwordHash) {
    return res.redirect(`/p/${slug}`);
  }

  const password = String(req.body.password ?? "");
  const ok = await verifyPassword(password, portfolio.passwordHash);

  if (!ok) {
    return res.status(401).render("password", {
      slug,
      title: portfolio.title || "Portfolio",
      business: portfolio.contact.business || "",
      error: "Incorrect password."
    });
  }

  const authCookieName = `pp_auth_${slug}`;
  const value = makePortfolioAuthCookieValue(env.COOKIE_SECRET, slug);

  res.cookie(authCookieName, value, {
    httpOnly: true,
    sameSite: "lax",
    secure: false,
    maxAge: 1000 * 60 * 60 * 24 * 30
  });

  return res.redirect(`/p/${slug}`);
});

app.get("/share/:slug", (req, res) => {
  const slug = String(req.params.slug);
  res.render("share", { shareUrl: `${env.BASE_URL}/p/${slug}` });
});

app.listen(Number(env.PORT), () => {
  console.log(`Designer portfolio pages running on ${env.BASE_URL}`);
});