max / makenotwork
3 files changed,
+97 insertions,
-56 deletions
| @@ -522,18 +522,14 @@ pub(super) async fn project_tab_code( | |||
| 522 | 522 | ||
| 523 | 523 | /// Render the HTMX partial for the project members tab. | |
| 524 | 524 | #[tracing::instrument(skip_all, name = "project_tabs::project_tab_members")] | |
| 525 | - | pub(super) async fn project_tab_members( | |
| 526 | - | State(state): State<AppState>, | |
| 527 | - | AuthUser(session_user): AuthUser, | |
| 528 | - | headers: HeaderMap, | |
| 529 | - | Path(slug): Path<String>, | |
| 530 | - | ) -> Result<axum::response::Response> { | |
| 531 | - | let (db_project, generation) = match resolve_project_etag(&state, session_user.id, &slug, &headers).await? { | |
| 532 | - | Ok(pair) => pair, | |
| 533 | - | Err(not_modified) => return Ok(not_modified), | |
| 534 | - | }; | |
| 535 | - | ||
| 536 | - | let db_members = db::project_members::get_project_members(&state.db, db_project.id).await?; | |
| 525 | + | /// Load a project's members as view rows plus the owner's residual split | |
| 526 | + | /// percent. Shared by the members tab and the combined monetization tab — the | |
| 527 | + | /// mapping was copy-pasted verbatim in both (audit Run 17 Architecture). | |
| 528 | + | async fn load_project_members( | |
| 529 | + | state: &AppState, | |
| 530 | + | project_id: db::ProjectId, | |
| 531 | + | ) -> Result<(Vec<ProjectMemberRow>, i64)> { | |
| 532 | + | let db_members = db::project_members::get_project_members(&state.db, project_id).await?; | |
| 537 | 533 | let members: Vec<ProjectMemberRow> = db_members | |
| 538 | 534 | .iter() | |
| 539 | 535 | .map(|m| ProjectMemberRow { | |
| @@ -547,9 +543,22 @@ pub(super) async fn project_tab_members( | |||
| 547 | 543 | added_at: m.added_at.format("%Y-%m-%d").to_string(), | |
| 548 | 544 | }) | |
| 549 | 545 | .collect(); | |
| 546 | + | let total_member_split = db::project_members::get_total_split_percent(&state.db, project_id).await?; | |
| 547 | + | Ok((members, 100 - total_member_split)) | |
| 548 | + | } | |
| 550 | 549 | ||
| 551 | - | let total_member_split = db::project_members::get_total_split_percent(&state.db, db_project.id).await?; | |
| 552 | - | let owner_split = 100 - total_member_split; | |
| 550 | + | pub(super) async fn project_tab_members( | |
| 551 | + | State(state): State<AppState>, | |
| 552 | + | AuthUser(session_user): AuthUser, | |
| 553 | + | headers: HeaderMap, | |
| 554 | + | Path(slug): Path<String>, | |
| 555 | + | ) -> Result<axum::response::Response> { | |
| 556 | + | let (db_project, generation) = match resolve_project_etag(&state, session_user.id, &slug, &headers).await? { | |
| 557 | + | Ok(pair) => pair, | |
| 558 | + | Err(not_modified) => return Ok(not_modified), | |
| 559 | + | }; | |
| 560 | + | ||
| 561 | + | let (members, owner_split) = load_project_members(&state, db_project.id).await?; | |
| 553 | 562 | ||
| 554 | 563 | Ok(helpers::with_etag(generation, ProjectMembersTabTemplate { | |
| 555 | 564 | project_id: db_project.id.to_string(), | |
| @@ -591,22 +600,7 @@ pub(super) async fn project_tab_monetization( | |||
| 591 | 600 | .collect(); | |
| 592 | 601 | ||
| 593 | 602 | // Members | |
| 594 | - | let db_members = db::project_members::get_project_members(&state.db, db_project.id).await?; | |
| 595 | - | let members: Vec<ProjectMemberRow> = db_members | |
| 596 | - | .iter() | |
| 597 | - | .map(|m| ProjectMemberRow { | |
| 598 | - | id: m.id.to_string(), | |
| 599 | - | user_id: m.user_id.to_string(), | |
| 600 | - | username: m.username.clone(), | |
| 601 | - | display_name: m.display_name.clone(), | |
| 602 | - | role: m.role.to_string(), | |
| 603 | - | split_percent: m.split_percent, | |
| 604 | - | stripe_connected: m.stripe_account_id.is_some() && m.stripe_charges_enabled, | |
| 605 | - | added_at: m.added_at.format("%Y-%m-%d").to_string(), | |
| 606 | - | }) | |
| 607 | - | .collect(); | |
| 608 | - | let total_member_split = db::project_members::get_total_split_percent(&state.db, db_project.id).await?; | |
| 609 | - | let owner_split = 100 - total_member_split; | |
| 603 | + | let (members, owner_split) = load_project_members(&state, db_project.id).await?; | |
| 610 | 604 | ||
| 611 | 605 | Ok(helpers::with_etag(generation, ProjectMonetizationTabTemplate { | |
| 612 | 606 | project_id: db_project.id.to_string(), |
| @@ -178,6 +178,32 @@ mod price_range_tests { | |||
| 178 | 178 | } | |
| 179 | 179 | ||
| 180 | 180 | /// Fetch items or projects with pagination; shared by both handlers. | |
| 181 | + | /// The normalized filter selection parsed from a `DiscoverQuery`: empty strings | |
| 182 | + | /// collapse to `None` and enum-valued params are parsed. Both the data fetch and | |
| 183 | + | /// the filter-chip rendering need exactly this and derived it independently | |
| 184 | + | /// (audit Run 17 Architecture) — the logic now lives in one place. | |
| 185 | + | struct DiscoverFilterSelection<'a> { | |
| 186 | + | item_type: Option<ItemType>, | |
| 187 | + | tag: Option<&'a str>, | |
| 188 | + | search: Option<&'a str>, | |
| 189 | + | category: Option<&'a str>, | |
| 190 | + | ai_tier: Option<db::AiTierFilter>, | |
| 191 | + | has_source_code: bool, | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | impl DiscoverQuery { | |
| 195 | + | fn filter_selection(&self) -> DiscoverFilterSelection<'_> { | |
| 196 | + | DiscoverFilterSelection { | |
| 197 | + | item_type: self.item_type.as_deref().filter(|s| !s.is_empty()).and_then(|s| s.parse().ok()), | |
| 198 | + | tag: self.tag.as_deref().filter(|s| !s.is_empty()), | |
| 199 | + | search: self.q.as_deref().filter(|s| !s.trim().is_empty()), | |
| 200 | + | category: self.category.as_deref().filter(|s| !s.is_empty()), | |
| 201 | + | ai_tier: self.ai_tier.as_deref().filter(|s| !s.is_empty()).and_then(|s| s.parse().ok()), | |
| 202 | + | has_source_code: self.has_source.as_deref() == Some("1"), | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | ||
| 181 | 207 | async fn fetch_discover_data(pool: &PgPool, query: &DiscoverQuery) -> Result<DiscoverData> { | |
| 182 | 208 | // Clamp the upper bound too (UX MINOR, Run #23): an unbounded page yields a | |
| 183 | 209 | // giant OFFSET = one expensive deep scan per request. Matches the git/admin | |
| @@ -187,19 +213,13 @@ async fn fetch_discover_data(pool: &PgPool, query: &DiscoverQuery) -> Result<Dis | |||
| 187 | 213 | let offset = ((page - 1) as i64) * limit; | |
| 188 | 214 | let mode = query.mode.as_deref().unwrap_or("projects"); | |
| 189 | 215 | ||
| 190 | - | let item_type_filter: Option<ItemType> = query.item_type.as_deref() | |
| 191 | - | .filter(|s| !s.is_empty()) | |
| 192 | - | .and_then(|s| s.parse().ok()); | |
| 193 | - | let tag_filter = query.tag.as_deref().filter(|s| !s.is_empty()); | |
| 194 | - | let search_filter = query.q.as_deref().filter(|s| !s.trim().is_empty()); | |
| 195 | - | ||
| 196 | - | let category_filter = query.category.as_deref().filter(|s| !s.is_empty()); | |
| 197 | - | ||
| 198 | - | let ai_tier_filter: Option<db::AiTierFilter> = query.ai_tier.as_deref() | |
| 199 | - | .filter(|s| !s.is_empty()) | |
| 200 | - | .and_then(|s| s.parse().ok()); | |
| 201 | - | ||
| 202 | - | let has_source_code = query.has_source.as_deref() == Some("1"); | |
| 216 | + | let f = query.filter_selection(); | |
| 217 | + | let item_type_filter = f.item_type; | |
| 218 | + | let tag_filter = f.tag; | |
| 219 | + | let search_filter = f.search; | |
| 220 | + | let category_filter = f.category; | |
| 221 | + | let ai_tier_filter = f.ai_tier; | |
| 222 | + | let has_source_code = f.has_source_code; | |
| 203 | 223 | ||
| 204 | 224 | let (items, projects, total_count) = if mode == "projects" { | |
| 205 | 225 | let sort_filter: Option<DiscoverSort> = query.sort.as_deref() | |
| @@ -367,16 +387,15 @@ pub(super) async fn discover( | |||
| 367 | 387 | Query(query): Query<DiscoverQuery>, | |
| 368 | 388 | ) -> Result<impl IntoResponse> { | |
| 369 | 389 | let csrf_token = get_csrf_token(&session).await; | |
| 370 | - | let search_filter = query.q.as_deref().filter(|s| !s.trim().is_empty()); | |
| 371 | - | let tag_filter = query.tag.as_deref().filter(|s| !s.is_empty()); | |
| 372 | - | let item_type_filter: Option<ItemType> = query.item_type.as_deref() | |
| 373 | - | .filter(|s| !s.is_empty()) | |
| 374 | - | .and_then(|s| s.parse().ok()); | |
| 375 | - | let has_source_code = query.has_source.as_deref() == Some("1"); | |
| 390 | + | let f = query.filter_selection(); | |
| 391 | + | let search_filter = f.search; | |
| 392 | + | let tag_filter = f.tag; | |
| 393 | + | let item_type_filter = f.item_type; | |
| 394 | + | let has_source_code = f.has_source_code; | |
| 376 | 395 | let data = fetch_discover_data(&state.db, &query).await?; | |
| 377 | 396 | ||
| 378 | 397 | // Build type and tag filters (items mode only) | |
| 379 | - | let category_filter = query.category.as_deref().filter(|s| !s.is_empty()); | |
| 398 | + | let category_filter = f.category; | |
| 380 | 399 | ||
| 381 | 400 | // Build category filters (projects mode only) | |
| 382 | 401 | let category_filters = if data.mode == "projects" { |
| @@ -16,13 +16,30 @@ pub struct FeedItem { | |||
| 16 | 16 | pub guid: String, | |
| 17 | 17 | } | |
| 18 | 18 | ||
| 19 | - | /// Escape XML special characters. | |
| 19 | + | /// Escape XML special characters, and drop bytes XML 1.0 forbids outright. | |
| 20 | + | /// | |
| 21 | + | /// XML 1.0 permits only `#x9` (tab), `#xA` (LF), and `#xD` (CR) from the C0 | |
| 22 | + | /// control range; every other control byte (`#x0`-`#x8`, `#xB`, `#xC`, | |
| 23 | + | /// `#xE`-`#x1F`) is illegal *even when escaped* — a strict feed parser rejects | |
| 24 | + | /// the whole document. Upstream validation already blocks most of these, so this | |
| 25 | + | /// is defense-in-depth: silently drop them (there is no valid representation) so | |
| 26 | + | /// a stray control byte in a title/description can't produce an unparseable feed. | |
| 20 | 27 | fn xml_escape(s: &str) -> String { | |
| 21 | - | s.replace('&', "&") | |
| 22 | - | .replace('<', "<") | |
| 23 | - | .replace('>', ">") | |
| 24 | - | .replace('"', """) | |
| 25 | - | .replace('\'', "'") | |
| 28 | + | let mut out = String::with_capacity(s.len()); | |
| 29 | + | for c in s.chars() { | |
| 30 | + | match c { | |
| 31 | + | '&' => out.push_str("&"), | |
| 32 | + | '<' => out.push_str("<"), | |
| 33 | + | '>' => out.push_str(">"), | |
| 34 | + | '"' => out.push_str("""), | |
| 35 | + | '\'' => out.push_str("'"), | |
| 36 | + | '\t' | '\n' | '\r' => out.push(c), | |
| 37 | + | // C0 control byte that XML 1.0 forbids — drop it. | |
| 38 | + | c if (c as u32) < 0x20 => {} | |
| 39 | + | c => out.push(c), | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | out | |
| 26 | 43 | } | |
| 27 | 44 | ||
| 28 | 45 | /// Core RSS 2.0 renderer. All public feed functions delegate here. | |
| @@ -270,6 +287,17 @@ mod tests { | |||
| 270 | 287 | } | |
| 271 | 288 | ||
| 272 | 289 | #[test] | |
| 290 | + | fn xml_escape_drops_illegal_control_bytes_keeps_tab_lf_cr() { | |
| 291 | + | // NUL, backspace, vertical tab, form feed, and other C0 controls are | |
| 292 | + | // illegal in XML 1.0 even escaped — they must be dropped, not emitted. | |
| 293 | + | assert_eq!(xml_escape("a\u{0}b\u{8}c\u{B}d\u{C}e\u{1F}f"), "abcdef"); | |
| 294 | + | // Tab / LF / CR are the three legal control chars — preserved verbatim. | |
| 295 | + | assert_eq!(xml_escape("x\ty\nz\r"), "x\ty\nz\r"); | |
| 296 | + | // A control byte adjacent to an entity char still escapes correctly. | |
| 297 | + | assert_eq!(xml_escape("A\u{0}&B"), "A&B"); | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | #[test] | |
| 273 | 301 | fn pub_date_is_rfc2822() { | |
| 274 | 302 | let date = Utc.with_ymd_and_hms(2026, 3, 15, 14, 30, 0).unwrap(); | |
| 275 | 303 | let item = FeedItem { |