${escapeHtml(m.title || m.original_name)}
${escapeHtml(m.url)} ยท ${bytes(m.size_bytes)}
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..e5f33d9
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,11 @@
+.env
+.git
+.gitignore
+node_modules
+storage
+public/uploads/*
+!public/uploads/.gitkeep
+!public/uploads/sample-hero.png
+npm-debug.log*
+server.*.log
+*.pid
diff --git a/.env.example b/.env.example
index dac3c04..ed3fba9 100644
--- a/.env.example
+++ b/.env.example
@@ -7,6 +7,13 @@ SECONDARY_DOMAIN=debbiewindler.com
# Generate a long random value before production.
SESSION_SECRET=replace-with-a-long-random-secret
+# Required before the first owner account can be created.
+# Open /admin/setup?token=the-value-here during first setup.
+SETUP_TOKEN=replace-with-a-long-random-one-time-setup-token
+
+# Use "loopback" when a local reverse proxy such as Caddy/Nginx forwards to Node.
+TRUST_PROXY=loopback
+
# SQLite and local file storage.
DATABASE_PATH=./storage/site.sqlite
UPLOAD_DIR=./public/uploads
diff --git a/.gitignore b/.gitignore
index 1c537fa..67ae671 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,8 @@ node_modules/
storage/*.sqlite
storage/*.sqlite-shm
storage/*.sqlite-wal
+storage/*.pid
+storage/*.log
storage/backups/*.zip
public/uploads/*
!public/uploads/.gitkeep
diff --git a/README.md b/README.md
index 5483083..b86ebd7 100644
--- a/README.md
+++ b/README.md
@@ -46,8 +46,8 @@ If `pnpm` is not installed globally, `npm install` and `npm run dev` also work.
## First Owner Setup
1. Start the site.
-2. Open `http://localhost:3000/admin`.
-3. The app will redirect to `/admin/setup` until the first owner account is created.
+2. Set a private `SETUP_TOKEN` in `.env`.
+3. Open `http://localhost:3000/admin/setup?token=YOUR_SETUP_TOKEN`.
4. Enter owner name, email, and a password with at least 10 characters.
5. After setup, future visits to `/admin` use the owner login screen.
@@ -92,6 +92,8 @@ Important values:
- `PORT`: app port, default `3000`.
- `APP_BASE_URL`: final public URL, for example `https://debbiewindlerseamstress.com`.
- `SESSION_SECRET`: long random secret for sessions.
+- `SETUP_TOKEN`: private one-time setup token required before the first owner account can be created.
+- `TRUST_PROXY`: use `loopback` for a local reverse proxy; avoid broad proxy trust unless the proxy is configured to strip client-supplied forwarding headers.
- `DATABASE_PATH`: SQLite file path.
- `UPLOAD_DIR`: uploaded image folder.
- `BACKUP_DIR`: backup output folder.
@@ -123,7 +125,8 @@ Recommended production shape:
2. Put a reverse proxy such as Caddy, Nginx, or IIS ARR in front of it.
3. Configure TLS for `debbiewindlerseamstress.com`.
4. Later, redirect `debbiewindler.com` to `debbiewindlerseamstress.com`.
-5. Set `NODE_ENV=production`, `APP_BASE_URL`, `SESSION_SECRET`, SMTP values, and owner email in `.env`.
+5. Set `NODE_ENV=production`, `APP_BASE_URL`, `SESSION_SECRET`, `SETUP_TOKEN`, `TRUST_PROXY`, SMTP values, and owner email in `.env`.
+6. Create the first owner account using `/admin/setup?token=YOUR_SETUP_TOKEN`.
This project does not make DNS, router, firewall, reverse proxy, or live production changes.
@@ -185,6 +188,7 @@ Manual checklist before going live:
## Security Checklist
- Set a long `SESSION_SECRET`.
+- Set a private `SETUP_TOKEN` before first boot in production.
- Use HTTPS in production.
- Keep `.env` private.
- Use a strong owner password.
diff --git a/package.json b/package.json
index 7d1af7f..5258f22 100644
--- a/package.json
+++ b/package.json
@@ -18,8 +18,9 @@
"bcryptjs": "^2.4.3",
"express": "^4.19.2",
"express-session": "^1.18.1",
+ "helmet": "^8.3.0",
"multer": "^2.2.0",
- "nodemailer": "^6.9.14",
+ "nodemailer": "^9.0.3",
"sanitize-html": "^2.13.0",
"sharp": "^0.33.5",
"slugify": "^1.6.6"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cb1433c..5c8bf00 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -20,12 +20,15 @@ importers:
express-session:
specifier: ^1.18.1
version: 1.19.0
+ helmet:
+ specifier: ^8.3.0
+ version: 8.3.0
multer:
specifier: ^2.2.0
version: 2.2.0
nodemailer:
- specifier: ^6.9.14
- version: 6.10.1
+ specifier: ^9.0.3
+ version: 9.0.3
sanitize-html:
specifier: ^2.13.0
version: 2.17.6
@@ -572,6 +575,10 @@ packages:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
+ helmet@8.3.0:
+ resolution: {integrity: sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==}
+ engines: {node: '>=18.0.0'}
+
htmlparser2@12.0.0:
resolution: {integrity: sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==}
engines: {node: '>=20.19.0'}
@@ -695,8 +702,8 @@ packages:
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
engines: {node: '>= 0.6'}
- nodemailer@6.10.1:
- resolution: {integrity: sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==}
+ nodemailer@9.0.3:
+ resolution: {integrity: sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==}
engines: {node: '>=6.0.0'}
normalize-path@3.0.0:
@@ -1487,6 +1494,8 @@ snapshots:
dependencies:
function-bind: 1.1.2
+ helmet@8.3.0: {}
+
htmlparser2@12.0.0:
dependencies:
domelementtype: 3.0.0
@@ -1585,7 +1594,7 @@ snapshots:
negotiator@0.6.3: {}
- nodemailer@6.10.1: {}
+ nodemailer@9.0.3: {}
normalize-path@3.0.0: {}
diff --git a/src/config.js b/src/config.js
index e91a6e1..5a7c523 100644
--- a/src/config.js
+++ b/src/config.js
@@ -12,14 +12,30 @@ function fromRoot(value, fallback) {
return path.isAbsolute(selected) ? selected : path.join(root, selected);
}
+const env = process.env.NODE_ENV || "development";
+const production = env === "production";
+const sessionSecret = process.env.SESSION_SECRET || "";
+const setupToken = process.env.SETUP_TOKEN || "";
+
+if (production) {
+ if (!sessionSecret || sessionSecret === "development-only-change-me" || sessionSecret.length < 32) {
+ throw new Error("Refusing to start in production without a strong SESSION_SECRET of at least 32 characters.");
+ }
+ if (!setupToken || setupToken.length < 24) {
+ throw new Error("Refusing to start in production without a SETUP_TOKEN of at least 24 characters.");
+ }
+}
+
module.exports = {
root,
- env: process.env.NODE_ENV || "development",
+ env,
port: Number(process.env.PORT || 3000),
baseUrl: process.env.APP_BASE_URL || "http://localhost:3000",
primaryDomain: process.env.PRIMARY_DOMAIN || "debbiewindlerseamstress.com",
secondaryDomain: process.env.SECONDARY_DOMAIN || "debbiewindler.com",
- sessionSecret: process.env.SESSION_SECRET || "development-only-change-me",
+ sessionSecret: sessionSecret || "development-only-change-me",
+ setupToken,
+ trustProxy: process.env.TRUST_PROXY || "loopback",
databasePath: fromRoot(process.env.DATABASE_PATH, "./storage/site.sqlite"),
uploadDir: fromRoot(process.env.UPLOAD_DIR, "./public/uploads"),
backupDir: fromRoot(process.env.BACKUP_DIR, "./storage/backups"),
@@ -37,5 +53,5 @@ module.exports = {
siteKey: process.env.CAPTCHA_SITE_KEY || "",
secretKey: process.env.CAPTCHA_SECRET_KEY || ""
},
- production: process.env.NODE_ENV === "production"
+ production
};
diff --git a/src/database.js b/src/database.js
index 6a248dc..e4085ab 100644
--- a/src/database.js
+++ b/src/database.js
@@ -327,8 +327,8 @@ function seed() {
"theme.radius": "8",
"theme.spacing": "comfortable",
"theme.width": "1120",
- "theme.heading_font": "Georgia, 'Times New Roman', serif",
- "theme.body_font": "Arial, Helvetica, sans-serif",
+ "theme.heading_font": "serif",
+ "theme.body_font": "sans",
"theme.footer_style": "light",
"home.hero.title": "Debbie Windler Seamstress",
"home.hero.subtitle": "Alterations, custom sewing, embroidery, and handmade pieces, with sample wording ready for Debbie to replace.",
diff --git a/src/render.js b/src/render.js
index f83e45a..037f125 100644
--- a/src/render.js
+++ b/src/render.js
@@ -1,4 +1,12 @@
-const { escapeHtml, cleanRichText, bytes } = require("./utils");
+const {
+ escapeHtml,
+ cleanRichText,
+ bytes,
+ safeCssColor,
+ safeCssNumber,
+ safeCssKeyword,
+ safeCssUrl
+} = require("./utils");
function setting(settings, key, fallback = "") {
return settings[key] ?? fallback;
@@ -130,20 +138,30 @@ function authLayout(title, body) {
function themeVars(settings) {
const keys = {
- "--bg": ["theme.bg", "#fffaf5"],
- "--bg-alt": ["theme.bg_alt", "#f4e8df"],
- "--text": ["theme.text", "#342b28"],
- "--heading": ["theme.heading", "#2d2522"],
- "--link": ["theme.link", "#7d3f45"],
- "--button": ["theme.button", "#7d3f45"],
- "--button-text": ["theme.button_text", "#ffffff"],
- "--accent": ["theme.accent", "#7a8f73"],
- "--radius": ["theme.radius", "8"],
- "--site-width": ["theme.width", "1120"]
+ "--bg": ["theme.bg", "#fffaf5", "color"],
+ "--bg-alt": ["theme.bg_alt", "#f4e8df", "color"],
+ "--text": ["theme.text", "#342b28", "color"],
+ "--heading": ["theme.heading", "#2d2522", "color"],
+ "--link": ["theme.link", "#7d3f45", "color"],
+ "--button": ["theme.button", "#7d3f45", "color"],
+ "--button-text": ["theme.button_text", "#ffffff", "color"],
+ "--accent": ["theme.accent", "#7a8f73", "color"],
+ "--radius": ["theme.radius", "8", "number"],
+ "--site-width": ["theme.width", "1120", "width"]
};
- const css = Object.entries(keys).map(([name, [key, fallback]]) => `${name}:${escapeHtml(setting(settings, key, fallback))}${name === "--radius" || name === "--site-width" ? "px" : ""};`);
- css.push(`--heading-font:${escapeHtml(setting(settings, "theme.heading_font", "Georgia, 'Times New Roman', serif"))};`);
- css.push(`--body-font:${escapeHtml(setting(settings, "theme.body_font", "Arial, Helvetica, sans-serif"))};`);
+ const css = Object.entries(keys).map(([name, [key, fallback, kind]]) => {
+ if (kind === "color") return `${name}:${safeCssColor(setting(settings, key, fallback), fallback)};`;
+ if (kind === "number") return `${name}:${safeCssNumber(setting(settings, key, fallback), Number(fallback), 0, 32)}px;`;
+ return `${name}:${safeCssNumber(setting(settings, key, fallback), Number(fallback), 760, 1600)}px;`;
+ });
+ const fonts = {
+ serif: "Georgia, 'Times New Roman', serif",
+ sans: "Arial, Helvetica, sans-serif",
+ verdana: "Verdana, Geneva, sans-serif",
+ system: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
+ };
+ css.push(`--heading-font:${fonts[safeCssKeyword(setting(settings, "theme.heading_font", "serif"), Object.keys(fonts), "serif")]};`);
+ css.push(`--body-font:${fonts[safeCssKeyword(setting(settings, "theme.body_font", "sans"), Object.keys(fonts), "sans")]};`);
return css.join("");
}
@@ -234,8 +252,8 @@ function contactForm({ csrfToken, item = "", service = "", success = false, sett
function sectionHtml(section) {
if (!section.is_published) return "";
const style = [
- section.background_color ? `background-color:${escapeHtml(section.background_color)}` : "",
- section.background_url ? `background-image:url('${escapeHtml(section.background_url)}')` : ""
+ section.background_color ? `background-color:${safeCssColor(section.background_color, "transparent")}` : "",
+ safeCssUrl(section.background_url) ? `background-image:url('${escapeHtml(safeCssUrl(section.background_url))}')` : ""
].filter(Boolean).join(";");
const image = section.image_url ? `` : "";
const content = `
Owner setup requires the private setup link or setup token from the server administrator.
")); +} + function navLinks() { return db.prepare("SELECT * FROM navigation WHERE is_published = 1 ORDER BY display_order, id").all(); } @@ -295,9 +347,15 @@ app.get("/", (req, res) => { 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 overlay = safeCssKeyword(setting(settings, "home.hero.overlay", "light"), ["light", "dark", "none"], "light"); + const heroImage = safeCssUrl(setting(settings, "home.hero.image", "")); + const heroPosition = safeCssKeyword(setting(settings, "home.hero.position", "center"), ["center", "top", "bottom", "left", "right"], "center"); + const heroSize = safeCssKeyword(setting(settings, "home.hero.size", "cover"), ["cover", "contain", "auto"], "cover"); + const heroOpacity = safeCssNumber(setting(settings, "home.hero.overlay_opacity", "0.28"), 0.28, 0, 0.9); + const heroTextColor = safeCssColor(setting(settings, "home.hero.text_color", "#2d2522"), "#2d2522"); + const highlightColor = safeCssColor(setting(settings, "home.highlight.background_color", "#f4e8df"), "#f4e8df"); const body = ` -Showcase
Create the first owner login. No password is stored as plain text.
`)); }); -app.post("/admin/setup", async (req, res) => { +app.post("/admin/setup", requireSetupToken, 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."); @@ -732,13 +792,13 @@ app.get("/admin/items/:id/edit", requireAdmin, (req, res) => { 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) => { +app.post("/admin/items/new", requireAdmin, upload.array("images", 8), csrfCheck, 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) => { +app.post("/admin/items/:id/edit", requireAdmin, upload.array("images", 8), csrfCheck, async (req, res, next) => { try { await saveItem(req, req.params.id); res.redirect(`/admin/items/${req.params.id}/edit`); @@ -835,10 +895,10 @@ 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) => { +app.post("/admin/services/new", requireAdmin, upload.single("image"), csrfCheck, 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) => { +app.post("/admin/services/:id/edit", requireAdmin, upload.single("image"), csrfCheck, 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) => { @@ -950,7 +1010,7 @@ app.get("/admin/media", requireAdmin, (req, res) => {${escapeHtml(m.url)} ยท ${bytes(m.size_bytes)}