Files
DebbieWindlerSeamstress/src/server.js
2026-07-21 11:48:50 -05:00

1094 lines
67 KiB
JavaScript

const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const express = require("express");
const session = require("express-session");
const bcrypt = require("bcryptjs");
const multer = require("multer");
const sharp = require("sharp");
const nodemailer = require("nodemailer");
const archiver = require("archiver");
const config = require("./config");
const {
db,
setSetting,
getSetting,
allSettings,
ensureCategory,
saveRevision,
audit
} = require("./database");
const {
escapeHtml,
cleanRichText,
makeSlug,
token,
now,
bytes,
checkbox,
listFromText,
safeJson
} = require("./utils");
const {
publicLayout,
adminLayout,
authLayout,
setting,
button,
itemCard,
serviceCard,
contactForm,
sectionHtml,
mediaOption,
mediaGrid
} = require("./render");
const app = express();
app.set("trust proxy", 1);
class SQLiteSessionStore extends session.Store {
get(sid, cb) {
try {
const row = db.prepare("SELECT sess FROM sessions WHERE sid = ? AND expired_at > ?").get(sid, Date.now());
cb(null, row ? JSON.parse(row.sess) : null);
} catch (error) {
cb(error);
}
}
set(sid, sess, cb) {
try {
const maxAge = sess.cookie?.maxAge || 1000 * 60 * 60 * 8;
db.prepare(`
INSERT INTO sessions (sid, sess, expired_at)
VALUES (?, ?, ?)
ON CONFLICT(sid) DO UPDATE SET sess = excluded.sess, expired_at = excluded.expired_at
`).run(sid, JSON.stringify(sess), Date.now() + maxAge);
cb(null);
} catch (error) {
cb(error);
}
}
destroy(sid, cb) {
try {
db.prepare("DELETE FROM sessions WHERE sid = ?").run(sid);
cb(null);
} catch (error) {
cb(error);
}
}
}
app.use(express.urlencoded({ extended: true, limit: "1mb" }));
app.use(express.json({ limit: "1mb" }));
app.use(session({
store: new SQLiteSessionStore(),
name: "dws.sid",
secret: config.sessionSecret,
resave: false,
saveUninitialized: false,
rolling: true,
cookie: {
httpOnly: true,
sameSite: "lax",
secure: config.production,
maxAge: 1000 * 60 * 60 * 8
}
}));
app.use((req, res, next) => {
if (!req.session.csrfToken) req.session.csrfToken = token(16);
res.locals.csrfToken = req.session.csrfToken;
next();
});
app.use((req, res, next) => {
if (["POST", "PUT", "PATCH", "DELETE"].includes(req.method)) {
const submitted = req.body?._csrf || req.headers["x-csrf-token"];
if (!submitted || submitted !== req.session.csrfToken) return res.status(403).send("Security check failed. Please go back and try again.");
}
next();
});
app.use("/css", express.static(path.join(config.root, "public/css"), { maxAge: "7d" }));
app.use("/js", express.static(path.join(config.root, "public/js"), { maxAge: "7d" }));
app.use("/uploads", express.static(config.uploadDir, { maxAge: "30d", fallthrough: true }));
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 8 * 1024 * 1024, files: 10 },
fileFilter(req, file, cb) {
const ok = ["image/jpeg", "image/png", "image/webp"].includes(file.mimetype);
cb(ok ? null : new Error("Only JPG, PNG, and WebP images are allowed."), ok);
}
});
function adminCount() {
return db.prepare("SELECT COUNT(*) AS count FROM administrators").get().count;
}
function currentAdmin(req) {
if (!req.session.adminId) return null;
return db.prepare("SELECT id, name, email, role FROM administrators WHERE id = ?").get(req.session.adminId);
}
function requireAdmin(req, res, next) {
const admin = currentAdmin(req);
if (!admin) return res.redirect("/admin/login");
req.admin = admin;
next();
}
function navLinks() {
return db.prepare("SELECT * FROM navigation WHERE is_published = 1 ORDER BY display_order, id").all();
}
function mediaRows() {
return db.prepare("SELECT * FROM media ORDER BY created_at DESC").all();
}
function pageBySlug(slug) {
return db.prepare("SELECT * FROM pages WHERE slug = ?").get(slug);
}
function pageSections(pageId, includeHidden = false) {
return db.prepare(`
SELECT ps.*, m.url image_url, m.alt_text image_alt, bm.url background_url
FROM page_sections ps
LEFT JOIN media m ON m.id = ps.image_id
LEFT JOIN media bm ON bm.id = ps.background_image_id
WHERE ps.page_id = ? ${includeHidden ? "" : "AND ps.is_published = 1"}
ORDER BY ps.display_order, ps.id
`).all(pageId);
}
function publicItems(where = "", params = []) {
return db.prepare(`
SELECT i.*, c.name category_name, m.url image_url, m.alt_text image_alt
FROM items i
LEFT JOIN categories c ON c.id = i.category_id
LEFT JOIN media m ON m.id = i.main_image_id
WHERE i.is_published = 1 AND i.is_archived = 0 ${where}
ORDER BY i.name COLLATE NOCASE
`).all(...params);
}
function publicServices() {
return db.prepare(`
SELECT s.*, m.url image_url, m.alt_text image_alt
FROM services s
LEFT JOIN media m ON m.id = s.image_id
WHERE s.is_published = 1
ORDER BY s.display_order, s.name
`).all();
}
function renderPublic(req, res, opts) {
const settings = allSettings();
if (maintenanceActive(req, settings)) {
return res.status(503).send(publicLayout({
title: "Website Maintenance",
description: "The website is temporarily in maintenance mode.",
settings,
nav: navLinks(),
csrfToken: res.locals.csrfToken,
currentPath: req.path,
body: maintenancePage(settings)
}));
}
res.send(publicLayout({
...opts,
settings,
nav: navLinks(),
csrfToken: res.locals.csrfToken,
currentPath: req.path
}));
}
function renderAdmin(req, res, title, active, body) {
res.send(adminLayout({ title, active, body, admin: req.admin, csrfToken: res.locals.csrfToken }));
}
function maintenanceActive(req, settings) {
return setting(settings, "maintenance.enabled", "false") === "true" && !req.session.adminId && !req.path.startsWith("/admin") && req.path !== "/healthz";
}
function maintenancePage(settings) {
const artwork = setting(settings, "maintenance.artwork", "");
return `<section class="maintenance">
${artwork ? `<img src="${escapeHtml(artwork)}" alt="">` : ""}
<p class="eyebrow">Website maintenance</p>
<h1>${escapeHtml(setting(settings, "business.name", "Debbie Windler Seamstress"))}</h1>
<p>${escapeHtml(setting(settings, "maintenance.message", "The website is getting a careful refresh. Please check back soon."))}</p>
${setting(settings, "maintenance.return_note", "") ? `<p>${escapeHtml(setting(settings, "maintenance.return_note"))}</p>` : ""}
</section>`;
}
function ipHash(req) {
return crypto.createHash("sha256").update(`${req.ip}|${config.sessionSecret}`).digest("hex");
}
function rateLimit(key, limit, windowMs) {
const resetAt = Date.now() + windowMs;
const row = db.prepare("SELECT * FROM rate_limits WHERE key = ?").get(key);
if (!row || row.reset_at < Date.now()) {
db.prepare("INSERT INTO rate_limits (key, count, reset_at) VALUES (?, 1, ?) ON CONFLICT(key) DO UPDATE SET count = 1, reset_at = excluded.reset_at").run(key, resetAt);
return true;
}
if (row.count >= limit) return false;
db.prepare("UPDATE rate_limits SET count = count + 1 WHERE key = ?").run(key);
return true;
}
async function storeImage(file, { title = "", alt = "", category = "" } = {}) {
if (!file) return null;
const metadata = await sharp(file.buffer, { failOn: "error" }).metadata();
if (!["jpeg", "png", "webp"].includes(metadata.format)) throw new Error("That image format is not allowed.");
const base = `${Date.now()}-${token(4)}`;
const webName = `${base}.webp`;
const thumbName = `${base}-thumb.webp`;
const webPath = path.join(config.uploadDir, webName);
const thumbPath = path.join(config.uploadDir, thumbName);
await sharp(file.buffer).rotate().resize({ width: 1800, withoutEnlargement: true }).webp({ quality: 82 }).toFile(webPath);
await sharp(file.buffer).rotate().resize(420, 300, { fit: "cover" }).webp({ quality: 78 }).toFile(thumbPath);
const finalMeta = await sharp(webPath).metadata();
const created = now();
const result = db.prepare(`
INSERT INTO media (file_name, original_name, title, alt_text, category, mime_type, size_bytes, width, height, url, thumb_url, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'image/webp', ?, ?, ?, ?, ?, ?, ?)
`).run(webName, file.originalname, title || file.originalname, alt, category, fs.statSync(webPath).size, finalMeta.width, finalMeta.height, `/uploads/${webName}`, `/uploads/${thumbName}`, created, created);
return db.prepare("SELECT * FROM media WHERE id = ?").get(result.lastInsertRowid);
}
async function notifyOwner(message) {
const ownerEmail = config.smtp.ownerEmail || getSetting("business.email", "");
if (!config.smtp.host || !ownerEmail) return { status: "not_configured", error: "" };
try {
const transporter = nodemailer.createTransport({
host: config.smtp.host,
port: config.smtp.port,
secure: config.smtp.secure,
auth: config.smtp.user ? { user: config.smtp.user, pass: config.smtp.pass } : undefined
});
await transporter.sendMail({
from: config.smtp.from,
to: ownerEmail,
replyTo: message.email,
subject: `Website inquiry: ${message.subject}`,
text: [
`Name: ${message.name}`,
`Email: ${message.email}`,
`Phone: ${message.phone || ""}`,
`Preferred contact: ${message.preferred_contact || ""}`,
`Related: ${message.related || ""}`,
`Source: ${message.source_page || ""}`,
"",
message.message
].join("\n")
});
return { status: "sent", error: "" };
} catch (error) {
return { status: "failed", error: error.message.slice(0, 500) };
}
}
app.get("/healthz", (req, res) => res.json({ ok: true, time: now() }));
app.get("/", (req, res) => {
const settings = allSettings();
const featured = publicItems("AND (i.featured = 1 OR i.newly_added = 1 OR i.on_sale = 1)").slice(0, 6);
const home = pageBySlug("home");
const sections = pageSections(home.id);
const overlay = setting(settings, "home.hero.overlay", "light");
const body = `
<section class="hero ${overlay}" style="background-image:url('${escapeHtml(setting(settings, "home.hero.image", ""))}');background-position:${escapeHtml(setting(settings, "home.hero.position", "center"))};background-size:${escapeHtml(setting(settings, "home.hero.size", "cover"))};--overlay-opacity:${escapeHtml(setting(settings, "home.hero.overlay_opacity", "0.28"))};color:${escapeHtml(setting(settings, "home.hero.text_color", "#2d2522"))}">
<div class="hero-inner">
<p class="eyebrow">${escapeHtml(setting(settings, "business.tagline", ""))}</p>
<h1>${escapeHtml(setting(settings, "home.hero.title", "Debbie Windler Seamstress"))}</h1>
<p>${escapeHtml(setting(settings, "home.hero.subtitle", ""))}</p>
<p class="hero-contact">${escapeHtml(setting(settings, "home.hero.contact", ""))}</p>
<div class="button-row">
${button(setting(settings, "home.hero.button1_label"), setting(settings, "home.hero.button1_url"))}
${button(setting(settings, "home.hero.button2_label"), setting(settings, "home.hero.button2_url"), "button secondary")}
</div>
</div>
</section>
<section class="showcase" style="background:${escapeHtml(setting(settings, "home.highlight.background_color", "#f4e8df"))}">
<div class="section-heading">
<p class="eyebrow">Showcase</p>
<h2>${escapeHtml(setting(settings, "home.highlight.title", "Featured and Newly Added"))}</h2>
<p>${escapeHtml(setting(settings, "home.highlight.intro", ""))}</p>
</div>
<div class="cards-grid">${featured.map(itemCard).join("")}</div>
</section>
${sections.map(sectionHtml).join("")}
`;
res.send(publicLayout({
title: setting(settings, "site.title", "Debbie Windler Seamstress"),
description: setting(settings, "site.description", ""),
settings,
nav: navLinks(),
csrfToken: res.locals.csrfToken,
currentPath: req.path,
body
}));
});
app.get("/items", (req, res) => {
const settings = allSettings();
const q = String(req.query.q || "").trim();
const category = String(req.query.category || "").trim();
const availability = String(req.query.availability || "").trim();
const where = [];
const params = [];
if (q) {
where.push("AND (i.name LIKE ? OR i.short_description LIKE ? OR i.full_description LIKE ?)");
params.push(`%${q}%`, `%${q}%`, `%${q}%`);
}
if (category) {
where.push("AND c.slug = ?");
params.push(category);
}
if (availability) {
where.push("AND i.availability_status = ?");
params.push(availability);
}
const items = publicItems(where.join(" "), params);
const categories = db.prepare("SELECT * FROM categories ORDER BY name").all();
const statuses = db.prepare("SELECT DISTINCT availability_status FROM items WHERE is_published = 1 ORDER BY availability_status").all();
const alphabet = [...new Set(items.map((item) => item.name[0]?.toUpperCase()).filter(Boolean))];
const body = `
<section class="page-hero compact"><h1>Items Available and Custom Orders</h1><p>Browse sample listings alphabetically. Every item can be edited, hidden, marked new, put on sale, or offered as a custom order.</p></section>
<section class="filters">
<form method="get">
<label>Search <input name="q" value="${escapeHtml(q)}" placeholder="Name or description"></label>
<label>Category <select name="category"><option value="">All categories</option>${categories.map((c) => `<option value="${escapeHtml(c.slug)}" ${category === c.slug ? "selected" : ""}>${escapeHtml(c.name)}</option>`).join("")}</select></label>
<label>Availability <select name="availability"><option value="">All statuses</option>${statuses.map((s) => `<option ${availability === s.availability_status ? "selected" : ""}>${escapeHtml(s.availability_status)}</option>`).join("")}</select></label>
<button type="submit">Filter</button>
</form>
${alphabet.length > 6 ? `<div class="alphabet">${alphabet.map((letter) => `<a href="#letter-${letter}">${letter}</a>`).join("")}</div>` : ""}
</section>
<section class="cards-grid">${items.map(itemCard).join("") || "<p>No items match that search yet.</p>"}</section>
`;
renderPublic(req, res, { title: "Items Available and Custom Orders", description: "Items, custom orders, and sewing examples.", body });
});
app.get("/items/:slug", (req, res) => {
const item = db.prepare(`
SELECT i.*, c.name category_name, m.url image_url, m.alt_text image_alt
FROM items i
LEFT JOIN categories c ON c.id = i.category_id
LEFT JOIN media m ON m.id = i.main_image_id
WHERE i.slug = ? AND i.is_published = 1 AND i.is_archived = 0
`).get(req.params.slug);
if (!item) return res.status(404).send("Item not found");
const gallery = db.prepare(`
SELECT m.* FROM item_images ii JOIN media m ON m.id = ii.media_id
WHERE ii.item_id = ? ORDER BY ii.display_order, ii.id
`).all(item.id);
const body = `<section class="detail-layout">
<div>${item.image_url ? `<img class="detail-image" src="${escapeHtml(item.image_url)}" alt="${escapeHtml(item.image_alt || item.name)}">` : `<div class="image-placeholder large"></div>`}</div>
<div>
<p class="eyebrow">${escapeHtml(item.category_name || "Item")}</p>
<h1>${escapeHtml(item.name)}</h1>
<p class="status">${escapeHtml(item.availability_status)}</p>
<p class="price">${item.contact_for_pricing ? "Contact for pricing" : escapeHtml(item.price || item.starting_price || item.price_range || "")}</p>
${cleanRichText(item.full_description || item.short_description || "")}
${item.material_notes ? `<h2>Materials</h2><p>${escapeHtml(item.material_notes)}</p>` : ""}
${item.color_size_notes ? `<h2>Color or Size Notes</h2><p>${escapeHtml(item.color_size_notes)}</p>` : ""}
${item.care_instructions ? `<h2>Care Instructions</h2><p>${escapeHtml(item.care_instructions)}</p>` : ""}
${button("Ask About This Item", `/about-contact?item=${encodeURIComponent(item.name)}#contact-form`)}
</div>
</section>
${gallery.length ? `<section class="gallery">${gallery.map((m) => `<img src="${escapeHtml(m.url)}" alt="${escapeHtml(m.alt_text || m.title || "")}" loading="lazy">`).join("")}</section>` : ""}`;
renderPublic(req, res, { title: item.name, description: item.short_description, body });
});
app.get("/services", (req, res) => {
const page = pageBySlug("services");
const services = publicServices();
const sections = pageSections(page.id);
const body = `<section class="page-hero compact"><h1>${escapeHtml(page.title)}</h1><p>${escapeHtml(page.intro || "")}</p></section>
<section class="cards-grid">${services.map(serviceCard).join("")}</section>
${sections.map(sectionHtml).join("")}`;
renderPublic(req, res, { title: page.title, description: page.meta_description, body });
});
app.get("/about-contact", (req, res) => {
const settings = allSettings();
const page = pageBySlug("about-contact");
const sections = pageSections(page.id);
const equipment = db.prepare(`
SELECT e.*, m.url image_url, m.alt_text image_alt
FROM equipment e LEFT JOIN media m ON m.id = e.image_id
WHERE e.is_published = 1 ORDER BY e.display_order, e.name
`).all();
const body = `<section class="page-hero compact"><h1>${escapeHtml(page.title)}</h1><p>${escapeHtml(page.intro || "")}</p></section>
${sections.map(sectionHtml).join("")}
<section class="equipment">
<div class="section-heading"><p class="eyebrow">Equipment</p><h2>Sewing Room and Equipment</h2></div>
<div class="cards-grid">${equipment.map((e) => `<article class="service-card">${e.image_url ? `<img src="${escapeHtml(e.image_url)}" alt="${escapeHtml(e.image_alt || e.name)}" loading="lazy">` : `<div class="image-placeholder thread"></div>`}<div class="card-body"><h3>${escapeHtml(e.name)}</h3><p>${escapeHtml([e.brand, e.model].filter(Boolean).join(" "))}</p><p>${escapeHtml(e.description || "")}</p></div></article>`).join("")}</div>
</section>
<section class="contact-details">
<h2>Contact Information</h2>
<dl>
<div><dt>Email</dt><dd>${escapeHtml(setting(settings, "business.email", "To be supplied"))}</dd></div>
<div><dt>Phone</dt><dd>${escapeHtml(setting(settings, "business.phone", "To be supplied"))}</dd></div>
<div><dt>Service area</dt><dd>${escapeHtml(setting(settings, "business.service_area", "To be supplied"))}</dd></div>
<div><dt>Hours</dt><dd>${escapeHtml(setting(settings, "business.hours", "By appointment"))}</dd></div>
</dl>
</section>
${contactForm({ csrfToken: res.locals.csrfToken, item: req.query.item, service: req.query.service, success: req.query.sent === "1", settings })}`;
renderPublic(req, res, { title: page.title, description: page.meta_description, body });
});
app.post("/contact", async (req, res) => {
if (!rateLimit(`contact:${ipHash(req)}`, 5, 60 * 60 * 1000)) return res.status(429).send("Too many messages were sent recently. Please try again later.");
if (req.body.website) return res.redirect("/about-contact?sent=1#contact-form");
const fields = {
name: String(req.body.name || "").trim().slice(0, 120),
email: String(req.body.email || "").trim().slice(0, 180),
phone: String(req.body.phone || "").trim().slice(0, 80),
preferred_contact: String(req.body.preferred_contact || "").trim().slice(0, 80),
subject: String(req.body.subject || "").trim().slice(0, 160),
message: String(req.body.message || "").trim().slice(0, 3000),
related: String(req.body.related || "").trim().slice(0, 160),
source_page: String(req.body.source_page || req.get("referer") || "").slice(0, 300)
};
if (!fields.name || !fields.email || !fields.subject || !fields.message || !req.body.consent || !fields.email.includes("@")) {
return res.status(400).send("Please complete the required contact form fields and try again.");
}
const emailResult = await notifyOwner(fields);
const created = now();
db.prepare(`
INSERT INTO messages
(name, email, phone, preferred_contact, subject, message, related_item, consent, source_page, ip_hash, user_agent, email_notification_status, email_error, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)
`).run(fields.name, fields.email, fields.phone, fields.preferred_contact, fields.subject, fields.message, fields.related, fields.source_page, ipHash(req), String(req.get("user-agent") || "").slice(0, 300), emailResult.status, emailResult.error, created, created);
res.redirect("/about-contact?sent=1#contact-form");
});
app.get("/robots.txt", (req, res) => {
const settings = allSettings();
const visible = setting(settings, "seo.visible", "true") !== "false";
res.type("text/plain").send(`${visible ? "User-agent: *\nAllow: /" : "User-agent: *\nDisallow: /"}\nSitemap: ${config.baseUrl}/sitemap.xml\n`);
});
app.get("/sitemap.xml", (req, res) => {
const items = publicItems();
const urls = ["/", "/items", "/services", "/about-contact", ...items.map((item) => `/items/${item.slug}`)];
res.type("application/xml").send(`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls.map((url) => `<url><loc>${escapeHtml(config.baseUrl + url)}</loc></url>`).join("")}</urlset>`);
});
app.get("/admin", (req, res) => {
if (!adminCount()) return res.redirect("/admin/setup");
if (!currentAdmin(req)) return res.redirect("/admin/login");
res.redirect("/admin/dashboard");
});
app.get("/admin/setup", (req, res) => {
if (adminCount()) return res.redirect("/admin/login");
res.send(authLayout("Create Owner Account", `<p>Create the first owner login. No password is stored as plain text.</p>
<form method="post">
<input type="hidden" name="_csrf" value="${escapeHtml(res.locals.csrfToken)}">
<label>Name <input required name="name" maxlength="120"></label>
<label>Email <input required type="email" name="email" maxlength="180"></label>
<label>Password <input required type="password" name="password" minlength="10"></label>
<button type="submit">Create Owner Account</button>
</form>`));
});
app.post("/admin/setup", async (req, res) => {
if (adminCount()) return res.redirect("/admin/login");
const password = String(req.body.password || "");
if (password.length < 10) return res.status(400).send("Please use a password with at least 10 characters.");
const hash = await bcrypt.hash(password, 12);
const ts = now();
const result = db.prepare("INSERT INTO administrators (name, email, password_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?)").run(String(req.body.name || "Owner").trim(), String(req.body.email || "").trim().toLowerCase(), hash, ts, ts);
req.session.adminId = result.lastInsertRowid;
audit(result.lastInsertRowid, "Created owner account", "", req.ip);
res.redirect("/admin/dashboard");
});
app.get("/admin/login", (req, res) => {
if (!adminCount()) return res.redirect("/admin/setup");
res.send(authLayout("Owner Login", `<form method="post">
<input type="hidden" name="_csrf" value="${escapeHtml(res.locals.csrfToken)}">
<label>Email <input required type="email" name="email" autocomplete="username"></label>
<label>Password <input required type="password" name="password" autocomplete="current-password"></label>
<button type="submit">Log In</button>
</form>`));
});
app.post("/admin/login", async (req, res) => {
if (!rateLimit(`login:${ipHash(req)}`, 8, 15 * 60 * 1000)) return res.status(429).send("Too many login tries. Please wait and try again.");
const admin = db.prepare("SELECT * FROM administrators WHERE email = ?").get(String(req.body.email || "").trim().toLowerCase());
if (!admin || !(await bcrypt.compare(String(req.body.password || ""), admin.password_hash))) return res.status(401).send("Login failed. Check the email and password.");
req.session.regenerate((error) => {
if (error) return res.status(500).send("Login could not be completed.");
req.session.adminId = admin.id;
req.session.csrfToken = token(16);
db.prepare("UPDATE administrators SET last_login_at = ? WHERE id = ?").run(now(), admin.id);
audit(admin.id, "Logged in", "", req.ip);
res.redirect("/admin/dashboard");
});
});
app.post("/admin/logout", requireAdmin, (req, res) => {
req.session.destroy(() => res.redirect("/admin/login"));
});
app.get("/admin/dashboard", requireAdmin, (req, res) => {
const stats = {
unread: db.prepare("SELECT COUNT(*) count FROM messages WHERE status = 'unread'").get().count,
publishedItems: db.prepare("SELECT COUNT(*) count FROM items WHERE is_published = 1 AND is_archived = 0").get().count,
hiddenItems: db.prepare("SELECT COUNT(*) count FROM items WHERE is_published = 0 OR is_archived = 1").get().count,
newItems: db.prepare("SELECT COUNT(*) count FROM items WHERE newly_added = 1").get().count,
featuredItems: db.prepare("SELECT COUNT(*) count FROM items WHERE featured = 1").get().count,
services: db.prepare("SELECT COUNT(*) count FROM services WHERE is_published = 1").get().count,
media: db.prepare("SELECT COALESCE(SUM(size_bytes),0) bytes FROM media").get().bytes,
backups: db.prepare("SELECT * FROM backups ORDER BY created_at DESC LIMIT 1").get()
};
const messages = db.prepare("SELECT * FROM messages ORDER BY created_at DESC LIMIT 5").all();
const changes = db.prepare("SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 8").all();
const body = `<section class="stats-grid">
${Object.entries({
"Unread messages": stats.unread,
"Published items": stats.publishedItems,
"Hidden or draft items": stats.hiddenItems,
"New items": stats.newItems,
"Featured items": stats.featuredItems,
"Published services": stats.services,
"Storage used": bytes(stats.media),
"Last backup": stats.backups ? stats.backups.created_at.slice(0, 10) : "No backup yet"
}).map(([label, value]) => `<article><strong>${escapeHtml(value)}</strong><span>${label}</span></article>`).join("")}
</section>
<section class="quick-actions">
${button("Add Item", "/admin/items/new")}
${button("Add Service", "/admin/services/new")}
${button("Upload Image", "/admin/media")}
${button("Edit Home Page", "/admin/home")}
${button("Read Messages", "/admin/messages")}
${button("Edit Contact Information", "/admin/business")}
</section>
<section class="two-column">
<div class="panel"><h2>Recent Messages</h2>${messages.map((m) => `<p><a href="/admin/messages/${m.id}">${escapeHtml(m.subject)}</a><br><small>${escapeHtml(m.name)} · ${escapeHtml(m.created_at)}</small></p>`).join("") || "<p>No messages yet.</p>"}</div>
<div class="panel"><h2>Recent Website Changes</h2>${changes.map((c) => `<p>${escapeHtml(c.action)}<br><small>${escapeHtml(c.created_at)}</small></p>`).join("") || "<p>No changes yet.</p>"}</div>
</section>`;
renderAdmin(req, res, "Dashboard", "dashboard", body);
});
app.get("/admin/home", requireAdmin, (req, res) => {
const s = allSettings();
const body = `<form method="post" class="editor-form">
${csrf(res)}
<section class="panel"><h2>Hero Area</h2>
${input("Business name in hero", "home.hero.title", setting(s, "home.hero.title"))}
${textarea("Subtitle", "home.hero.subtitle", setting(s, "home.hero.subtitle"))}
${input("Contact line", "home.hero.contact", setting(s, "home.hero.contact"))}
${input("Hero background URL", "home.hero.image", setting(s, "home.hero.image"))}
${select("Image position", "home.hero.position", ["center", "top", "bottom", "left", "right"], setting(s, "home.hero.position"))}
${select("Image sizing", "home.hero.size", ["cover", "contain", "auto"], setting(s, "home.hero.size"))}
${select("Overlay style", "home.hero.overlay", ["light", "dark", "none"], setting(s, "home.hero.overlay"))}
${input("Overlay opacity", "home.hero.overlay_opacity", setting(s, "home.hero.overlay_opacity"), "number", "0", "1", "0.05")}
${input("Hero text color", "home.hero.text_color", setting(s, "home.hero.text_color"), "color")}
${input("First button label", "home.hero.button1_label", setting(s, "home.hero.button1_label"))}
${input("First button link", "home.hero.button1_url", setting(s, "home.hero.button1_url"))}
${input("Second button label", "home.hero.button2_label", setting(s, "home.hero.button2_label"))}
${input("Second button link", "home.hero.button2_url", setting(s, "home.hero.button2_url"))}
</section>
<section class="panel"><h2>Featured and Newly Added Area</h2>
${input("Section title", "home.highlight.title", setting(s, "home.highlight.title"))}
${textarea("Intro text", "home.highlight.intro", setting(s, "home.highlight.intro"))}
${input("Background color", "home.highlight.background_color", setting(s, "home.highlight.background_color"), "color")}
</section>
<button type="submit">Save Home Page Settings</button>
<a class="button secondary" href="/" target="_blank" rel="noopener noreferrer">Preview Home Page</a>
</form>`;
renderAdmin(req, res, "Home Page Editor", "home", body);
});
app.post("/admin/home", requireAdmin, (req, res) => {
Object.keys(req.body).filter((key) => key.startsWith("home.")).forEach((key) => setSetting(key, req.body[key]));
audit(req.admin.id, "Updated home page settings", "", req.ip);
res.redirect("/admin/home?saved=1");
});
app.get("/admin/pages", requireAdmin, (req, res) => {
const pages = db.prepare("SELECT * FROM pages ORDER BY id").all();
renderAdmin(req, res, "Page Editor", "pages", `<div class="table-list">${pages.map((p) => `<article><h2>${escapeHtml(p.title)}</h2><p>${escapeHtml(p.intro || "")}</p><a class="button" href="/admin/pages/${p.slug}">Edit Page</a><a class="button secondary" href="${p.slug === "home" ? "/" : "/" + p.slug}" target="_blank">Preview</a></article>`).join("")}</div>`);
});
app.get("/admin/pages/:slug", requireAdmin, (req, res) => {
const page = pageBySlug(req.params.slug);
if (!page) return res.status(404).send("Page not found");
const sections = pageSections(page.id, true);
const media = mediaRows();
const body = `<form method="post" class="editor-form">
${csrf(res)}
<section class="panel">
${input("Page title", "title", page.title)}
${input("Page subtitle", "subtitle", page.subtitle || "")}
${textarea("Introduction", "intro", page.intro || "")}
${input("Search title", "meta_title", page.meta_title || "")}
${textarea("Search description", "meta_description", page.meta_description || "")}
${select("Search visibility", "search_visible", [["1","Visible to search engines"], ["0","Hidden from search engines"]], String(page.search_visible))}
${select("Status", "status", ["published", "draft"], page.status)}
</section>
<button type="submit">Save Page</button>
<a class="button secondary" href="${page.slug === "home" ? "/" : "/" + page.slug}" target="_blank">Preview Page</a>
</form>
<section class="panel"><h2>Content Sections</h2>
${sections.map((section) => sectionForm(section, media, res.locals.csrfToken, `/admin/pages/${page.slug}/sections/${section.id}`)).join("")}
<form method="post" action="/admin/pages/${page.slug}/sections/new" class="inline-form">
${csrf(res)}<button type="submit">Add Section</button>
</form>
</section>`;
renderAdmin(req, res, `Edit ${page.title}`, "pages", body);
});
app.post("/admin/pages/:slug", requireAdmin, (req, res) => {
const page = pageBySlug(req.params.slug);
saveRevision("page", page.id, page.title, page, req.admin.id);
db.prepare("UPDATE pages SET title=?, subtitle=?, intro=?, meta_title=?, meta_description=?, search_visible=?, status=?, updated_at=? WHERE id=?")
.run(req.body.title, req.body.subtitle, req.body.intro, req.body.meta_title, req.body.meta_description, checkbox(req.body.search_visible === "1"), req.body.status, now(), page.id);
audit(req.admin.id, `Updated page ${page.title}`, "", req.ip);
res.redirect(`/admin/pages/${page.slug}`);
});
app.post("/admin/pages/:slug/sections/new", requireAdmin, (req, res) => {
const page = pageBySlug(req.params.slug);
db.prepare("INSERT INTO page_sections (page_id, title, body, layout, display_order, is_published, updated_at) VALUES (?, 'New Section', '<p>Edit this section.</p>', 'text', (SELECT COALESCE(MAX(display_order),0)+10 FROM page_sections WHERE page_id=?), 0, ?)")
.run(page.id, page.id, now());
res.redirect(`/admin/pages/${page.slug}`);
});
app.post("/admin/pages/:slug/sections/:id", requireAdmin, (req, res) => {
const page = pageBySlug(req.params.slug);
const section = db.prepare("SELECT * FROM page_sections WHERE id = ?").get(req.params.id);
saveRevision("section", section.id, section.title, section, req.admin.id);
db.prepare("UPDATE page_sections SET title=?, body=?, layout=?, image_id=?, background_image_id=?, background_color=?, button_label=?, button_url=?, display_order=?, is_published=?, updated_at=? WHERE id=?")
.run(req.body.title, cleanRichText(req.body.body), req.body.layout, req.body.image_id || null, req.body.background_image_id || null, req.body.background_color, req.body.button_label, req.body.button_url, Number(req.body.display_order || 0), checkbox(req.body.is_published), now(), section.id);
audit(req.admin.id, `Updated section on ${page.title}`, "", req.ip);
res.redirect(`/admin/pages/${page.slug}`);
});
function csrf(res) {
return `<input type="hidden" name="_csrf" value="${escapeHtml(res.locals.csrfToken)}">`;
}
function input(label, name, value = "", type = "text", min = "", max = "", step = "") {
return `<label>${escapeHtml(label)} <input type="${type}" name="${escapeHtml(name)}" value="${escapeHtml(value)}" ${min !== "" ? `min="${min}"` : ""} ${max !== "" ? `max="${max}"` : ""} ${step !== "" ? `step="${step}"` : ""}></label>`;
}
function textarea(label, name, value = "") {
return `<label>${escapeHtml(label)} <textarea name="${escapeHtml(name)}" rows="5">${escapeHtml(value)}</textarea></label>`;
}
function select(label, name, options, selected = "") {
const html = options.map((option) => {
const value = Array.isArray(option) ? option[0] : option;
const text = Array.isArray(option) ? option[1] : option;
return `<option value="${escapeHtml(value)}" ${String(selected) === String(value) ? "selected" : ""}>${escapeHtml(text)}</option>`;
}).join("");
return `<label>${escapeHtml(label)} <select name="${escapeHtml(name)}">${html}</select></label>`;
}
function checkboxInput(label, name, checked) {
return `<label class="checkbox"><input type="checkbox" name="${escapeHtml(name)}" value="1" ${checked ? "checked" : ""}> ${escapeHtml(label)}</label>`;
}
function sectionForm(section, media, csrfToken, action) {
return `<details class="section-editor"><summary>${escapeHtml(section.title || "Untitled section")} ${section.is_published ? "" : "(hidden)"}</summary>
<form method="post" action="${escapeHtml(action)}" class="editor-form nested">
<input type="hidden" name="_csrf" value="${escapeHtml(csrfToken)}">
${input("Heading", "title", section.title || "")}
${textarea("Text", "body", section.body || "")}
${select("Layout", "layout", [["text", "Text only"], ["image-left", "Image left, text right"], ["image-right", "Text left, image right"], ["full-image", "Full-width image"], ["contact-callout", "Contact callout"]], section.layout)}
<label>Image <select name="image_id"><option value="">No image</option>${media.map((m) => mediaOption(m, section.image_id)).join("")}</select></label>
<label>Background image <select name="background_image_id"><option value="">No background image</option>${media.map((m) => mediaOption(m, section.background_image_id)).join("")}</select></label>
${input("Background color", "background_color", section.background_color || "", "color")}
${input("Button label", "button_label", section.button_label || "")}
${input("Button link", "button_url", section.button_url || "")}
${input("Display order", "display_order", section.display_order || 0, "number")}
${checkboxInput("Show this section on the public page", "is_published", section.is_published)}
<button type="submit">Save Section</button>
</form>
</details>`;
}
app.get("/admin/items", requireAdmin, (req, res) => {
const q = String(req.query.q || "");
const items = db.prepare(`
SELECT i.*, c.name category_name FROM items i LEFT JOIN categories c ON c.id = i.category_id
WHERE i.name LIKE ? OR i.short_description LIKE ?
ORDER BY i.name
`).all(`%${q}%`, `%${q}%`);
const body = `<form method="get" class="toolbar"><input name="q" placeholder="Search items" value="${escapeHtml(q)}"><button>Search</button><a class="button" href="/admin/items/new">Add Item</a></form>
<div class="table-list">${items.map((i) => `<article><h2>${escapeHtml(i.name)}</h2><p>${escapeHtml(i.category_name || "")} · ${escapeHtml(i.availability_status)} ${i.is_published ? "" : "· Hidden"}</p><a class="button" href="/admin/items/${i.id}/edit">Edit</a><a class="button secondary" href="/items/${i.slug}" target="_blank">Preview</a></article>`).join("")}</div>`;
renderAdmin(req, res, "Items Manager", "items", body);
});
app.get("/admin/items/new", requireAdmin, (req, res) => renderAdmin(req, res, "Add Item", "items", itemEditor(null, res.locals.csrfToken)));
app.get("/admin/items/:id/edit", requireAdmin, (req, res) => {
const item = db.prepare("SELECT i.*, c.name category_name FROM items i LEFT JOIN categories c ON c.id = i.category_id WHERE i.id = ?").get(req.params.id);
if (!item) return res.status(404).send("Item not found");
renderAdmin(req, res, `Edit ${item.name}`, "items", itemEditor(item, res.locals.csrfToken));
});
app.post("/admin/items/new", requireAdmin, upload.array("images", 8), async (req, res, next) => {
try {
const id = await saveItem(req, null);
res.redirect(`/admin/items/${id}/edit`);
} catch (error) { next(error); }
});
app.post("/admin/items/:id/edit", requireAdmin, upload.array("images", 8), async (req, res, next) => {
try {
await saveItem(req, req.params.id);
res.redirect(`/admin/items/${req.params.id}/edit`);
} catch (error) { next(error); }
});
app.post("/admin/items/:id/duplicate", requireAdmin, (req, res) => {
const item = db.prepare("SELECT * FROM items WHERE id=?").get(req.params.id);
if (!item) return res.status(404).send("Item not found");
const copyName = `${item.name} Copy`;
const copySlug = `${item.slug}-copy-${token(3)}`;
const ts = now();
const result = db.prepare(`INSERT INTO items (name, slug, short_description, full_description, category_id, main_image_id, price, starting_price, price_range, contact_for_pricing, quantity, availability_status, custom_order_available, color_size_notes, material_notes, care_instructions, featured, newly_added, on_sale, sale_text, display_order, is_published, is_archived, date_added, published_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, 0, 0, ?, NULL, ?)`)
.run(copyName, copySlug, item.short_description, item.full_description, item.category_id, item.main_image_id, item.price, item.starting_price, item.price_range, item.contact_for_pricing, item.quantity, item.availability_status, item.custom_order_available, item.color_size_notes, item.material_notes, item.care_instructions, item.sale_text, item.display_order + 1, ts, ts);
audit(req.admin.id, "Duplicated item", item.name, req.ip);
res.redirect(`/admin/items/${result.lastInsertRowid}/edit`);
});
app.post("/admin/items/:id/archive", requireAdmin, (req, res) => {
db.prepare("UPDATE items SET is_archived=1, is_published=0, updated_at=? WHERE id=?").run(now(), req.params.id);
audit(req.admin.id, "Archived item", String(req.params.id), req.ip);
res.redirect("/admin/items");
});
app.post("/admin/items/:id/delete", requireAdmin, (req, res) => {
db.prepare("DELETE FROM items WHERE id=?").run(req.params.id);
audit(req.admin.id, "Deleted item", String(req.params.id), req.ip);
res.redirect("/admin/items");
});
async function saveItem(req, id) {
const categoryId = req.body.category ? ensureCategory(req.body.category.trim()) : null;
const ts = now();
const slug = makeSlug(req.body.slug || req.body.name, "item");
if (id) {
const old = db.prepare("SELECT * FROM items WHERE id=?").get(id);
saveRevision("item", id, old.name, old, req.admin.id);
db.prepare(`UPDATE items SET name=?, slug=?, short_description=?, full_description=?, category_id=?, price=?, starting_price=?, price_range=?, contact_for_pricing=?, quantity=?, availability_status=?, custom_order_available=?, color_size_notes=?, material_notes=?, care_instructions=?, featured=?, newly_added=?, on_sale=?, sale_text=?, display_order=?, is_published=?, updated_at=? WHERE id=?`)
.run(req.body.name, slug, req.body.short_description, cleanRichText(req.body.full_description), categoryId, req.body.price, req.body.starting_price, req.body.price_range, checkbox(req.body.contact_for_pricing), req.body.quantity, req.body.availability_status, checkbox(req.body.custom_order_available), req.body.color_size_notes, req.body.material_notes, req.body.care_instructions, checkbox(req.body.featured), checkbox(req.body.newly_added), checkbox(req.body.on_sale), req.body.sale_text, Number(req.body.display_order || 0), checkbox(req.body.is_published), ts, id);
} else {
const result = db.prepare(`INSERT INTO items (name, slug, short_description, full_description, category_id, price, starting_price, price_range, contact_for_pricing, quantity, availability_status, custom_order_available, color_size_notes, material_notes, care_instructions, featured, newly_added, on_sale, sale_text, display_order, is_published, date_added, published_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(req.body.name, slug, req.body.short_description, cleanRichText(req.body.full_description), categoryId, req.body.price, req.body.starting_price, req.body.price_range, checkbox(req.body.contact_for_pricing), req.body.quantity, req.body.availability_status, checkbox(req.body.custom_order_available), req.body.color_size_notes, req.body.material_notes, req.body.care_instructions, checkbox(req.body.featured), checkbox(req.body.newly_added), checkbox(req.body.on_sale), req.body.sale_text, Number(req.body.display_order || 0), checkbox(req.body.is_published), ts, ts, ts);
id = result.lastInsertRowid;
}
for (const file of req.files || []) {
const media = await storeImage(file, { title: req.body.name, alt: req.body.name, category: "Items" });
db.prepare("INSERT INTO item_images (item_id, media_id, display_order, is_main) VALUES (?, ?, (SELECT COALESCE(MAX(display_order),0)+10 FROM item_images WHERE item_id=?), 0)").run(id, media.id, id);
const item = db.prepare("SELECT main_image_id FROM items WHERE id=?").get(id);
if (!item.main_image_id) db.prepare("UPDATE items SET main_image_id=? WHERE id=?").run(media.id, id);
}
audit(req.admin.id, id ? "Saved item" : "Added item", req.body.name, req.ip);
return id;
}
function itemEditor(item, csrfToken) {
const values = item || { availability_status: "Available", is_published: 1 };
return `<form method="post" enctype="multipart/form-data" class="editor-form">
<input type="hidden" name="_csrf" value="${escapeHtml(csrfToken)}">
${input("Item name", "name", values.name || "")}
${input("Clean page address", "slug", values.slug || "")}
${textarea("Short description", "short_description", values.short_description || "")}
${textarea("Full description", "full_description", values.full_description || "")}
${input("Category", "category", values.category_name || "")}
${input("Price", "price", values.price || "")}
${input("Starting price", "starting_price", values.starting_price || "")}
${input("Price range", "price_range", values.price_range || "")}
${checkboxInput("Contact for pricing", "contact_for_pricing", values.contact_for_pricing)}
${input("Available quantity", "quantity", values.quantity || "")}
${select("Availability", "availability_status", ["Available", "Made to Order", "Custom Order", "Sold", "Seasonal", "On Sale", "Hidden"], values.availability_status)}
${checkboxInput("Custom order available", "custom_order_available", values.custom_order_available)}
${textarea("Color or size notes", "color_size_notes", values.color_size_notes || "")}
${textarea("Material notes", "material_notes", values.material_notes || "")}
${textarea("Care instructions", "care_instructions", values.care_instructions || "")}
${checkboxInput("Feature this item", "featured", values.featured)}
${checkboxInput("Mark as newly added", "newly_added", values.newly_added)}
${checkboxInput("Mark as on sale", "on_sale", values.on_sale)}
${input("Sale text", "sale_text", values.sale_text || "")}
${input("Display order", "display_order", values.display_order || 0, "number")}
${checkboxInput("Publish this item", "is_published", values.is_published)}
<label>Upload item images <input type="file" name="images" multiple accept="image/png,image/jpeg,image/webp"></label>
<button type="submit">Save Item</button>
</form>
${values.id ? `<section class="panel quick-actions">
<form method="post" action="/admin/items/${values.id}/duplicate"><input type="hidden" name="_csrf" value="${escapeHtml(csrfToken)}"><button>Duplicate Item</button></form>
<form method="post" action="/admin/items/${values.id}/archive"><input type="hidden" name="_csrf" value="${escapeHtml(csrfToken)}"><button>Archive Item</button></form>
<form method="post" action="/admin/items/${values.id}/delete"><input type="hidden" name="_csrf" value="${escapeHtml(csrfToken)}"><button class="danger">Delete Item</button></form>
</section>` : ""}`;
}
app.get("/admin/services", requireAdmin, (req, res) => {
const services = db.prepare("SELECT * FROM services ORDER BY display_order, name").all();
renderAdmin(req, res, "Services Manager", "services", `<p><a class="button" href="/admin/services/new">Add Service</a></p><div class="table-list">${services.map((s) => `<article><h2>${escapeHtml(s.name)}</h2><p>${s.is_published ? "Published" : "Hidden"} · Order ${s.display_order}</p><a class="button" href="/admin/services/${s.id}/edit">Edit</a></article>`).join("")}</div>`);
});
app.get("/admin/services/new", requireAdmin, (req, res) => renderAdmin(req, res, "Add Service", "services", serviceEditor(null, res.locals.csrfToken)));
app.get("/admin/services/:id/edit", requireAdmin, (req, res) => {
const service = db.prepare("SELECT * FROM services WHERE id=?").get(req.params.id);
renderAdmin(req, res, `Edit ${service.name}`, "services", serviceEditor(service, res.locals.csrfToken));
});
app.post("/admin/services/new", requireAdmin, upload.single("image"), async (req, res, next) => {
try { const id = await saveService(req, null); res.redirect(`/admin/services/${id}/edit`); } catch (e) { next(e); }
});
app.post("/admin/services/:id/edit", requireAdmin, upload.single("image"), async (req, res, next) => {
try { await saveService(req, req.params.id); res.redirect(`/admin/services/${req.params.id}/edit`); } catch (e) { next(e); }
});
app.post("/admin/services/:id/duplicate", requireAdmin, (req, res) => {
const s = db.prepare("SELECT * FROM services WHERE id=?").get(req.params.id);
if (!s) return res.status(404).send("Service not found");
const result = db.prepare("INSERT INTO services (name, slug, image_id, short_description, full_description, pricing_note, display_order, featured, is_published, published_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, NULL, ?)")
.run(`${s.name} Copy`, `${s.slug}-copy-${token(3)}`, s.image_id, s.short_description, s.full_description, s.pricing_note, s.display_order + 1, now());
audit(req.admin.id, "Duplicated service", s.name, req.ip);
res.redirect(`/admin/services/${result.lastInsertRowid}/edit`);
});
app.post("/admin/services/:id/delete", requireAdmin, (req, res) => {
db.prepare("DELETE FROM services WHERE id=?").run(req.params.id);
audit(req.admin.id, "Deleted service", String(req.params.id), req.ip);
res.redirect("/admin/services");
});
async function saveService(req, id) {
const media = req.file ? await storeImage(req.file, { title: req.body.name, alt: req.body.name, category: "Services" }) : null;
const ts = now();
const slug = makeSlug(req.body.slug || req.body.name, "service");
if (id) {
const old = db.prepare("SELECT * FROM services WHERE id=?").get(id);
saveRevision("service", id, old.name, old, req.admin.id);
db.prepare("UPDATE services SET name=?, slug=?, image_id=COALESCE(?, image_id), short_description=?, full_description=?, pricing_note=?, display_order=?, featured=?, is_published=?, updated_at=? WHERE id=?")
.run(req.body.name, slug, media?.id || null, req.body.short_description, cleanRichText(req.body.full_description), req.body.pricing_note, Number(req.body.display_order || 0), checkbox(req.body.featured), checkbox(req.body.is_published), ts, id);
} else {
const result = db.prepare("INSERT INTO services (name, slug, image_id, short_description, full_description, pricing_note, display_order, featured, is_published, published_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
.run(req.body.name, slug, media?.id || null, req.body.short_description, cleanRichText(req.body.full_description), req.body.pricing_note, Number(req.body.display_order || 0), checkbox(req.body.featured), checkbox(req.body.is_published), ts, ts);
id = result.lastInsertRowid;
}
audit(req.admin.id, "Saved service", req.body.name, req.ip);
return id;
}
function serviceEditor(service, csrfToken) {
const s = service || { is_published: 1, display_order: 0 };
return `<form method="post" enctype="multipart/form-data" class="editor-form">
<input type="hidden" name="_csrf" value="${escapeHtml(csrfToken)}">
${input("Service name", "name", s.name || "")}
${input("Clean page address", "slug", s.slug || "")}
${textarea("Short description", "short_description", s.short_description || "")}
${textarea("Full description", "full_description", s.full_description || "")}
${input("Pricing note", "pricing_note", s.pricing_note || "")}
${input("Display order", "display_order", s.display_order || 0, "number")}
${checkboxInput("Feature this service", "featured", s.featured)}
${checkboxInput("Publish this service", "is_published", s.is_published)}
<label>Service image <input type="file" name="image" accept="image/png,image/jpeg,image/webp"></label>
<button type="submit">Save Service</button>
</form>
${s.id ? `<section class="panel quick-actions">
<form method="post" action="/admin/services/${s.id}/duplicate"><input type="hidden" name="_csrf" value="${escapeHtml(csrfToken)}"><button>Duplicate Service</button></form>
<form method="post" action="/admin/services/${s.id}/delete"><input type="hidden" name="_csrf" value="${escapeHtml(csrfToken)}"><button class="danger">Delete Service</button></form>
</section>` : ""}`;
}
app.get("/admin/about-equipment", requireAdmin, (req, res) => {
const equipment = db.prepare("SELECT * FROM equipment ORDER BY display_order, name").all();
const body = `<p><a class="button" href="/admin/pages/about-contact">Edit About Page Text</a></p>
<section class="panel"><h2>Equipment</h2>${equipment.map((e) => equipmentForm(e, res.locals.csrfToken)).join("")}
<form method="post" action="/admin/about-equipment/new">${csrf(res)}<button>Add Equipment</button></form></section>`;
renderAdmin(req, res, "About and Equipment Manager", "about", body);
});
app.post("/admin/about-equipment/new", requireAdmin, (req, res) => {
db.prepare("INSERT INTO equipment (name, type, description, display_order, is_published, updated_at) VALUES ('New Equipment', '', '', 100, 0, ?)").run(now());
res.redirect("/admin/about-equipment");
});
app.post("/admin/about-equipment/:id", requireAdmin, (req, res) => {
db.prepare("UPDATE equipment SET name=?, type=?, brand=?, model=?, description=?, display_order=?, is_published=?, updated_at=? WHERE id=?")
.run(req.body.name, req.body.type, req.body.brand, req.body.model, req.body.description, Number(req.body.display_order || 0), checkbox(req.body.is_published), now(), req.params.id);
audit(req.admin.id, "Saved equipment", req.body.name, req.ip);
res.redirect("/admin/about-equipment");
});
function equipmentForm(e, csrfToken) {
return `<details class="section-editor"><summary>${escapeHtml(e.name)} ${e.is_published ? "" : "(hidden)"}</summary><form method="post" action="/admin/about-equipment/${e.id}" class="editor-form nested">
<input type="hidden" name="_csrf" value="${escapeHtml(csrfToken)}">${input("Equipment name", "name", e.name)}${input("Type", "type", e.type || "")}${input("Brand", "brand", e.brand || "")}${input("Model", "model", e.model || "")}${textarea("Description", "description", e.description || "")}${input("Display order", "display_order", e.display_order, "number")}${checkboxInput("Publish this equipment", "is_published", e.is_published)}<button>Save Equipment</button></form></details>`;
}
app.get("/admin/messages", requireAdmin, (req, res) => {
const status = req.query.status || "";
const rows = db.prepare(`SELECT * FROM messages ${status ? "WHERE status=?" : ""} ORDER BY created_at DESC`).all(...(status ? [status] : []));
const body = `<div class="toolbar"><a class="button secondary" href="/admin/messages/export">Export CSV</a><a href="/admin/messages">All</a><a href="/admin/messages?status=unread">Unread</a><a href="/admin/messages?status=archived">Archived</a></div>
<div class="table-list">${rows.map((m) => `<article><h2><a href="/admin/messages/${m.id}">${escapeHtml(m.subject)}</a></h2><p>${escapeHtml(m.name)} · ${escapeHtml(m.email)} · ${escapeHtml(m.created_at)} · ${escapeHtml(m.status)}</p></article>`).join("") || "<p>No messages here.</p>"}</div>`;
renderAdmin(req, res, "Messages Inbox", "messages", body);
});
app.get("/admin/messages/export", requireAdmin, (req, res) => {
const rows = db.prepare("SELECT created_at,name,email,phone,preferred_contact,subject,message,related_item,source_page,status,email_notification_status FROM messages ORDER BY created_at DESC").all();
const csv = ["created_at,name,email,phone,preferred_contact,subject,message,related_item,source_page,status,email_notification_status", ...rows.map((r) => Object.values(r).map((v) => `"${String(v ?? "").replaceAll('"', '""')}"`).join(","))].join("\n");
res.type("text/csv").attachment("debbie-windler-messages.csv").send(csv);
});
app.get("/admin/messages/:id", requireAdmin, (req, res) => {
const m = db.prepare("SELECT * FROM messages WHERE id=?").get(req.params.id);
if (!m) return res.status(404).send("Message not found");
db.prepare("UPDATE messages SET status='read', updated_at=? WHERE id=? AND status='unread'").run(now(), m.id);
const mailto = `mailto:${encodeURIComponent(m.email)}?subject=${encodeURIComponent(`Re: ${m.subject}`)}`;
renderAdmin(req, res, m.subject, "messages", `<section class="panel message-detail"><p><strong>From:</strong> ${escapeHtml(m.name)} · ${escapeHtml(m.email)} · ${escapeHtml(m.phone || "")}</p><p><strong>Preferred:</strong> ${escapeHtml(m.preferred_contact || "")}</p><p><strong>Related:</strong> ${escapeHtml(m.related_item || "")}</p><p><strong>Source:</strong> ${escapeHtml(m.source_page || "")}</p><p><strong>Email notice:</strong> ${escapeHtml(m.email_notification_status)} ${m.email_error ? `- ${escapeHtml(m.email_error)}` : ""}</p><pre>${escapeHtml(m.message)}</pre><form method="post" action="/admin/messages/${m.id}/status">${csrf(res)}<button name="status" value="unread">Mark Unread</button><button name="status" value="archived">Archive</button><button name="status" value="deleted" class="danger">Delete</button><a class="button secondary" href="${mailto}">Reply by Email</a></form></section>`);
});
app.post("/admin/messages/:id/status", requireAdmin, (req, res) => {
db.prepare("UPDATE messages SET status=?, updated_at=? WHERE id=?").run(req.body.status, now(), req.params.id);
res.redirect("/admin/messages");
});
app.get("/admin/media", requireAdmin, (req, res) => {
const media = mediaRows();
const body = `<form method="post" enctype="multipart/form-data" class="panel editor-form">
${csrf(res)}${input("Image title", "title", "")}${input("Alt text", "alt", "")}${input("Folder or category", "category", "")}
<label>Images <input type="file" name="images" multiple accept="image/png,image/jpeg,image/webp"></label><button>Upload Images</button>
</form>${mediaGrid(media)}
<div class="table-list">${media.map((m) => `<article><h2>${escapeHtml(m.title || m.original_name)}</h2><p>${escapeHtml(m.url)} · ${bytes(m.size_bytes)}</p><form method="post" action="/admin/media/${m.id}">${csrf(res)}${input("Title", "title", m.title || "")}${input("Alt text", "alt_text", m.alt_text || "")}${input("Folder", "category", m.category || "")}<button>Save Image Details</button></form><form method="post" action="/admin/media/${m.id}/delete">${csrf(res)}<button class="danger">Delete Unused Image</button></form></article>`).join("")}</div>`;
renderAdmin(req, res, "Media Library", "media", body);
});
app.post("/admin/media", requireAdmin, upload.array("images", 10), async (req, res, next) => {
try {
for (const file of req.files || []) await storeImage(file, req.body);
audit(req.admin.id, "Uploaded images", `${req.files?.length || 0} files`, req.ip);
res.redirect("/admin/media");
} catch (e) { next(e); }
});
app.post("/admin/media/:id", requireAdmin, (req, res) => {
db.prepare("UPDATE media SET title=?, alt_text=?, category=?, updated_at=? WHERE id=?").run(req.body.title, req.body.alt_text, req.body.category, now(), req.params.id);
res.redirect("/admin/media");
});
app.post("/admin/media/:id/delete", requireAdmin, (req, res) => {
const id = Number(req.params.id);
const used = [
db.prepare("SELECT COUNT(*) count FROM items WHERE main_image_id=?").get(id).count,
db.prepare("SELECT COUNT(*) count FROM services WHERE image_id=?").get(id).count,
db.prepare("SELECT COUNT(*) count FROM equipment WHERE image_id=?").get(id).count,
db.prepare("SELECT COUNT(*) count FROM page_sections WHERE image_id=? OR background_image_id=?").get(id, id).count
].reduce((sum, count) => sum + count, 0);
if (used) return res.status(400).send("This image is currently used on the website. Remove it from those pages or items before deleting it.");
const media = db.prepare("SELECT * FROM media WHERE id=?").get(id);
if (media) {
for (const url of [media.url, media.thumb_url]) {
if (!url) continue;
const filePath = path.join(config.root, "public", url.replace(/^\//, ""));
if (filePath.startsWith(config.uploadDir) && fs.existsSync(filePath)) fs.unlinkSync(filePath);
}
db.prepare("DELETE FROM media WHERE id=?").run(id);
audit(req.admin.id, "Deleted unused image", media.file_name, req.ip);
}
res.redirect("/admin/media");
});
app.get("/admin/appearance", requireAdmin, (req, res) => settingsForm(req, res, "Appearance and Theme Settings", "appearance", [
["Main background color", "theme.bg", "color"], ["Secondary background color", "theme.bg_alt", "color"], ["Text color", "theme.text", "color"], ["Heading color", "theme.heading", "color"], ["Link color", "theme.link", "color"], ["Button color", "theme.button", "color"], ["Button text color", "theme.button_text", "color"], ["Accent color", "theme.accent", "color"], ["Border radius", "theme.radius", "number"], ["Site width", "theme.width", "number"], ["Heading font", "theme.heading_font"], ["Body font", "theme.body_font"], ["Footer style", "theme.footer_style"]
]));
app.post("/admin/appearance", requireAdmin, (req, res) => saveSettings(req, res, "appearance", "Updated appearance settings"));
app.get("/admin/business", requireAdmin, (req, res) => settingsForm(req, res, "Business Settings", "business", [
["Business name", "business.name"], ["Tagline", "business.tagline"], ["Email", "business.email"], ["Phone", "business.phone"], ["Facebook link", "business.facebook"], ["Service area", "business.service_area"], ["Business hours", "business.hours"], ["Preferred contact method", "business.preferred_contact"], ["Footer text", "business.footer_text"], ["Copyright text", "business.copyright"], ["Website title", "site.title"], ["Website description", "site.description"]
]));
app.post("/admin/business", requireAdmin, (req, res) => saveSettings(req, res, "business", "Updated business settings"));
app.get("/admin/seo", requireAdmin, (req, res) => settingsForm(req, res, "Search Engine and Sharing Settings", "seo", [
["Website title", "site.title"], ["Website description", "site.description"], ["Canonical URL", "seo.canonical"], ["Search-engine visibility", "seo.visible"]
]));
app.post("/admin/seo", requireAdmin, (req, res) => saveSettings(req, res, "seo", "Updated SEO settings"));
app.get("/admin/maintenance", requireAdmin, (req, res) => settingsForm(req, res, "Maintenance Mode", "maintenance", [
["Maintenance enabled (true or false)", "maintenance.enabled"], ["Maintenance message", "maintenance.message"], ["Expected return message", "maintenance.return_note"], ["Artwork URL", "maintenance.artwork"]
]));
app.post("/admin/maintenance", requireAdmin, (req, res) => saveSettings(req, res, "maintenance", "Updated maintenance mode"));
function settingsForm(req, res, title, active, fields) {
const s = allSettings();
const body = `<form method="post" class="editor-form panel">${csrf(res)}${fields.map(([label, key, type]) => type === "textarea" ? textarea(label, key, setting(s, key)) : input(label, key, setting(s, key), type || "text")).join("")}<button>Save Settings</button></form>`;
renderAdmin(req, res, title, active, body);
}
function saveSettings(req, res, redirect, action) {
Object.entries(req.body).filter(([k]) => k !== "_csrf").forEach(([key, value]) => setSetting(key, value));
audit(req.admin.id, action, "", req.ip);
res.redirect(`/admin/${redirect}`);
}
app.get("/admin/navigation", requireAdmin, (req, res) => {
const rows = db.prepare("SELECT * FROM navigation ORDER BY display_order, id").all();
const body = `<section class="panel">${rows.map((n) => `<form method="post" action="/admin/navigation/${n.id}" class="inline-grid">${csrf(res)}${input("Label", "label", n.label)}${input("Link", "url", n.url)}${select("Kind", "kind", ["internal", "external"], n.kind)}${input("Order", "display_order", n.display_order, "number")}${checkboxInput("Show", "is_published", n.is_published)}${checkboxInput("Open in new tab", "opens_new_tab", n.opens_new_tab)}<button>Save Link</button></form>`).join("")}<form method="post" action="/admin/navigation/new">${csrf(res)}<button>Add Navigation Link</button></form></section>`;
renderAdmin(req, res, "Navigation Manager", "navigation", body);
});
app.post("/admin/navigation/new", requireAdmin, (req, res) => {
db.prepare("INSERT INTO navigation (label,url,kind,display_order,is_published) VALUES ('New Link','/','internal',100,0)").run();
res.redirect("/admin/navigation");
});
app.post("/admin/navigation/:id", requireAdmin, (req, res) => {
const url = String(req.body.url || "/");
if (url.startsWith("/admin")) return res.status(400).send("Administration links cannot be added to the public menu.");
db.prepare("UPDATE navigation SET label=?, url=?, kind=?, display_order=?, is_published=?, opens_new_tab=? WHERE id=?").run(req.body.label, url, req.body.kind, Number(req.body.display_order || 0), checkbox(req.body.is_published), checkbox(req.body.opens_new_tab), req.params.id);
res.redirect("/admin/navigation");
});
app.get("/admin/security", requireAdmin, (req, res) => {
renderAdmin(req, res, "User and Security Settings", "security", `<section class="panel"><h2>Change Password</h2><form method="post" class="editor-form">${csrf(res)}<label>Current password <input type="password" name="current_password" required></label><label>New password <input type="password" name="new_password" minlength="10" required></label><button>Change Password</button></form></section><section class="panel"><h2>Security Included</h2><ul><li>Secure password hashing</li><li>Session expiration</li><li>Login rate limiting</li><li>CSRF protection</li><li>Upload validation</li><li>Audit log for important changes</li></ul></section>`);
});
app.post("/admin/security", requireAdmin, async (req, res) => {
const row = db.prepare("SELECT * FROM administrators WHERE id=?").get(req.admin.id);
if (!(await bcrypt.compare(req.body.current_password || "", row.password_hash))) return res.status(400).send("Current password was not correct.");
if (String(req.body.new_password || "").length < 10) return res.status(400).send("Use at least 10 characters.");
db.prepare("UPDATE administrators SET password_hash=?, updated_at=? WHERE id=?").run(await bcrypt.hash(req.body.new_password, 12), now(), req.admin.id);
audit(req.admin.id, "Changed password", "", req.ip);
res.redirect("/admin/security");
});
app.get("/admin/backups", requireAdmin, (req, res) => {
const rows = db.prepare("SELECT * FROM backups ORDER BY created_at DESC").all();
renderAdmin(req, res, "Backup and Restore", "backups", `<section class="panel"><form method="post" action="/admin/backups/create">${csrf(res)}<button>Create Manual Backup</button></form><p>Backups include the SQLite database and uploaded images. Secret environment files are not included.</p></section><div class="table-list">${rows.map((b) => `<article><h2>${escapeHtml(b.file_name)}</h2><p>${bytes(b.size_bytes)} · ${escapeHtml(b.created_at)}</p><a class="button" href="/admin/backups/${b.id}/download">Download Backup</a></article>`).join("") || "<p>No backups yet.</p>"}</div>`);
});
app.post("/admin/backups/create", requireAdmin, async (req, res, next) => {
try {
const backup = await createBackup();
audit(req.admin.id, "Created backup", backup.file_name, req.ip);
res.redirect("/admin/backups");
} catch (e) { next(e); }
});
app.get("/admin/backups/:id/download", requireAdmin, (req, res) => {
const backup = db.prepare("SELECT * FROM backups WHERE id=?").get(req.params.id);
if (!backup || !fs.existsSync(backup.file_path)) return res.status(404).send("Backup not found");
res.download(backup.file_path, backup.file_name);
});
async function createBackup() {
fs.mkdirSync(config.backupDir, { recursive: true });
const fileName = `debbie-windler-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.zip`;
const filePath = path.join(config.backupDir, fileName);
await new Promise((resolve, reject) => {
const output = fs.createWriteStream(filePath);
const archive = archiver("zip", { zlib: { level: 9 } });
output.on("close", resolve);
archive.on("error", reject);
archive.pipe(output);
archive.file(config.databasePath, { name: "site.sqlite" });
if (fs.existsSync(config.uploadDir)) archive.directory(config.uploadDir, "uploads");
archive.finalize();
});
const stat = fs.statSync(filePath);
const result = db.prepare("INSERT INTO backups (file_name, file_path, size_bytes, created_at) VALUES (?, ?, ?, ?)").run(fileName, filePath, stat.size, now());
return db.prepare("SELECT * FROM backups WHERE id=?").get(result.lastInsertRowid);
}
app.use((err, req, res, next) => {
console.error(err);
const message = config.production ? "Something went wrong. Please try again." : err.message;
res.status(500).send(`<h1>Something went wrong</h1><p>${escapeHtml(message)}</p>`);
});
if (require.main === module) {
app.listen(config.port, () => {
console.log(`Debbie Windler Seamstress running at http://localhost:${config.port}`);
});
}
module.exports = { app, createBackup };