# The Stitching Beehive — Remediation TODO & Roadmap Read this first: - **Part A** is the ledger of the original remediation (TODO-01 … TODO-21): what's done, and what's still open or was re-opened. The full original specs live in git history (`git show 5761435:TODO.md`); the numbers are kept because `LEARNING-GUIDE.md` refers to them. - **Part B** is the active roadmap. Work the phases in order; items list dependencies. Every concrete item has a **Done when** section — do not mark an item complete until every check passes. - Items marked **[HUMAN]** must be done by a person, not an agent. - Test commands assume the repo root as working directory. The app listens on `http://localhost:5000` with `dotnet run`; production runs via `docker compose up -d` on a Linux VPS (see `README.md`). - `LEARNING-GUIDE.md` explains the *why* — Part 1 for TODO-01…21, Part 2 (P1…P6) for the lessons behind several items below. --- # Part A — Original remediation: status ledger | Item | Summary | Status | |---|---|---| | TODO-01 | Fix duplicate response-header crash in production | ✅ Done | | TODO-02 | Stop deleting the database on every startup | ✅ Done | | TODO-03 | Replace hand-rolled admin auth with cookie authentication | ✅ Done | | TODO-04 | Move secrets out of `appsettings.json` | ⚠️ Done, then **regressed** — admin password was re-committed (see TODO-23) | | TODO-05 | Replace regex HTML sanitizer with HtmlSanitizer library | ✅ Done | | TODO-06 | Delete unused static website templates | ✅ Done | | TODO-07 | Stop tracking generated binaries in git | ✅ Done | | TODO-08 | Remove dead code | ✅ Done | | TODO-09 | Port ImageService off System.Drawing to ImageSharp | ✅ Done | | TODO-10 | Upgrade to .NET 10 | ⚠️ Done, then **downgraded to .NET 9** for Visual Studio compatibility; .NET 9 left support May 2026 (see TODO-27) | | TODO-11 | Generate and use gallery thumbnails | ✅ Done | | TODO-12 | Introduce EF Core migrations | ✅ Done | | TODO-13 | Prune fake seed data | ✅ Done | | TODO-14 | Store tip/service content as HTML in the database | ✅ Done | | TODO-15 | Replace loose `.txt` site-content files with a database table | ✅ Done | | TODO-16 | Small schema hardening | ✅ Done | | TODO-17 | Move mutable data out of the deploy directory | ✅ Done | | TODO-18 | Vendor frontend libraries locally, unify Bootstrap | ✅ Done (a leftover csproj exclusion broke publishing; fixed — see Learning Guide P1) | | TODO-19 | Add analyzers and `.editorconfig` | ✅ Done | | TODO-20 | Add GitHub Actions CI workflow | ✅ Done (gaps remain — see TODO-28) | | TODO-21 | Multi-stage Dockerfile + fixed docker-compose | ✅ Done, variant: `Dockerfile.linux` is production; `Dockerfile` is VS's Windows-container file for local debugging; `docker-compose.dev.yml` added | | TODO-H1 | Rotate the leaked credentials | ❌ **Still open** — see TODO-22 | | TODO-H2 | Decide on git-history scrubbing | ❌ Still open (decide after TODO-22) | --- # Part B — Active roadmap ## Phase 8 — Security hardening (launch blockers) Everything in this phase should be finished **before** the site is exposed to the internet, except where noted. ### TODO-22: Rotate the leaked credentials [HUMAN] **Depends on:** none — do ASAP. The Gmail app password (account `djterry17@gmail.com`) and the admin password `AdminDebbie2025` were committed to git and remain readable in history. The same admin password was re-committed in June 2026, so it is still the live value. A person must: 1. Revoke the old Gmail app password in the Google account; generate a new one. 2. Choose a **new** admin password (not `AdminDebbie2025` or a variant). 3. Supply both only via the server's `.env` file (see `README.md`) and, locally, via `dotnet user-secrets`. Never commit them. **Done when:** the old Gmail app password no longer authenticates, logging in with `AdminDebbie2025` fails on the deployed site, and `git diff` shows no new secrets. ### TODO-23: Re-blank the admin password in appsettings.json **Files:** `appsettings.json` **Depends on:** TODO-22 (rotate first, or this just gets reverted again). **Do:** Set `AdminSettings:Password` to `""`. The login code already fails closed on an empty configured password ("Admin password is not configured"), and production supplies the real value via `ADMIN_PASSWORD` in `.env`. For local dev: `dotnet user-secrets set "AdminSettings:Password" "..."`. **Done when:** - `grep -n '"Password"' appsettings.json` shows only empty strings. - `dotnet run` + login with the user-secrets password reaches `/Admin/Dashboard`. - `ADMIN_PASSWORD=x docker compose config` still resolves (env path unchanged). ### TODO-24: Trust the reverse proxy; mark cookies Secure **Files:** `Program.cs` **Depends on:** none **Why (one line):** behind Caddy the app sees plain HTTP from localhost, so it never knows requests were HTTPS — auth/session cookies aren't marked `Secure`, and any scheme-dependent logic (HSTS, redirect URLs, logged client IPs) is wrong. **Do:** 1. Add `UseForwardedHeaders` (before auth/anything scheme-aware) handling `XForwardedFor | XForwardedProto`. Caddy runs on the same host, and loopback is a trusted proxy by default, so no extra network config is needed. 2. Set `Cookie.SecurePolicy = CookieSecurePolicy.Always` on the auth cookie and the session cookie (these are only ever used by the admin, over HTTPS in production). 3. Leave HSTS to Caddy or keep `UseHsts()` — with forwarded headers it now actually fires. **Done when:** - `curl -sI -H "X-Forwarded-Proto: https" http://localhost:5000/Admin/Login` → the `Set-Cookie` headers include `secure`. - Local `dotnet run` (Development) still works over plain http. ### TODO-25: Rate-limit the login and contact endpoints **Files:** `Program.cs`, `Controllers/AdminController.cs`, `Controllers/HomeController.cs` **Depends on:** TODO-24 (so the limiter keys on the real client IP, not Caddy's). **Why (one line):** the admin area is a single shared password — without throttling it is an open invitation to brute force; the contact form is the other obvious abuse target. **Do:** Use the built-in rate limiter (`AddRateLimiter` + `app.UseRateLimiter()`): a fixed-window policy of ~5 requests/minute per client IP applied with `[EnableRateLimiting("strict")]` to the Login POST and Contact POST actions. Return 429. **Done when:** - `for i in $(seq 1 10); do curl -s -o /dev/null -w "%{http_code} " -X POST http://localhost:5000/Admin/Login -d "password=x"; done` shows 429s after the first few attempts. - Normal page browsing is unaffected (no limiter on GETs). ### TODO-26: Add a Content-Security-Policy header **Files:** `Program.cs` **Depends on:** none (easier now that all assets are local — TODO-18). **Do:** Add CSP alongside the existing security headers. Start with: `default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; frame-ancestors 'none'` then browse every public and admin page with the browser console open and tighten/loosen based on actual violations (inline scripts may need fixing or a nonce). If anything is unclear, ship it as `Content-Security-Policy-Report-Only` first and promote after a week. **Done when:** - All pages render with zero CSP violations in the browser console. - `curl -sI http://localhost:5000/ | grep -i content-security-policy` shows the policy. ### TODO-27: Upgrade to .NET 10 LTS [partly HUMAN] **Files:** `The Stitching Beehive.csproj`, `Dockerfile`, `Dockerfile.linux`, `.github/workflows/ci.yml`, `dotnet-tools.json` **Depends on:** [HUMAN] update Visual Studio to a version that supports .NET 10 — this is the step that caused the downgrade last time (Learning Guide P4). **Why (one line):** .NET 9 (STS) left support in May 2026; it runs fine but no longer receives security patches, which is not acceptable for an internet-facing site for long. **Do:** Bump `TargetFramework` to `net10.0`, base images to `:10.0`, CI `dotnet-version` to `10.0.x`, and refresh `Microsoft.EntityFrameworkCore.*` to the matching major version. **Done when:** - CI is green and `docker compose build` succeeds on Linux. - `docker compose up -d` serves `/` with 200 and an existing data volume migrates cleanly. ## Phase 9 — Operational hardening ### TODO-28: Make CI catch what actually broke this project **Files:** `.github/workflows/ci.yml` **Depends on:** none **Why (one line):** the wwwroot/lib publish bug (Learning Guide P1) sailed through CI because CI builds but never publishes; the vulnerable-package step also never fails (`dotnet list package --vulnerable` exits 0 even when it finds vulnerabilities). **Do:** 1. Add a publish step: `dotnet publish -c Release -o /tmp/publish` followed by an assertion, e.g. `test -f /tmp/publish/wwwroot/lib/bootstrap/css/bootstrap.min.css` and `test -d /tmp/publish/wwwroot/css`. 2. Make the vulnerability check fail the build: `dotnet list package --vulnerable --include-transitive | tee vulns.txt` then `! grep -q "has the following vulnerable packages" vulns.txt`. 3. Add a job that runs `docker build -f Dockerfile.linux .` so the production image is build-tested on every push. **Done when:** - Temporarily re-adding `` to the csproj makes CI fail; reverting makes it pass. - A deliberately old package version makes the vulnerability step fail. ### TODO-29: Make backups consistent and automatic **Files:** `README.md`, server cron (documented, not committed) **Depends on:** none **Why (one line):** tar-ing a live SQLite database mid-write can produce a corrupt backup; SQLite's own backup command produces a consistent snapshot even while the app runs. **Do:** 1. Replace the README backup one-liner with a consistent snapshot, e.g.: `docker run --rm -v app_data:/data -v /root/backups:/backup alpine sh -c "apk add -q sqlite && sqlite3 /data/StitchingBeehive.db \".backup /backup/beehive-$(date +%F).db\" && tar czf /backup/uploads-$(date +%F).tar.gz -C /data uploads"` 2. Document a nightly cron entry and a retention rule (e.g. keep 14 days). 3. **[HUMAN]** Arrange an off-box copy (rclone to any cloud storage, or even a weekly download) — a backup that lives only on the VPS dies with the VPS. 4. **Test a restore once**: restore into a fresh volume, start the app, see real data. **Done when:** a restore from last night's backup onto a clean volume boots with content intact, and the off-box copy exists. ### TODO-30: Establish a patch cadence for the container **Files:** `README.md` **Depends on:** none **Why (one line):** the base image's OS and .NET runtime patches only reach production when the image is rebuilt — `restart: unless-stopped` keeps the *old* image running forever. **Do:** Document (and put in a calendar) a monthly routine: `git pull && docker compose build --pull && docker compose up -d`, plus `docker image prune -f`. Note that CVE announcements for ASP.NET may warrant doing it immediately. **Done when:** README has an "Updating / patching" section and the first scheduled run has happened. ### TODO-31: Health endpoint and container healthcheck **Files:** `Program.cs`, `docker-compose.yml` **Depends on:** none **Do:** `builder.Services.AddHealthChecks().AddDbContextCheck()` and `app.MapHealthChecks("/healthz")`; add a compose `healthcheck` hitting `http://localhost:8080/healthz` (note: the aspnet base image ships no curl/wget — either install curl in `Dockerfile.linux` or use a tiny `HEALTHCHECK` helper). **Done when:** - `curl -s http://localhost:5000/healthz` returns `Healthy`. - `docker compose ps` shows the service as `healthy`. ### TODO-32: VPS baseline hardening and uptime monitoring [HUMAN] **Depends on:** server exists. 1. SSH: key-only auth, disable root password login. 2. Firewall: allow 22/80/443 only (`ufw`); port 5000 stays closed — Caddy proxies to it on localhost. 3. `unattended-upgrades` (Debian/Ubuntu) for OS security patches; `fail2ban` for SSH. 4. Point a free uptime monitor (e.g. UptimeRobot) at `https://thestitchingbeehive.com/healthz` with email alerts. **Done when:** an external port scan shows only 22/80/443, and stopping the container produces an alert email within minutes. ### TODO-39: Remove the legacy file-based service-content channel **Files:** `Controllers/AdminController.cs`, `Views/Admin/ServiceContentFiles.cshtml`, `Views/Shared/_AdminLayout.cshtml`, `Services/ContentService.cs`, `Program.cs`, `Models/Service.cs` (+ migration) **Depends on:** none (found during a content audit, after the items above were numbered) **Why (one line):** the public site renders service text from the database (`Service.Content`), but the admin still has a linked "Service Content Files" page that reads/writes loose `.txt` files in `wwwroot/content/` via `Service.ContentFileName` — edits made there never appear on the site, and in the production container the save will throw because the app user cannot write inside `/app/wwwroot`. **Do:** 1. Delete the `ServiceContentFiles` (GET/POST) and `UploadServiceContentFile` actions, the `ServiceContentFiles.cshtml` view, and its `_AdminLayout` nav link. 2. Delete `Services/ContentService.cs` and its DI registration in `Program.cs` (`IContentService` has zero callers). 3. Drop `Service.ContentFileName` (property + migration). **Done when:** - `grep -rn "ContentFileName\|IContentService\|ServiceContentFiles" Controllers/ Services/ Views/ Models/ Program.cs` returns nothing. - Admin can still edit service content via the DB-backed editor and the change shows on `/Home/Services`; CI is green. ## Phase 10 — Polish ### TODO-33: Email Debbie when the contact form is submitted **Files:** `Controllers/HomeController.cs` **Depends on:** working SMTP credentials (TODO-22). **Why (one line):** submissions currently land only in the admin panel — nobody is told, so a customer's message can sit unread for weeks. **Do:** After saving the `ContactSubmission`, call the existing `IEmailService` to send a short notification to `EmailSettings:FromEmail`. Wrap in try/catch + log: a mail failure must not break the customer's submission (the row is already saved). **Done when:** submitting the form delivers an email containing the sender's name, email, and message; with SMTP credentials removed, submission still succeeds and the failure is logged. ### TODO-34: Contact form spam protection **Files:** `Views/Home/Contact.cshtml`, `Controllers/HomeController.cs` **Depends on:** TODO-25 (rate limiting is the first layer). **Do:** Add a honeypot: a hidden input (e.g. named `Website`) that humans never fill; if it arrives non-empty, pretend success but discard. Add a minimum-time check (hidden timestamp field; submissions completed in under ~3 seconds are bots). Hold CAPTCHA in reserve — only add one if spam actually gets through, since it costs real users friction. **Done when:** a POST with the honeypot filled returns the success page but creates no row; a normal browser submission still works. ### TODO-35: Fix email deliverability [HUMAN decision + config] **Depends on:** TODO-22. **Why (one line):** the app sends "From: debbier@thestitchingbeehive.com" through smtp.gmail.com authenticated as a different gmail.com account — SPF/DKIM/DMARC alignment fails, so notifications are likely to land in spam or be rejected. **Do (choose one):** - *Easiest:* set `EmailSettings:FromEmail` to the actual Gmail address used to authenticate; or - *Proper:* use the domain's own SMTP (the web host likely provides it) or a free transactional tier (e.g. Brevo), and set SPF + DKIM DNS records for `thestitchingbeehive.com`. **Done when:** a contact-form notification arrives in a Gmail *and* an Outlook inbox (not spam), and `dig TXT thestitchingbeehive.com` shows an SPF record matching the sender. ### TODO-36: robots.txt, sitemap, and per-page metadata **Files:** `wwwroot/robots.txt` (new), static `sitemap.xml` (or a small controller), `Views/Shared/_Layout.cshtml`, individual views **Depends on:** none **Do:** 1. `robots.txt`: allow all, `Disallow: /Admin/`, point at the sitemap. 2. `sitemap.xml` listing the public pages (a static file is fine; regenerate if tips get individual URLs). 3. Unique `` and `<meta name="description">` per public page; Open Graph tags (title/description/image) so shared links look right on Facebook — quilting customers are heavily on Facebook. 4. LocalBusiness JSON-LD (name, address/area, hours, phone) on the contact page for local-search results. **Done when:** `curl -s http://localhost:5000/robots.txt` and `/sitemap.xml` return 200; every public page has a distinct title/description (view source); Facebook's sharing debugger shows a proper preview card. ### TODO-37: Cache public pages **Files:** `Program.cs`, `Controllers/HomeController.cs` **Depends on:** none **Why (one line):** every public page does DB queries on every request; content changes only when Debbie edits it, so even a short cache removes nearly all load. **Do:** Add output caching (`AddOutputCache`/`UseOutputCache`) with a ~5-minute policy tagged `"content"` on the public GET actions (never on `/Admin` or POSTs), and call `IOutputCacheStore.EvictByTagAsync("content", …)` from the admin save paths so edits show immediately. **Done when:** second request to `/` is served from cache (log shows no DB queries); editing the hero text in admin is visible on the public site immediately. ### TODO-38: Accessibility and image polish **Files:** `Models/GalleryItem.cs` (if alt text field missing), admin upload views, public gallery/tip views **Depends on:** none **Do:** 1. Ensure every rendered `<img>` has meaningful `alt` text — give gallery items an AltText/Description field surfaced in the admin form if one doesn't exist. 2. Add `loading="lazy"` and explicit `width`/`height` (or CSS aspect-ratio) to gallery and tip images to stop layout shift. 3. Run the browser's Lighthouse audit; fix anything scored "easy" (contrast, label-for, heading order). **Done when:** Lighthouse accessibility score ≥ 90 on `/` and `/Home/Gallery`, and no `<img>` without `alt` (`grep -rn "<img" Views/ | grep -v alt=` returns nothing). ## Phase 11 — Extension ideas (not scheduled — pick what excites you) These are suggestions, not commitments. Each is independently shippable; if one is picked up, write it as a numbered item with a "Done when" first (that format is why Phases 1–7 worked — see Learning Guide P6). - **Quote request / pricing calculator.** Longarm quilting is normally priced per square inch by pattern density. A small form (width × length × pattern tier) that shows an estimate and pre-fills the contact form would convert browsers into customers and cut back-and-forth email. - **Quilt status tracker.** A customer-visible status per order ("received → quilting → binding → ready for pickup"), looked up by a short code Debbie gives out. Small table, one public page, big customer-service win. - **Appointment / drop-off scheduling.** Even a simple "request a time slot" form that emails Debbie beats phone tag. A full calendar integration is a later step. - **Testimonials section.** Quilting is a trust business; a managed list (admin CRUD + public carousel) is a weekend feature using patterns that already exist in the admin. - **Gallery categories and filtering.** Tag gallery items (pattern type, quilt size) and filter on the public page; the data model change is one column plus a migration. - **Tips RSS feed + newsletter signup.** RSS is nearly free once tips have stable URLs; a newsletter can start as "collect emails into a table, export CSV" — no service needed until the list is real. - **Admin quality-of-life.** Bulk image upload, drag-and-drop gallery ordering, and an image picker that shows existing uploads — the current admin makes Debbie do one-at-a-time uploads. - **Privacy-friendly analytics.** A single `<script>` from Plausible or a self-hosted GoatCounter container answers "is anyone visiting?" without cookie banners or Google. --- ## Suggested order of attack 1. **Now (before launch):** TODO-22 → 23, then 24 → 25 → 26, with 33/34 close behind (the contact form is the whole point of the site). 2. **Launch week:** TODO-28…32 and 39 — CI, backups, patching, health, VPS hardening, and removing the broken legacy admin page. 3. **First month:** TODO-27 (.NET 10, once Visual Studio is updated), 35, 36, 37, 38. 4. **Whenever motivation strikes:** Phase 11, one item at a time, spec first.