docs: add Plan 3 design spec (persistence, editors, uploads)
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
# Persistence, Secure Image Handling, and Scenario Editors Design
|
||||
|
||||
## Purpose
|
||||
|
||||
Plan 3 of the four-plan BattleForge build. Closes the gap between the pure-PHP `Domain` layer (Plans 1 and 2) and the in-browser experience by adding:
|
||||
|
||||
- Anonymous-browser persistence of scenarios and in-progress matches (no user accounts, no cross-device, no server-side storage of game state).
|
||||
- Server-rendered editor pages for teams and battlefields, with the domain's `Scenario` and `ScenarioValidator` enforcing every rule.
|
||||
- A secure image-upload pipeline (content-sniffed, size-limited, dimension-limited, session-scoped).
|
||||
- A minimal plain-PHP web stack with the cross-cutting security concerns the spec demands (CSRF, output escaping, content-validated uploads).
|
||||
|
||||
## Success Criteria
|
||||
|
||||
Plan 3 succeeds when a new user, without developer assistance and with no global state, can:
|
||||
|
||||
1. Visit `http://localhost:8000/`, see a home page that lists their recent scenarios, and start a new one.
|
||||
2. Use the team editor to build two teams of 3-6 units each, choosing archetypes, stats, abilities, names, and optional custom images.
|
||||
3. Use the battlefield editor to choose dimensions, paint terrain, place deployment zones, and place an objective (when the victory condition requires it).
|
||||
4. Validate the assembled scenario server-side; see clear errors when something is invalid.
|
||||
5. Upload a custom image and have it appear in the team editor.
|
||||
6. "Start a match" from a saved scenario and have the initial `MatchState` round-trip through the browser.
|
||||
7. Refresh the page, lose the connection, or come back tomorrow and find their saved scenarios still in their browser (no data loss from the user's perspective).
|
||||
8. Reject forged cross-site requests, accept only image types the spec allows, and never let a user access another user's uploads.
|
||||
|
||||
## Out of Scope (deferred to Plan 4 or later)
|
||||
|
||||
- Hot-seat battle interface: Plan 3 ships a placeholder page that reads the assembled `MatchState` from `localStorage` and renders a "Battle interface coming in Plan 4" stub.
|
||||
- Bundled scenarios (the three ready-made scenarios in the spec): Plan 4.
|
||||
- Real-time interaction, drag-and-drop, or any other JS surface beyond the single fetch-based grid editor and the small `localStorage` helper.
|
||||
- Server-side storage of scenarios, matches, or any game state.
|
||||
- User accounts, sessions beyond the CSRF cookie, or any per-user server-side state.
|
||||
- Mobile-native, PWA, offline service workers, or any non-server-rendered path.
|
||||
- Antivirus scanning or upload rate limiting.
|
||||
|
||||
## In Scope
|
||||
|
||||
### Persistence
|
||||
|
||||
- Scenarios are JSON-serialized in the browser's `localStorage`, keyed as `scenario:{id}`.
|
||||
- In-progress matches are JSON-serialized in `localStorage`, keyed as `match:current`.
|
||||
- The server never sees a `Scenario` or `MatchState` after the editor POST completes. Each form submission is validated and acknowledged; the browser then writes to `localStorage` on its own.
|
||||
- The home page lists scenarios by enumerating `localStorage` keys; no server call.
|
||||
- "Resume in-progress match" reads `match:current` and shows a banner on the home page.
|
||||
|
||||
### Team Editor
|
||||
|
||||
- One form per scenario, with sections for: meta (id, name), team A, team B, victory condition.
|
||||
- Each team section has 3-6 unit rows; rows are added and removed client-side (DOM manipulation, no fetch).
|
||||
- Each unit row has: archetype dropdown, display name, image upload button + hidden URL field, four stat inputs (with `min`/`max` from the chosen archetype's template), and an abilities multi-select (limited to the chosen archetype's allowlist).
|
||||
- The archetype JSON is served from `/assets/archetypes.json` and includes the four `ArchetypeTemplate` definitions.
|
||||
- A new "Upload" form submits a single image to `POST /assets/upload` and writes the returned URL into the unit row's hidden `image` field. The unit-editor form is not submitted as part of the upload.
|
||||
- Victory condition: a radio pair (`EliminateAll` / `HoldObjective`). When `HoldObjective` is selected, an input for `holdRoundsRequired` (1-10) appears.
|
||||
- Submitting the form POSTs the full draft to `POST /scenarios/{id}/edit/team`. The server parses, builds a `ScenarioDraft`, runs `ScenarioValidator::validate()`, and on failure re-renders the form with errors inline.
|
||||
|
||||
### Battlefield Editor
|
||||
|
||||
- One page per scenario. Width/height inputs (8-16 each) at the top.
|
||||
- Terrain palette: one button per `Terrain` enum value. The selected button is the "current paint."
|
||||
- Grid: an HTML `<table>` of `<button>` cells with `data-x` and `data-y`. Clicking applies the current paint. Shift-click erases.
|
||||
- Two deployment-zone controls (one per team). Each is a "click on grid to add this tile to my zone" mode.
|
||||
- Objective control: a single "click on grid to place the objective" mode that is only shown when the victory condition is `HoldObjective`.
|
||||
- The page ships with a small ESM helper (~50 lines, no build step) that maintains an in-memory `tileMap` object, posts it as JSON to `POST /scenarios/{id}/edit/battlefield` on save, and writes the server's response to `localStorage`.
|
||||
|
||||
### Image Upload Pipeline
|
||||
|
||||
- Multipart POST to `POST /assets/upload` with a hidden CSRF field.
|
||||
- Server reads `$_FILES['image']`, validates with `ImageValidator` (see below), stores the file at `var/uploads/{userToken}/{hash}.{ext}`, and returns `{"url": "/assets/{userToken}/{hash}.{ext}"}`.
|
||||
- `GET /assets/{userToken}/{filename}` streams the file with the right `Content-Type`. The `{userToken}` is derived from the session cookie and the request's `{userToken}` is verified to match; mismatch returns 404. Misses return 404.
|
||||
- No listing endpoint. No directory traversal.
|
||||
- Bundled placeholder images at `/assets/placeholders/{archetype}.png` are committed under `public/assets/placeholders/` and served without the `{userToken}` prefix.
|
||||
|
||||
### Security and Quality
|
||||
|
||||
- A per-session CSRF token stored in an `HttpOnly`, `SameSite=Lax` cookie, signed with the app's secret using `hash_hmac('sha256', …)`.
|
||||
- Every form includes a hidden `_csrf` field; the fetch helper reads the value from a `<meta name="csrf-token">` tag and sends it in an `X-CSRF-Token` header.
|
||||
- All output escaping goes through `Escape::html`, `Escape::attr`, and `Escape::url` helpers. Code review enforces the pattern; PHPStan has no rule for it.
|
||||
- All uploaded images are content-validated by reading the first 12 bytes and checking the magic number against an allowlist (PNG, JPEG, WebP, GIF), then `getimagesizefromstring()` to confirm dimensions are ≤ 512×512, then size-checked at ≤ 2 MB.
|
||||
- `Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self'` is set on every page. No `unsafe-inline`.
|
||||
- `X-Content-Type-Options: nosniff` and `Referrer-Policy: same-origin` on every page.
|
||||
- Bundled placeholders are committed; user uploads live in `var/uploads/` (git-ignored).
|
||||
- PHP follows the repository PHPCS rules and passes PHPStan at level 6.
|
||||
- The single small JS file (`public/js/grid-editor.js` and `public/js/storage.js`) passes ESLint `airbnb/base` and Prettier.
|
||||
|
||||
## System Boundaries
|
||||
|
||||
The Plan 1+2 system boundaries are preserved and extended:
|
||||
|
||||
1. **Content library** (Domain, unchanged): owns curated unit archetypes, abilities, terrain, and bundled asset metadata. Served read-only to the web layer.
|
||||
2. **Scenario editor** (web, NEW): hosts the team and battlefield editor pages. The `Application` layer translates form input into a `Scenario` and runs `ScenarioValidator`. The web layer never inspects a `Scenario` directly.
|
||||
3. **Rules engine** (Domain, unchanged from Plans 1 and 2): authoritatively validates actions and resolves combat.
|
||||
4. **Battle interface** (Plan 4, stubbed in Plan 3 as a "match is loaded" page that reads `match:current` from `localStorage`).
|
||||
|
||||
The new Application and Http layers sit between the web pages and the Domain. They do not depend on browser presentation code, and the Domain does not depend on them.
|
||||
|
||||
## Architecture
|
||||
|
||||
The four `// @phpstan-ignore` annotations in `src/Domain/` (in `UnitState`, `MatchState`, `Scenario`, `DeploymentZone`) reference "Plan 4" in their parenthetical comments. The comments will be updated to "Plan 3" in a follow-up amend after this design is approved.
|
||||
|
||||
```
|
||||
src/
|
||||
├── Domain/ (Plan 1 + 2, unchanged)
|
||||
│ └── … 19 files …
|
||||
│
|
||||
├── Application/ (NEW)
|
||||
│ ├── ScenarioSerializer.php JSON ↔ Scenario
|
||||
│ ├── ScenarioDraft.php mutable draft state for the editor
|
||||
│ ├── ImageUploadService.php validates + stores a user-uploaded image
|
||||
│ └── ImageValidator.php content-sniff + size + dimension checks
|
||||
│
|
||||
├── Http/ (NEW)
|
||||
│ ├── Router.php tiny path → handler dispatcher
|
||||
│ ├── Request.php value object wrapping $_GET, $_POST, $_FILES, $_SERVER
|
||||
│ ├── Response.php value object with status, headers, body
|
||||
│ ├── CsrfToken.php per-session token store + verify helper
|
||||
│ ├── Escape.php html(), attr(), url() helpers
|
||||
│ └── Handlers/
|
||||
│ ├── GetHomePage.php
|
||||
│ ├── GetTeamEditor.php
|
||||
│ ├── PostTeamEditor.php
|
||||
│ ├── GetBattlefieldEditor.php
|
||||
│ ├── PostBattlefieldEditor.php
|
||||
│ ├── GetAssets.php
|
||||
│ └── PostImageUpload.php
|
||||
│
|
||||
├── Views/ (NEW — plain PHP templates)
|
||||
│ ├── layout.php
|
||||
│ ├── home.php
|
||||
│ ├── team-editor.php
|
||||
│ ├── battlefield-editor.php
|
||||
│ ├── match-stub.php
|
||||
│ └── upload-result.php
|
||||
│
|
||||
└── public/ (NEW — document root for `php -S`)
|
||||
├── index.php front controller
|
||||
├── assets/
|
||||
│ ├── archetypes.json
|
||||
│ ├── placeholders/{defender,striker,support,scout}.png
|
||||
│ └── (user uploads live under var/uploads/, not here)
|
||||
└── js/
|
||||
├── storage.js
|
||||
└── grid-editor.js
|
||||
```
|
||||
|
||||
`var/` is the only runtime-writable directory. It is git-ignored. It contains:
|
||||
|
||||
```
|
||||
var/
|
||||
├── cache/ PHP opcode cache (created by `php -S` if enabled)
|
||||
├── phpunit/ PHPUnit cache
|
||||
├── phpstan/ PHPStan cache
|
||||
└── uploads/
|
||||
├── .htaccess Deny from all (Apache)
|
||||
├── index.php Sentinel that returns 403 (built-in dev server)
|
||||
└── {userToken}/ Per-browser namespace; created on first upload
|
||||
```
|
||||
|
||||
## Data and Action Flow
|
||||
|
||||
### Cold-start flow
|
||||
|
||||
1. Browser hits `GET /`. Server renders the home page.
|
||||
2. A small inline script in the home page enumerates `localStorage` keys matching `scenario:*` and `match:current` and renders the list.
|
||||
3. User picks an existing scenario → the team editor opens with the form pre-filled.
|
||||
4. User picks "New scenario" → the team editor opens with an empty form for a new id.
|
||||
|
||||
### Team editor save flow
|
||||
|
||||
1. User edits the form, presses Save.
|
||||
2. Browser POSTs the form to `POST /scenarios/{id}/edit/team` with `Content-Type: application/x-www-form-urlencoded` and a hidden `_csrf` field.
|
||||
3. Server's `PostTeamEditor` handler:
|
||||
a. Verifies the CSRF token.
|
||||
b. Parses the form into a `ScenarioDraft`.
|
||||
c. Builds a `Scenario` from the draft.
|
||||
d. Runs `ScenarioValidator::validate($scenario)`.
|
||||
e. On failure: re-renders the editor with the validator's errors next to each field, preserving the form's values.
|
||||
f. On success: renders the editor with an inline `<script>` block that calls `localStorage.setItem('scenario:' + id, JSON.stringify(scenarioJson))` and shows a "Saved" toast. The server does not store the scenario.
|
||||
|
||||
### Battlefield editor save flow
|
||||
|
||||
1. User clicks tiles in the grid; the small JS helper maintains an in-memory `tileMap` object.
|
||||
2. User presses Save; the JS helper POSTs the assembled JSON to `POST /scenarios/{id}/edit/battlefield` with `Content-Type: application/json` and an `X-CSRF-Token` header.
|
||||
3. Server's `PostBattlefieldEditor` handler:
|
||||
a. Verifies the CSRF token.
|
||||
b. Decodes the JSON body, validates the shape, builds a `ScenarioDraft`, runs the validator.
|
||||
c. On failure: returns `{"ok": false, "errors": [...]}` with HTTP 400.
|
||||
d. On success: returns `{"ok": true, "scenario": ...}` with HTTP 200. The JS helper writes `localStorage` and shows a toast.
|
||||
|
||||
### Image upload flow
|
||||
|
||||
1. User picks an image in the team editor and presses Upload.
|
||||
2. Browser submits the file to `POST /assets/upload` as `multipart/form-data` with a hidden `_csrf` field.
|
||||
3. Server's `PostImageUpload` handler:
|
||||
a. Verifies the CSRF token.
|
||||
b. Validates the upload with `ImageValidator`.
|
||||
c. Writes the file to `var/uploads/{userToken}/{hash}.{ext}`.
|
||||
d. Returns `{"url": "/assets/{userToken}/{hash}.{ext}"}` with HTTP 200.
|
||||
4. JS writes the returned URL into the unit row's hidden `image` field. The unit-editor form is not submitted as part of the upload.
|
||||
|
||||
### Start-match flow
|
||||
|
||||
1. User on the team editor presses "Continue to battlefield" or on the battlefield editor presses "Start match."
|
||||
2. JS reads the assembled `Scenario` JSON from `localStorage`, POSTs to `POST /scenarios/{id}/start` with `Content-Type: application/json`.
|
||||
3. Server runs `ScenarioValidator` (defense in depth), calls `Scenario::startMatch('alpha')`, returns the initial `MatchState` JSON. (No browser-side `startMatch` call; the JS does not run the domain's PHP code.)
|
||||
4. JS writes `match:current` to `localStorage` and navigates to `GET /match/current`.
|
||||
|
||||
## Validation and Failure Handling
|
||||
|
||||
- `ScenarioValidator` is the single source of truth for "is this scenario valid." Both the server (on every form POST) and the browser (before navigating forward) call it.
|
||||
- A rejected action or form never partially mutates the persisted state. The browser's `localStorage` write happens only on a successful server response.
|
||||
- Failure to save a scenario leaves the user's in-progress form intact; the editor re-renders the same values with errors inline.
|
||||
- The CSRF token's invalid-or-missing case returns a generic 403 page that links back to `GET /`. The user's in-progress form state is preserved in `localStorage` via the on-change JS hook.
|
||||
- A failed image upload shows an inline error next to the upload button; the unit row keeps whatever `image` field it had.
|
||||
- Corrupt or incompatible JSON in `localStorage` (e.g. a hand-edited scenario) is rejected on read: the home page's "Recent scenarios" list skips it and shows a "scenario corrupt" placeholder for that key.
|
||||
|
||||
## Security and Quality Requirements
|
||||
|
||||
- Every state-changing form or request uses and verifies a CSRF token before mutation. The token is per-session, signed with the app's secret, and the form's hidden field is required for HTML forms or the `X-CSRF-Token` header is required for fetch POSTs.
|
||||
- All input is sanitized and validated at the application boundary in the `Request` value object and the `ImageValidator`.
|
||||
- All rendered output is escaped for its output context via the `Escape::html`, `Escape::attr`, and `Escape::url` helpers. Templates do not interpolate user input without one of these helpers.
|
||||
- Uploaded images are validated by content (magic number) and by dimensions; the stored file's name is a server-generated random hex; the original filename and declared MIME are discarded after validation.
|
||||
- `var/uploads/` is denied by `.htaccess` (Apache) and `index.php` (built-in server).
|
||||
- The dev server is the built-in `php -S` running on a single port. No CGI, no Apache-only assumptions; the `var/uploads/.htaccess` and `var/uploads/index.php` sentinels cover both Apache deployments and the built-in server respectively.
|
||||
- PHP follows the repository PHPCS rules and passes PHPStan at level 6.
|
||||
- The small JS surface passes ESLint `airbnb/base` and Prettier.
|
||||
- Composer configuration passes `composer validate --strict`.
|
||||
- All output includes `X-Content-Type-Options: nosniff`, `Referrer-Policy: same-origin`, and a `Content-Security-Policy` header that forbids inline scripts and styles.
|
||||
|
||||
## Verification Strategy
|
||||
|
||||
### Unit tests
|
||||
|
||||
- `ScenarioSerializerTest`: round-trip every Plan 2 fixture through `toJson` → `fromJson`; add fixtures for all four archetypes, both victory conditions, every terrain, and at least one uploaded-image reference.
|
||||
- `ScenarioDraftTest`: form fields → `ScenarioDraft` → `Scenario` for every field combination; error cases (out-of-bounds stat, unknown archetype, ability not in allowlist, oversized team, etc.).
|
||||
- `ImageValidatorTest`: valid PNG/JPEG/WebP/GIF headers; invalid headers (text, executable, fake extension); size > 2 MB; dimension > 512; MIME/extension mismatch.
|
||||
- `ImageUploadServiceTest`: round-trips a temp file through the service; asserts the file lands at the expected path; asserts the response URL is well-formed.
|
||||
- `CsrfTokenTest`: round-trip; cross-session rejection; expired-cookie rejection.
|
||||
- `EscapeTest`: regression set for `<`, `>`, `'`, `"`, `&`, control characters, NULL bytes.
|
||||
- `RequestTest`: synthetic `$_GET`/`$_POST`/`$_FILES`/`$_SERVER` extraction.
|
||||
|
||||
### Integration tests
|
||||
|
||||
- One happy-path and one validation-error test per handler. Asserts cover status code, `Content-Type` header, presence/absence of the CSRF token in the response body, and inline error placement.
|
||||
- A round-trip "save scenario" integration test: POST a full team-editor form, assert the response says "saved," then GET the same page and assert the form is pre-filled.
|
||||
- A round-trip "upload + read back" integration test: POST a small PNG to `/assets/upload`, then GET the returned URL and assert the body matches the uploaded bytes exactly.
|
||||
- A round-trip "forged CSRF" test: a POST without the token returns 403.
|
||||
- A round-trip "wrong user" test: an upload stored under one session's `{userToken}` is unreadable from a different session.
|
||||
|
||||
### End-to-end smoke test (seeded in Plan 3, completed in Plan 4)
|
||||
|
||||
- Plan 3 ships the *plumbing* of an E2E test that boots a tiny PHP server in a background process, sends a sequence of HTTP requests, and asserts on response bodies. Plan 4 fills in the battle-interaction assertions.
|
||||
|
||||
### Static analysis / lint
|
||||
|
||||
- PHPCS rules stay `PSR-12`; the new `src/Http/`, `src/Application/`, `src/Views/`, and `public/` directories are added to the `phpcs.xml` `<file>` list.
|
||||
- PHPStan level 6 stays. New exclusions: `src/Views/*` and `public/index.php`.
|
||||
- ESLint `airbnb/base` + Prettier on the single small JS file. `package.json` + `package-lock.json` are committed; CI gains an `npm run lint` step.
|
||||
|
||||
### Manual usability check
|
||||
|
||||
- A new tester with no BattleForge context can: open the dev URL, see the home page, create a new scenario, build two teams with mixed archetypes, paint a simple battlefield, save both, and start a match — without leaving the browser or seeing any error from the validator that wasn't explained inline.
|
||||
|
||||
## Release Boundary
|
||||
|
||||
Plan 3 is releasable when every in-scope capability and success criterion is met, the verification suite is green (PHPUnit, PHPStan, PHPCS, ESLint, Prettier), and no out-of-scope capability (battle interface, bundled scenarios, AI, online play) is required to exercise the local creation flow. The local dev server runs the full app on a single port with `php -S`.
|
||||
|
||||
The next plan (Plan 4) will replace the battle-page stub with a real battle interface, add the three bundled scenarios, and ship the end-to-end smoke test that the Plan 3 plumbing seeds.
|
||||
Reference in New Issue
Block a user