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 `
${artwork ? `` : ""}

Website maintenance

${escapeHtml(setting(settings, "business.name", "Debbie Windler Seamstress"))}

${escapeHtml(setting(settings, "maintenance.message", "The website is getting a careful refresh. Please check back soon."))}

${setting(settings, "maintenance.return_note", "") ? `

${escapeHtml(setting(settings, "maintenance.return_note"))}

` : ""}
`; } 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 = `

${escapeHtml(setting(settings, "business.tagline", ""))}

${escapeHtml(setting(settings, "home.hero.title", "Debbie Windler Seamstress"))}

${escapeHtml(setting(settings, "home.hero.subtitle", ""))}

${escapeHtml(setting(settings, "home.hero.contact", ""))}

${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")}

Showcase

${escapeHtml(setting(settings, "home.highlight.title", "Featured and Newly Added"))}

${escapeHtml(setting(settings, "home.highlight.intro", ""))}

${featured.map(itemCard).join("")}
${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 = `

Items Available and Custom Orders

Browse sample listings alphabetically. Every item can be edited, hidden, marked new, put on sale, or offered as a custom order.

${alphabet.length > 6 ? `
${alphabet.map((letter) => `${letter}`).join("")}
` : ""}
${items.map(itemCard).join("") || "

No items match that search yet.

"}
`; 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 = `
${item.image_url ? `${escapeHtml(item.image_alt || item.name)}` : `
`}

${escapeHtml(item.category_name || "Item")}

${escapeHtml(item.name)}

${escapeHtml(item.availability_status)}

${item.contact_for_pricing ? "Contact for pricing" : escapeHtml(item.price || item.starting_price || item.price_range || "")}

${cleanRichText(item.full_description || item.short_description || "")} ${item.material_notes ? `

Materials

${escapeHtml(item.material_notes)}

` : ""} ${item.color_size_notes ? `

Color or Size Notes

${escapeHtml(item.color_size_notes)}

` : ""} ${item.care_instructions ? `

Care Instructions

${escapeHtml(item.care_instructions)}

` : ""} ${button("Ask About This Item", `/about-contact?item=${encodeURIComponent(item.name)}#contact-form`)}
${gallery.length ? `` : ""}`; 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 = `

${escapeHtml(page.title)}

${escapeHtml(page.intro || "")}

${services.map(serviceCard).join("")}
${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 = `

${escapeHtml(page.title)}

${escapeHtml(page.intro || "")}

${sections.map(sectionHtml).join("")}

Equipment

Sewing Room and Equipment

${equipment.map((e) => `
${e.image_url ? `${escapeHtml(e.image_alt || e.name)}` : `
`}

${escapeHtml(e.name)}

${escapeHtml([e.brand, e.model].filter(Boolean).join(" "))}

${escapeHtml(e.description || "")}

`).join("")}

Contact Information

Email
${escapeHtml(setting(settings, "business.email", "To be supplied"))}
Phone
${escapeHtml(setting(settings, "business.phone", "To be supplied"))}
Service area
${escapeHtml(setting(settings, "business.service_area", "To be supplied"))}
Hours
${escapeHtml(setting(settings, "business.hours", "By appointment"))}
${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(`${urls.map((url) => `${escapeHtml(config.baseUrl + url)}`).join("")}`); }); 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", `

Create the first owner login. No password is stored as plain text.

`)); }); 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", `
`)); }); 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 = `
${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]) => `
${escapeHtml(value)}${label}
`).join("")}
${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")}

Recent Messages

${messages.map((m) => `

${escapeHtml(m.subject)}
${escapeHtml(m.name)} · ${escapeHtml(m.created_at)}

`).join("") || "

No messages yet.

"}

Recent Website Changes

${changes.map((c) => `

${escapeHtml(c.action)}
${escapeHtml(c.created_at)}

`).join("") || "

No changes yet.

"}
`; renderAdmin(req, res, "Dashboard", "dashboard", body); }); app.get("/admin/home", requireAdmin, (req, res) => { const s = allSettings(); const body = `
${csrf(res)}

Hero Area

${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"))}

Featured and Newly Added Area

${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")}
Preview Home Page
`; 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", `
${pages.map((p) => `

${escapeHtml(p.title)}

${escapeHtml(p.intro || "")}

Edit PagePreview
`).join("")}
`); }); 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 = `
${csrf(res)}
${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)}
Preview Page

Content Sections

${sections.map((section) => sectionForm(section, media, res.locals.csrfToken, `/admin/pages/${page.slug}/sections/${section.id}`)).join("")}
${csrf(res)}
`; 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', '

Edit this section.

', '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 ``; } function input(label, name, value = "", type = "text", min = "", max = "", step = "") { return ``; } function textarea(label, name, value = "") { return ``; } 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 ``; }).join(""); return ``; } function checkboxInput(label, name, checked) { return ``; } function sectionForm(section, media, csrfToken, action) { return `
${escapeHtml(section.title || "Untitled section")} ${section.is_published ? "" : "(hidden)"}
${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)} ${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)}
`; } 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 = `
Add Item
${items.map((i) => `

${escapeHtml(i.name)}

${escapeHtml(i.category_name || "")} · ${escapeHtml(i.availability_status)} ${i.is_published ? "" : "· Hidden"}

EditPreview
`).join("")}
`; 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 `
${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)}
${values.id ? `
` : ""}`; } 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", `

Add Service

${services.map((s) => `

${escapeHtml(s.name)}

${s.is_published ? "Published" : "Hidden"} · Order ${s.display_order}

Edit
`).join("")}
`); }); 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 `
${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)}
${s.id ? `
` : ""}`; } app.get("/admin/about-equipment", requireAdmin, (req, res) => { const equipment = db.prepare("SELECT * FROM equipment ORDER BY display_order, name").all(); const body = `

