max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
6 files changed,
+84 insertions,
-25 deletions
| @@ -920,6 +920,10 @@ | |||
| 920 | 920 | pub struct ItemSalesTabTemplate { | |
| 921 | 921 | pub item: Item, | |
| 922 | 922 | pub sales: Vec<SaleRow>, | |
| 923 | + | /// Where the described Export control posts. Built by the handler rather | |
| 924 | + | /// than composed in the template: `export_act::html` takes a `&str` and | |
| 925 | + | /// Askama cannot `format!` one in an expression. | |
| 926 | + | pub export_route: String, | |
| 923 | 927 | } | |
| 924 | 928 | ||
| 925 | 929 | /// Item embed tab: copy-paste embed codes for this item. |
| @@ -588,6 +588,12 @@ | |||
| 588 | 588 | let export_routes = CsrfRouter::new() | |
| 589 | 589 | .route("/api/export/projects", post_csrf(exports::export_projects)) | |
| 590 | 590 | .route("/api/export/sales", post_csrf(exports::export_sales)) | |
| 591 | + | // Item-scoped, and separate from the seller-wide export above: the item | |
| 592 | + | // sales tab offers a file for the one item it is showing. `1ea96868`. | |
| 593 | + | .route( | |
| 594 | + | "/api/export/items/{id}/sales", | |
| 595 | + | post_csrf(exports::export_item_sales), | |
| 596 | + | ) | |
| 591 | 597 | .route( | |
| 592 | 598 | "/api/export/purchases", | |
| 593 | 599 | post_csrf(exports::export_purchases), |
| @@ -1,7 +1,12 @@ | |||
| 1 | 1 | <div class="item-sales-tab-header"> | |
| 2 | 2 | <h2 class="subsection-title">Sales</h2> | |
| 3 | 3 | {% if !sales.is_empty() %} | |
| 4 | - | <button class="btn-secondary btn-compact" data-action="exportItemSalesCSV">Export CSV</button> | |
| 4 | + | {#- The described export control, posting to a server route that runs | |
| 5 | + | `sanitize_csv_cell` over every free-text column. This was | |
| 6 | + | `data-action="exportItemSalesCSV"`, which built the file in the browser | |
| 7 | + | by scraping this table and quoting each cell with a bare `"`. See | |
| 8 | + | `1ea96868`. -#} | |
| 9 | + | {{ crate::quasi::export_act::html(export_route.as_str(), "item-sales.csv")|safe }} | |
| 5 | 10 | {% endif %} | |
| 6 | 11 | </div> | |
| 7 | 12 | ||
| @@ -44,5 +49,3 @@ | |||
| 44 | 49 | </table> | |
| 45 | 50 | {% endif %} | |
| 46 | 51 | ||
| 47 | - | <div id="tab-item-sales-cfg" hidden data-item-title="{{ item.title }}"></div> | |
| 48 | - | <script src="/static/tab-item-sales.js?v=0623"></script> |
| @@ -542,6 +542,67 @@ | |||
| 542 | 542 | finish_csv(is_htmx, "makenot-work-sales.csv", rx).await | |
| 543 | 543 | } | |
| 544 | 544 | ||
| 545 | + | /// Export one item's sales as a downloadable CSV file. | |
| 546 | + | /// | |
| 547 | + | /// `1ea96868`. This existed as `static/tab-item-sales.js`, which built the file | |
| 548 | + | /// in the browser by scraping the rendered table and quoting each cell with a | |
| 549 | + | /// bare `"`. That neutralised no formula prefix and escaped no embedded quote, | |
| 550 | + | /// and the Buyer column is `guest_email` -- typed by the buyer at guest | |
| 551 | + | /// checkout, so attacker-controlled and landing in a file the creator opens. | |
| 552 | + | /// Every server-side export in this module already ran `sanitize_csv_cell`; | |
| 553 | + | /// that one was the only export in the tree built client-side and inherited | |
| 554 | + | /// none of it. | |
| 555 | + | /// | |
| 556 | + | /// # Why it reads the whole set rather than paging | |
| 557 | + | /// | |
| 558 | + | /// The item's own sales tab already calls `get_sales_by_item` and renders every | |
| 559 | + | /// row, so holding the same set here adds no exposure the page did not have. | |
| 560 | + | /// Paging it would want a second query, and this one is item-scoped rather than | |
| 561 | + | /// seller-scoped: it is a page of a creator's history, not the history. | |
| 562 | + | /// | |
| 563 | + | /// Ownership is the query's, not a separate check: `get_sales_by_item` takes | |
| 564 | + | /// `seller_id` and filters on it, so another creator's item id returns nothing. | |
| 565 | + | #[tracing::instrument(skip_all, name = "exports::export_item_sales")] | |
| 566 | + | pub(super) async fn export_item_sales( | |
| 567 | + | State(db): State<PgPool>, | |
| 568 | + | headers: HeaderMap, | |
| 569 | + | AuthUser(user): AuthUser, | |
| 570 | + | axum::extract::Path(item_id): axum::extract::Path<crate::db::ItemId>, | |
| 571 | + | ) -> Result<Response> { | |
| 572 | + | let is_htmx = is_htmx_request(&headers); | |
| 573 | + | let sales = db::transactions::get_sales_by_item(&db, item_id, user.id).await?; | |
| 574 | + | ||
| 575 | + | let mut body = String::new(); | |
| 576 | + | for tx in &sales { | |
| 577 | + | let buyer = tx | |
| 578 | + | .guest_email | |
| 579 | + | .clone() | |
| 580 | + | .or_else(|| tx.buyer_id.map(|_| "Registered user".to_string())) | |
| 581 | + | .unwrap_or_else(|| "Unknown".to_string()); | |
| 582 | + | writeln!( | |
| 583 | + | body, | |
| 584 | + | "{},{},{},{}", | |
| 585 | + | tx.created_at.format("%Y-%m-%d %H:%M"), | |
| 586 | + | sanitize_csv_cell(&buyer), | |
| 587 | + | crate::formatting::format_dollars_plain(tx.amount_cents.as_i64()), | |
| 588 | + | sanitize_csv_cell(&tx.status.to_string()), | |
| 589 | + | ) | |
| 590 | + | .unwrap(); | |
| 591 | + | } | |
| 592 | + | ||
| 593 | + | // One page, then done. `spawn_paginated_csv` ends when a page comes back | |
| 594 | + | // shorter than a batch, so the row count is what terminates it and an | |
| 595 | + | // oversized set still stops on the empty second page. | |
| 596 | + | let rows = sales.len(); | |
| 597 | + | let mut once = Some(body); | |
| 598 | + | let rx = spawn_paginated_csv("Date,Buyer,Amount,Status\n", move |_limit, _offset| { | |
| 599 | + | let page = once.take(); | |
| 600 | + | async move { Ok(page.map_or_else(|| (String::new(), 0), |text| (text, rows))) } | |
| 601 | + | }); | |
| 602 | + | ||
| 603 | + | finish_csv(is_htmx, "makenot-work-item-sales.csv", rx).await | |
| 604 | + | } | |
| 605 | + | ||
| 545 | 606 | /// Export revenue splits as a downloadable CSV file. | |
| 546 | 607 | #[tracing::instrument(skip_all, name = "exports::export_splits")] | |
| 547 | 608 | pub(super) async fn export_splits( |
| @@ -229,5 +229,11 @@ | |||
| 229 | 229 | }) | |
| 230 | 230 | .collect(); | |
| 231 | 231 | ||
| 232 | - | Ok(ItemSalesTabTemplate { item, sales: rows }) | |
| 232 | + | let export_route = format!("/api/export/items/{item_id}/sales"); | |
| 233 | + | ||
| 234 | + | Ok(ItemSalesTabTemplate { | |
| 235 | + | item, | |
| 236 | + | sales: rows, | |
| 237 | + | export_route, | |
| 238 | + | }) | |
| 233 | 239 | } |
| @@ -1,21 +1,0 @@ | |||
| 1 | - | function exportItemSalesCSV() { | |
| 2 | - | var cfg = document.getElementById('tab-item-sales-cfg'); | |
| 3 | - | var itemTitle = cfg.dataset.itemTitle; | |
| 4 | - | var table = document.getElementById('item-sales-table'); | |
| 5 | - | if (!table) return; | |
| 6 | - | var rows = table.querySelectorAll('tbody tr'); | |
| 7 | - | var csv = 'Date,Buyer,Amount,Status\n'; | |
| 8 | - | rows.forEach(function(row) { | |
| 9 | - | var cells = row.querySelectorAll('td'); | |
| 10 | - | if (cells.length < 4) return; | |
| 11 | - | csv += '"' + cells[0].textContent.trim() + '","' | |
| 12 | - | + cells[1].textContent.trim() + '","' | |
| 13 | - | + cells[2].textContent.trim() + '","' | |
| 14 | - | + cells[3].textContent.trim() + '"\n'; | |
| 15 | - | }); | |
| 16 | - | var blob = new Blob([csv], { type: 'text/csv' }); | |
| 17 | - | var a = document.createElement('a'); | |
| 18 | - | a.href = URL.createObjectURL(blob); | |
| 19 | - | a.download = itemTitle + '-sales.csv'; | |
| 20 | - | a.click(); | |
| 21 | - | } |