feat(rm-handlers): W1 — Issue list + detail handlers#10
Conversation
W1 of the Redmine Integration Plan, first Phase-1 width track. The hot-path resource: Redmine's `Issue`, OpenProject's `WorkPackage`, both routing through `class_ids::PROJECT_WORK_ITEM` (0x0102). # What this adds - New `rm-handlers` crate. Each Phase-1 width track (W2..W8) adds one module here owning a single canonical concept's routes — per Plan §8 file ownership, no two parallel tracks edit the same file. - `rm-handlers/src/issues.rs` — the W1 module. Routes: - `GET /issues` — list page, columns `#` + `Subject` - `GET /issues/:id` — detail page - `rm-handlers/src/common.rs` — shared `AppState`, `wrap_in_doc` HTML shell (G1 master template replaces this later), `record_id_to_u64` (URL → render-kit u64 adapter via stable hash). - rm-store gains `Store::list_issues()` — empty Vec on a fresh store is the empty-state path the kit renders as "No data.". - rm-server: new `build_router_with(store, auth_cfg)` mounts all resource routers + the rm-auth surface. `serve()` boots the store + auth config and wires the full app. The bare `build_router()` stays for the proof-of-shape index test. # DoD pinned by 7 tests (in addition to the 3 store-list ones) - common: wrap_in_doc shape, record_id_to_u64 deterministic + distinguishes - issues: - list_renders_empty_state_when_no_issues — empty store → empty-state - list_renders_seeded_issues — two issues both visible + detail hrefs - detail_renders_a_known_issue — seeded row → 200 + subject + class_id - detail_returns_404_for_unknown_issue — NotFound → 404 via IntoResponse # Notable - HandlerError carries `Render(String)` not `Render(askama::Error)`. `askama` is transitive through `ogar-render-askama`; declaring it as a direct dep here would trigger the same `askama_axum` macro expansion mess we hit in W0.1. Map at the call site with `.map_err(|e| HandlerError::Render(e.to_string()))`. - SurrealDB 3.x `RecordId` doesn't impl `Display`; uses `ToSql::to_sql()` for stable string formatting (URL hrefs + the hash input). - rm-handlers does NOT depend on rm-server — rm-server consumes rm-handlers (the W1..W8 merges live in rm-server's router), and a reverse dep would cycle. # What this unlocks Phase 1 parallel fan-out: W2 (Project), W3 (TimeEntry), W4 (User/ Member/Role), W5..W8 each follow the same shape — one file in rm-handlers, one alphabetised .merge call in rm-server's build_router_with. cargo fmt --check + cargo clippy --all-targets -- -D warnings + cargo test --workspace all green locally; 58 tests across 5 crates.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 852d977a06
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| ) | ||
| .map_err(|e| HandlerError::Render(e.to_string()))?; | ||
| Ok(Html(wrap_in_doc( | ||
| &format!("#{} {}", rid.key.to_sql(), &issue.subject), |
There was a problem hiding this comment.
Escape issue subjects before setting the page title
For an issue whose subject contains markup such as </title><script>...</script>, GET /issues/:id sends that subject verbatim into wrap_in_doc, which interpolates the title directly into <title>{title}</title>. Since issue subjects are stored user-controlled data, this creates stored XSS on the detail page; escape the subject before passing it to the document wrapper.
Useful? React with 👍 / 👎.
…ail-fast Closes the codex items left open across my Phase 0/1 PRs: # P1 — XSS via raw user content in handler-built HTML (#10, #12) `wrap_in_doc(title, body)` interpolated the raw `title` straight into the document's `<title>` element. With user-controlled inputs (issue subjects, time-entry spent_on, project names, user display names) reaching this fn, a payload like `</title><script>alert(1)</script>` is a stored XSS. The `render_detail(..., headline_html, ...)` parameter is contractually pre-rendered HTML (kit `|safe`s it through the template), so handlers that compose it from user data must escape themselves. W1 (Issue), W2 (Project), W3 (TimeEntry), W4a (User), W4b (Role) all did this with raw interpolation. Fix: - `common::html_escape` — minimal `& < > " '` escape, no extra dep. - `wrap_in_doc` escapes its `title` parameter. - Every handler that composes `headline_html` from user data now calls `html_escape` first. - New regression test `detail_escapes_xss_in_subject_for_title_and_headline` in `issues.rs` exercises the full XSS surface. # P2 — URL slug encoding (#13) `/roles/<name>`, `/projects/<identifier>`, `/users/<login>` paths interpolated the slug raw. A name with `#`/`?`/`/` would break the browser's URL parse (`#` = fragment, `/` = path segment). Fix: - `common::encode_path_segment` — percent-encodes everything outside RFC-3986 unreserved + path-safe chars. No new dep (rule is small). - Roles, projects, users all route hrefs through it. - New regression test `list_percent_encodes_role_names_with_reserved_chars` in `roles.rs` (Q/A → Q%2FA, R&D → R%26D). # P1 — RM_BIND fail-fast (#7) `std::env::var("RM_BIND").ok().and_then(|s| s.parse().ok())` collapsed unset and malformed into the same fallback. A typo silently started the server on the default. Now we distinguish: - Unset → default - Malformed → return `io::Error::other(...)` immediately # Skipped - #9 (auth routes not reachable) — already fixed by #10's `build_router_with` refactor that mounts `rm_auth::router(cfg)`. - #8 (lowercase vs PascalCase OGAR table names) — schema- divergence noted in W3's doc, lands in a dedicated PR; needs the per-port DDL emission story W4-followups will revisit. cargo fmt --check + cargo clippy --all-targets -- -D warnings + cargo test --workspace all green locally; 108 tests across 5 crates.
First Phase-1 width track from the Redmine Integration Plan
W1 — Issue (
project_work_item). The hot-path resource — Redmine'sIssue, OpenProject'sWorkPackage, both routing throughclass_ids::PROJECT_WORK_ITEM(0x0102). End-to-end render goesthrough the OGAR askama kit; the kit inherits Redmine's 17-yr UX
(
ColumnKind, formatters) so the handler body stays ~80 LOC.What this adds
rm-handlerscrate — the Phase-1 fan-out point. Each widthtrack (W2..W8) adds one module here, no two parallel tracks edit
the same file (Plan §8 file ownership).
rm-handlers/src/issues.rs— W1 routes:GET /issues— list view (columns#+Subject)GET /issues/:id— detail viewrm-handlers/src/common.rs— sharedAppState,wrap_in_doc,record_id_to_u64rm-storegainsStore::list_issues()returningVec<IssueRow>rm-servergainsbuild_router_with(store, auth_cfg)—the merge file where width tracks add their resource routers
alphabetised by URL path
Live end-to-end
DoD pinned by 7 tests in rm-handlers + 3 new in rm-store
All three CI gates green locally: fmt + clippy + cargo test --workspace (58 tests across 5 crates).
Notable
HandlerError::Render(String)—askamais transitive throughogar-render-askama; declaring it directly here would re-triggerthe
askama_axummacro-expansion issue we hit in W0.1. Map at thecall site.
RecordIddoesn't implDisplay; usesToSql::to_sql()for URL hrefs + the stable hash input.rm-handlersdoes NOT depend onrm-server— rm-server consumesrm-handlers, reverse dep would cycle. Plan §8 dep DAG holds.
What this unlocks
Phase 1 parallel fan-out — W2..W8 follow the same shape (one
file in rm-handlers, one alphabetised
.mergein rm-server'sbuild_router_with).🤖 Generated with Claude Code