Edit About Page Text

Equipment

${equipment.map((e) => equipmentForm(e, res.locals.csrfToken)).join("")}
${csrf(res)}
`; 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 `
${escapeHtml(e.name)} ${e.is_published ? "" : "(hidden)"}
${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)}
`; } 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 = `
Export CSVAllUnreadArchived
${rows.map((m) => `

${escapeHtml(m.subject)}

${escapeHtml(m.name)} · ${escapeHtml(m.email)} · ${escapeHtml(m.created_at)} · ${escapeHtml(m.status)}

`).join("") || "

No messages here.

"}
`; 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", `

From: ${escapeHtml(m.name)} · ${escapeHtml(m.email)} · ${escapeHtml(m.phone || "")}

Preferred: ${escapeHtml(m.preferred_contact || "")}

Related: ${escapeHtml(m.related_item || "")}

Source: ${escapeHtml(m.source_page || "")}

Email notice: ${escapeHtml(m.email_notification_status)} ${m.email_error ? `- ${escapeHtml(m.email_error)}` : ""}

${escapeHtml(m.message)}
${csrf(res)}Reply by Email
`); }); 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 = `
${csrf(res)}${input("Image title", "title", "")}${input("Alt text", "alt", "")}${input("Folder or category", "category", "")}
${mediaGrid(media)}
${media.map((m) => `

${escapeHtml(m.title || m.original_name)}

${escapeHtml(m.url)} · ${bytes(m.size_bytes)}

${csrf(res)}${input("Title", "title", m.title || "")}${input("Alt text", "alt_text", m.alt_text || "")}${input("Folder", "category", m.category || "")}
${csrf(res)}
`).join("")}
`; 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 = `
${csrf(res)}${fields.map(([label, key, type]) => type === "textarea" ? textarea(label, key, setting(s, key)) : input(label, key, setting(s, key), type || "text")).join("")}
`; 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 = `
${rows.map((n) => `
${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)}
`).join("")}
${csrf(res)}
`; 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", `

Change Password

${csrf(res)}

Security Included

`); }); 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", `
${csrf(res)}

Backups include the SQLite database and uploaded images. Secret environment files are not included.

${rows.map((b) => `

${escapeHtml(b.file_name)}

${bytes(b.size_bytes)} · ${escapeHtml(b.created_at)}

Download Backup
`).join("") || "

No backups yet.

"}
`); }); 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(`

Something went wrong

${escapeHtml(message)}

`); }); if (require.main === module) { app.listen(config.port, () => { console.log(`Debbie Windler Seamstress running at http://localhost:${config.port}`); }); } module.exports = { app, createBackup };