From 830eb4f6dcb511105e3e3d5ae321cfa6da0bda41 Mon Sep 17 00:00:00 2001 From: Jim Basso Date: Thu, 11 Jun 2026 15:50:18 -0500 Subject: [PATCH] Add TODO.md backlog and docs/DESIGN.md (original plan) Track the project backlog in-repo and preserve the original pre-implementation design with a note on where the build diverged. Co-Authored-By: Claude Opus 4.8 --- TODO.md | 28 +++++++++++++ docs/DESIGN.md | 108 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 TODO.md create mode 100644 docs/DESIGN.md diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..e5a140b --- /dev/null +++ b/TODO.md @@ -0,0 +1,28 @@ +# WishNinja — TODO + +Lightweight backlog for the project. Check items off as they land; move to Gitea Issues if the +list outgrows a single file. + +## Cleanup +- [ ] Remove dead `IdentityNoOpEmailSender.cs` (leftover from the Blazor template scaffold; the + real sender is `Services/EmailSender.cs`). + +## Docs +- [ ] Document deployment in the README: Gitea Actions runner setup (`catthehacker` label mapping), + Unraid container env vars, and the **Nginx Proxy Manager WebSocket toggle** required for Blazor. + +## Features / polish +- [ ] Let users edit their own **display name** (Account → Manage). +- [ ] Tag releases (`v0.1.0`, …) so CI also publishes versioned images alongside `:latest`. +- [ ] Blazor reconnection UX polish (custom reconnect modal copy). + +## Security (revisit if onboarding ever opens beyond a trusted group) +- [ ] Add an SSRF guard to the image-URL fetch (`ImageService.SaveFromUrlAsync`) to block + private/loopback IP ranges. Low risk while accounts are admin-invite-only. + +## Done (for reference) +- [x] Initial build: Blazor + EF Core/SQLite + Identity, owner-hidden claims, admin invites. +- [x] Dockerfile, docker-compose, Gitea Actions CI (test + push image), Unraid template. +- [x] Deployed to Unraid behind Nginx Proxy Manager at https://wish.basso.land. +- [x] Fix: SQLite can't `ORDER BY DateTimeOffset` — order by `Id` instead. +- [x] All item images stored locally (URL inputs downloaded server-side). diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..c13194f --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,108 @@ +# WishNinja — Design (original plan) + +> **Status:** This is the original pre-implementation design, kept for posterity. The build +> followed it closely, but a few details evolved during implementation. For the current state of +> the code, the README and the git history are authoritative. Notable deltas from this document: +> +> - **Item images are always stored locally.** Pasted URLs are downloaded server-side and saved as +> files; the `ImageKind.ExternalUrl` value and `WishlistItem.ImageUrl` column were removed. +> - **Ordering:** list queries order by `Id` (not `CreatedAt`), because SQLite cannot `ORDER BY` +> a `DateTimeOffset` column. +> - **Connection string:** derived from `WishNinja__DataPath` (default `/data`); override via +> `ConnectionStrings__DefaultConnection` (not `ConnectionStrings__Default`). +> - **CI registry auth:** uses a `REGISTRY_TOKEN` PAT secret, not the auto-provided token. +> - **Seeder:** implemented as `Services/StartupInitializer.cs` (migrate + seed roles + admin), +> not `AdminSeeder.cs`. +> - **Container** runs as root for hassle-free Unraid bind-mount permissions (the "non-root" note +> below was not adopted). + +--- + +## Context + +The user runs an Unraid home server hosting many self-hosted apps and wants a **new gift +wishlist manager** ("WishNinja") that ships as a **Docker image** and lives in their +self-hosted **Gitea** instance. The working directory was empty — a clean greenfield build. + +The app lets a closed group (family/friends) create wishlists, add items with links/prices/ +images, and **claim/reserve gifts so the owner never sees what's been claimed** (preserving the +surprise) while other viewers do (avoiding duplicate gifts). Users are managed internally with +email-based password resets. + +### Confirmed decisions +- **Stack:** ASP.NET Core **Blazor Web App** (interactive **server** render mode), **.NET 10 LTS**. +- **Persistence:** **SQLite** via **EF Core**, single DB file on a mounted volume. +- **Auth:** **ASP.NET Core Identity** with internal accounts + email password resets. +- **Onboarding:** **Admin invites only** (no open self-registration). +- **Claim privacy:** **Claims hidden from the list owner**; visible to other viewers. +- **Item images:** **file upload + clipboard paste** (both stored locally on `/data`) **+ external URL**. +- **Delivery:** Docker image, built via Gitea Actions → Gitea container registry, run on Unraid. + +--- + +## Architecture & Stack + +| Concern | Choice | +|---|---| +| Framework | ASP.NET Core 10 (LTS), Blazor Web App, InteractiveServer render mode | +| ORM / DB | EF Core 10 + SQLite (`Microsoft.EntityFrameworkCore.Sqlite`) | +| Identity | ASP.NET Core Identity (cookie auth, roles: `Admin`, `User`) | +| Email | `MailKit` for SMTP (password reset + invite emails) | +| Data Protection | Keys persisted to `/data/keys` so cookies/antiforgery survive restarts | +| Image storage | Local files under `/data/uploads`; served via a static-files mapping | +| Container base | `mcr.microsoft.com/dotnet/aspnet:10.0` (Debian slim), built from `sdk:10.0` | + +**Why Blazor Server interactivity:** all-C#, single container, no separate JS build, live claim +buttons. Reverse proxy must allow **WebSocket upgrades** (SignalR) — documented in the README. + +--- + +## Data Model (EF Core entities) + +- **ApplicationUser : IdentityUser** — adds `DisplayName`, `CreatedAt`. +- **Wishlist** — `Id`, `OwnerId` (FK user), `Title`, `Description`, `Visibility` + (`AllMembers` | `SpecificUsers`), `IsArchived`, `CreatedAt`. +- **WishlistShare** — `WishlistId`, `UserId` (rows only when `Visibility = SpecificUsers`). +- **WishlistItem** — `Id`, `WishlistId`, `Name`, `Description`, `ProductUrl`, `ImageKind`, + `ImagePath`, `Price`, `Priority`, `Quantity`, `SortOrder`, `CreatedAt`. +- **Claim** — `Id`, `WishlistItemId`, `ClaimedByUserId`, `Quantity`, `Note`, `CreatedAt`. +- **Invite** — `Id`, `Email`, `TokenHash`, `Role`, `InvitedByUserId`, `ExpiresAt`, `AcceptedAt`. + +EF migrations applied automatically at startup for friction-free self-hosting. + +--- + +## Core Features + +### Wishlists & items +- CRUD wishlists; set visibility (all members / specific users via `WishlistShare`). +- CRUD items with name, description, product link, price, priority, quantity, image. +- Item images via three inputs, all stored locally: + - **File upload** → saved to `/data/uploads`, `ImageKind=Uploaded`. + - **Clipboard paste** → JS-interop `paste` handler captures the blob, uploads via the same path. + - **Image URL** → downloaded server-side and saved as a local file. + - Upload validation: allowlist content types (png/jpeg/webp/gif), size cap, GUID filenames. + +### Claim / reservation (owner-hidden) +- Any member viewing a list they **don't own** sees each item's claim state and can claim/ + unclaim (respecting `Quantity`). +- The **owner's view of their own list never loads or renders claim data** — enforced at the + query layer, not just hidden in the UI, so it can't leak via timing/DOM. This is the single most + important correctness rule and gets dedicated unit tests. + +### Auth & onboarding (admin-invite-only) +- Self-registration disabled. Admin invites by email → emailed invite link with a one-time token + → recipient sets `DisplayName` + password → account created with `User` role. +- First-run **admin seeding** from env vars (`SEED_ADMIN_EMAIL` / `SEED_ADMIN_PASSWORD`). +- Password reset via Identity's token flow + MailKit SMTP email. +- Admin area: list/disable users, manage invites. + +--- + +## Verification +- **Automated:** `dotnet test` — owner queries never return claim data; non-owner queries do; + invite token single-use/expiry; image upload type/size validation; list queries run on SQLite. +- **Container:** `docker compose up --build`; verify SQLite DB + uploads persist across restarts + via the `/data` volume. +- **Unraid:** deploy image, map appdata volume + port, place behind reverse proxy with WebSockets + enabled, confirm Blazor interactivity (claim button) works through the proxy.