---
type: plan
title: Hartling owner-portals rebuild — scope & plan
status: approved — Django on DigitalOcean
author: rian (with Claude)
created: 2026-08-24
updated: 2026-08-25
---

# Hartling owner portals — rebuild scope & plan

Rebuild three WordPress owner sites (The Sands, The Shore Club, The Palms) as
one **Django** codebase deployed as **three isolated instances on DigitalOcean**.
Grounded in a full audit of the three staging copies and their databases, the
client's approved scope (email thread 2026-08-13, in `notes/`), and a stack
review done jointly with the `professormediagroup` session.

**Stack decision (2026-08-25):** Django + Postgres on DigitalOcean — chosen over
Laravel (rejected on unfamiliarity) and over the v8 FastAPI+React house stack
(this app is auth/admin-heavy but UI-light, exactly Django's sweet spot). See §2.

---

## 0. Priority zero — a security incident (staging remediated; production still open)

The Sands staging copy contained an **active malware backdoor** and a **plaintext
owner-credential export**. Because staging is refreshed from production and the
malware file was dated to the last refresh (2026-06-25), the **live
thesandsowners.com must be treated as compromised**.

- **Backdoor:** `garment-prototype-blocks.php` — self-hiding, unauthenticated RCE,
  and an instant-admin bypass via a magic query string. Wordfence was deactivated.
- **Credential leak:** eight copies of `Owners-Credentials-The-Sands-...csv`
  (plaintext `password` column) at guessable public URLs under `uploads/2021/03/`.

**Done (2026-08-25, owner-approved):** on the staging copy the backdoor file was
quarantined (renamed, inert, kept as evidence) and removed from `active_plugins`;
all eight CSVs deleted; `srv-gw security-audit` = 0 critical; a scan confirmed the
signature is on no other site and there is no stray PHP in any uploads dir.

**Still open (rian's action, off this server):** check live thesandsowners.com
(and, to be safe, the siblings) on the current production host for the same plugin;
if present, rotate every secret the site could read and force-reset owner
passwords. **Do NOT** open the `?garment-prototype-blocks=...` URL on the live site
— it forges an admin login. The clean-room rebuild is the durable remediation: no
plugin is carried forward.

This is the strongest argument for the rebuild's central goal: **owner documents
and credentials must never again be reachable without an authenticated session.**

---

## 1. What we are replacing (audit facts)

All three are Kadence WordPress sites (currently on Cloudways) sharing near-
identical DNA: a title-only `docs` CPT categorised by an ACF select field
(`wpcf-doc-type`, **not** a taxonomy), rendered by a custom child-theme block
inside Kadence tabs on one WP-`private` page, with files linked by ACF `acf-pdf` =
media attachment ID. Board members are a single hand-edited private page. This
shared shape is why one rebuilt codebase fits all three.

| | The Sands | The Shore Club | The Palms |
|---|---|---|---|
| Users (total) | 134 | 48 | 136 |
| Active last 12 mo | ~13 | 6 | ~8 |
| Legacy phpass `$P$B` hashes | 120 | 42 | 126 |
| Modern `$wp$` bcrypt | 14 | 6 | 10 |
| Library docs (published) | 148 | 54 | 144 |
| Doc categories | 7 | 4 | 5 |
| Board members | 5 (private page) | 5 (private page) | 9 current (public table) + stale bio page |
| Uploads on disk | 393 MB / 221 PDF | 168 MB / 64 PDF | 274 MB + **250 protected files (151 MB) live only on the production host** |
| File protection today | none | none | partial (`bw-lock-files`, one-shot) |
| Notable extra | **malware backdoor + credential CSV** | — | **open self-registration** granting owner role |

**Key data facts for migration:**
- Categories are a magic meta value, not a taxonomy — trivial to remap to a real
  category table. The list differs per site (7 / 4 / 5), so it is **per-instance
  config**, not shared.
- File linkage is uniform: `acf-pdf` → attachment ID (primary), `acf-pdf-2` →
  secondary, `acf-thumb`, `wpcf-summary` text. Older docs also carry legacy
  Toolset `wpcf-pdf` = full URL; migration reconciles both into one field.
- **No per-owner custom data exists** (no unit, no phone) — user migration is only
  username, email, name, role, and the password hash.
- **The Palms' 250 protected files are not in the uploads backup path** — they live
  only in `wp-content/bw-protected/` on the current production host and must be
  pulled separately (SSH/SFTP) before cutover, or 155 documents 404.
- **Password hash sampling (staging, representative):** ~90% legacy phpass `$P$B`;
  the rest are WP 6.8 `$wp$` = bcrypt over base64(HMAC-SHA-384(password,
  `"wp-sha384"`)). Re-sample the live DBs at migration time.

**What owners actually use (audit + client email agree):** the document library
and the board page. Forum (dead since 2023), gallery, media clippings, and most
newsletters are unused and are **cut**.

---

## 2. Architecture

### 2.1 Stack: Django (Python), server-rendered, + Postgres

A document portal is auth, a document library, private downloads, a staff admin,
and board pages — **auth/admin-heavy but UI-light**. That shape is Django's sweet
spot. Chosen over the two alternatives deliberately:

- **Over Laravel:** Laravel was only ever on the table because Cloudways is
  PHP-only. Moving to DigitalOcean removes that constraint, and picking a stack
  rian doesn't know (he just moved a client *off* Laravel for that reason) is the
  wrong call for something he maintains.
- **Over the v8 FastAPI + React house stack:** the house stack's earned assets are
  **UI-layer** (OpenAPI→TS generation, React patterns) and this app has no app-like
  UI to spend them on; and its auth model is explicitly "no user/session store —
  identity comes from the id-auth gate," which does not cover an **ungated client
  app**. On the house stack we'd hand-build session auth, password reset, the WP-
  hash migration, and the whole staff admin — the exact security-critical surface
  `coding.md` flags as a repeated failure source.
- **Why Django wins the security-primitive argument:** admin, session auth,
  password reset, and a **pluggable password-hasher chain** ship in the framework.
  The WP-hash requirement has a first-class Django answer (a custom hasher +
  rehash-on-login). For the fleet's most leak-sensitive client, framework-
  maintained auth beats anything we'd hand-roll — the cost that matters is *owning
  the patch surface forever*, not writing it once.
- **Fleet consistency is preserved where it actually lives — the ops layer.**
  Postgres, Docker, the v8 workspace shape, the secrets conventions, and
  `coding.md` all carry identically to a Django container. Consistency was never in
  the framework.

Decision reached jointly with the `professormediagroup` session (inverse shape —
interactive SPA, trivial admin, auth deferred — so it stays on the house stack).

### 2.2 Hosting: DigitalOcean, three isolated instances

One codebase, three deployments — exactly the "one app, three instances" the
client wants — with per-property isolation made **structural**:

- **Production on DigitalOcean.** Recommended shape: **one Droplet running Docker
  Compose with three isolated app+DB stacks** — each property its own Django
  container and **its own Postgres database** (the isolation boundary), fronted by
  Caddy for automatic TLS and per-domain routing. Site A's app holds no credentials
  for and has no code path to site B's database, so **no query can cross tenants**.
  Three separate Droplets are available if physical isolation is ever wanted; at
  this scale (a few hundred users, low traffic each) one Droplet is the pragmatic
  sweet spot.
- **Private files** live in a directory **outside the web root** (or a private DO
  Spaces bucket). Unlike Cloudways, a self-managed Droplet with nginx/Caddy can use
  an **internal-redirect handoff (X-Accel-Redirect)** for efficient private
  downloads; Django's `FileResponse` streaming is fine either way at this scale.
- **Tenancy is just config** — per-instance branding (logo, colours) and the
  category list. No multi-tenant framework, no shared database.
- **Dev** runs on this server (mosiah) as one Docker container via `srv-gw` at
  hartlingowners.demoing.info (id-auth gated), in the v8 workspace.
- **Deploy** is git-based: pull → `migrate` → `collectstatic` → restart, via a
  small script or CI, run for each instance. No Cloudways deploy constraints apply.
- **Ops rian now owns:** a Droplet to patch, DO snapshots + a nightly `pg_dump`
  for backups, Caddy auto-TLS. Keep it boring.

### 2.3 Deliberate deviation + a new standard

The rule "**apps never build their own login; identity comes from id-auth**" is
scoped to apps hosted **here**, behind Caddy. These run **off-server on DO**, where
id-auth does not exist, and must verify owners' existing WordPress hashes — so the
app owns its auth. Resolution: **Django's framework auth** (we don't hand-roll
crypto). Per `coding.md`, this is a surfaced conflict, not a silent choice —
**approval of this plan approves the deviation.**

Deliverables (Phase 1):
- **`/srv/projects/standards/django.md`** — the new stack standard, with the
  dispatch rule: *auth/admin-heavy, UI-light portal → Django; interactive app-like
  product → FastAPI+React*. This **adds a shape** to a library that already
  dispatches by shape (`wordpress.md` vs `react.md`); it does not open a door.
- **Fix the standards gap** the app exposes: `react.md`/`coding.md` "identity comes
  from the id-auth gate" does not cover ungated client apps. That gap is real for
  both this project and `professormediagroup`; the standards should say what fills
  it.

---

## 3. Features to build

Mapped to the client's approved 7-item scope, plus the "overdeliver so they stop
emailing me" goal.

### 3.1 Document library (the core)
- One `documents` table per instance: title, category (FK), summary, primary file,
  optional secondary file, optional thumbnail, published date, uploaded-by,
  timestamps. Category list is per-instance config (7 / 4 / 5, editable).
- Owner-facing UI: fast, **mobile-first**, full-text **search** across title +
  summary + category, filter by category, sort by date. The single most-used
  screen — server-rendered templates with a light sprinkle of JS where it helps.

### 3.2 Bulletproof document protection (the #1 requirement)
- Files stored **outside the web root** (Droplet dir or private Spaces bucket) —
  no public URL for any document ever exists.
- Served only through an **authenticated, authorised Django view** (login required
  + the requesting user's instance owns the document), via `X-Accel-Redirect`
  internal handoff or `FileResponse` streaming. Copying the URL into a logged-out
  browser yields a login redirect — by design.
- Headers on every document + authed page: `X-Robots-Tag: noindex, nofollow,
  noarchive`, `Content-Disposition: attachment`, `Cache-Control: private,
  no-store`, `X-Content-Type-Options: nosniff`.
- **No signed/expiring URLs** as a primary mechanism — a signed URL is a URL that
  works without a session (forwardable, crawlable). This is the direct answer to
  the résumé-in-Google incident.
- **No auto-generated public preview images** (WordPress leaked PDF first pages as
  JPGs). Any thumbnail is generated into the private store and gated identically.
- Rate-limit downloads; **log every download** (who, which doc, IP, when) — the log
  doubles as the activity feature (3.6).
- Blanket `noindex` on everything except the login/landing page.

### 3.3 User management (self-service for staff — the "stop emailing me" goal)
- The **Django admin** is the staff interface: create / edit / deactivate owners,
  **reset a password**, search owners, see status and last login — plus upload and
  categorise documents, and manage board members. This is the batteries-included
  win: the staff CRUD is largely free.
- Roles: `owner` (read the library + board — a regular authenticated user, **no
  admin access**), `staff` (Django admin: manage owners + documents + activity).
  **No open self-registration** — the Palms' hole is closed; accounts are created/
  invited by staff.
- Keep the admin **staff-only** — the moment the owner experience needs to feel
  app-like, that is template/JS frontend work, never admin customization.
- Bulk import for the migration; invite-by-email for new owners.

### 3.4 Password migration — verify legacy WP hashes, no forced reset
- Custom Django password hashers registered in `PASSWORD_HASHERS`, mirroring
  WordPress `wp_check_password()`:
  - phpass portable `$P$` / `$H$` (salted iterated MD5),
  - WP 6.8+ `$wp$` = **bcrypt over base64(HMAC-SHA-384(password, "wp-sha384"))**
    (the HMAC pre-step is required — plain bcrypt will not verify these),
  - vanilla `$2y$` bcrypt.
- Django's auth **transparently rehashes to the default hasher** (argon2 or bcrypt)
  on first successful login — legacy hashes age out with zero user friction.
- Unit-tested against fixture hashes of all formats exported from a real WP install
  before migration day.
- Import **all** users (nobody locked out); deactivate long-dormant; staff prune.

### 3.5 Self-service password reset (owners)
- Django's built-in `password_reset` flow (token, expiry, throttle) so a forgotten
  password never becomes a support email. Needs a transactional email provider
  (see §7).

### 3.6 Login / download activity view (Joan's explicit ask)
- "A quick and easy link to see who logged in and when." A staff screen (admin or a
  simple report view) listing recent logins (owner, time, IP) and recent downloads,
  searchable — built from the activity log in 3.2/3.4.

### 3.7 Board of directors
- A `board_members` table (name, title, photo, bio, order, optional contact),
  editable in the Django admin — no hand-edited HTML. **Do not** publish members'
  personal emails in markup (a contact form or obfuscation instead).

### 3.8 Staff-only owner directory (per the email)
- The staff-only directory rian offered Joan: a searchable owner list with contact
  info + last login. Admin-only, never owner-facing. Folds into 3.3.

### 3.9 Cut (confirmed unused, client-agreed)
Forum, gallery, media clippings, most newsletters, per-owner unit numbers, and all
legacy WordPress debris. A simple newsletters list can be kept per-site only if a
property still wants it. 2FA is not requested (an easy future add in Django).

### 3.10 Design
- A light, modern, **responsive** refresh — polish, not a redesign project. Each
  instance can differ in logo/colours/content via branding config; shared templates
  keep all three consistent.

---

## 4. Migration plan (per site, scripted + idempotent)

Source = the current WordPress on the production host (Cloudways). Target = the new
Django app on DigitalOcean.

1. **Extract** from each WP DB (read-only): docs (title, date, category value,
   summary, `acf-pdf`/`acf-pdf-2`/`acf-thumb` attachment IDs → file paths, legacy
   `wpcf-pdf` URLs), users (username, email, name, role, `user_pass` hash,
   registration date), board content.
2. **Files:** copy referenced PDFs/office files into the new private store,
   normalised. **The Palms:** additionally pull the 250-file `bw-protected/` corpus
   (151 MB) from the current production host over SSH/SFTP — not in the uploads
   backup; 155 docs depend on it. Skip the public PDF-preview JPGs.
3. **Users:** import with hashes preserved (custom hashers verify them). **Do not
   import the Sands credential CSV** (destroyed).
4. **Categories:** create each site's rows from its `wpcf-doc-type` map; attach docs.
5. **Board:** import members (the Palms: use the current 9-member table, not the
   stale 2022 page).
6. **Verify** every doc resolves to a gated, downloadable file; reconcile the dual
   legacy/new file schemes; report orphans. Dry-run first, with a reconciliation
   report — never a one-shot manual pass.

---

## 5. De-indexing the previously leaked documents

At cutover, for each old site:
- Return **`410 Gone`** (not a redirect, not 404) for legacy paths
  (`/wp-content/uploads/*`, old doc/page slugs, `/wp-login.php`). A redirect keeps
  the URL alive in Google's index; 410 says permanently gone.
- **Do not** robots.txt-block those paths — Google must recrawl to see the 410.
- File **Google Search Console prefix removals** per domain for
  `/wp-content/uploads/`; repeat in Bing Webmaster Tools.
- The new app is blanket-`noindex` except the login/landing page; every document is
  behind auth anyway.

---

## 6. Phased delivery

- **P0 — Incident:** staging remediated (done 2026-08-25). Production check on the
  live host is rian's action. (Gate before cutover, not before build.)
- **P1 — Foundations:** Django skeleton + write `django.md`; framework auth + custom
  WP-hash hashers (phpass + `$wp$` + `$2y$`) with fixture tests; private-file
  storage + authed download view with the full header/logging stack; stand up **one
  instance on a DO test Droplet end-to-end** — git deploy → `migrate` → prove a
  logged-out download attempt returns a login redirect. Verify by exercising.
- **P2 — Core features:** document library (owner UI + search); Django admin for
  staff (upload/categorise, users, password reset, activity); board members;
  owner-facing screens.
- **P3 — Migration:** extraction/import scripts; dry-run per site; pull the Palms
  corpus; reconciliation reports.
- **P4 — Design & polish:** responsive refresh, per-site branding, mobile review.
- **P5 — Cutover, one property at a time (Shore Club first — Joan's priority):**
  final sync, DNS to DO, 410s + GSC removals, smoke-test the logged-out-download
  redirect, monitor. Then Sands, then Palms.

Each phase ends with a `/checkpoint` handoff entry.

---

## 7. Decisions

**Locked:** DigitalOcean + Django + Postgres · three isolated instances (one DB
each) · Django admin as the staff interface (no separate admin panel) · import all
owners then prune · Sands staging incident remediated.

**Small, still open (I'll recommend and proceed unless you say otherwise):**
1. **DO shape** — one Droplet with three isolated app+DB stacks (recommended) vs
   three Droplets (max physical isolation, 3× cost).
2. **Private file store** — Droplet disk outside the web root (recommended, one
   fewer service) vs private DO Spaces bucket.
3. **Transactional email provider** for password-reset + invite mail (Postmark,
   SES, Resend, etc.) — needed by P1; pick one and I'll wire it.
4. **Production compromise check** on the live host — rian's action from P0.

## 8. Risks

- **Production still compromised** until checked/cleaned (P0).
- **The Palms corpus lives only on the live host** — pull before cutover or docs 404.
- **Django is new to rian** — mitigated: it's Python, batteries-included, extremely
  well-trodden and vibe-code-friendly; conventions captured in `django.md`.
- **Hash-migration correctness** — a wrong `$wp$` algorithm silently rejects logins;
  fixture tests for all formats (including the HMAC-SHA-384 pre-step) are mandatory.
- **DO ops ownership** — rian now runs a Droplet (patching, backups, TLS). Mitigate
  with DO snapshots + nightly `pg_dump` + Caddy auto-TLS.
- **Scope creep** — hold the "polish, not redesign" line, and keep the Django admin
  staff-only (don't extend it toward owner-facing screens).
