max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
8 files changed,
+71 insertions,
-87 deletions
| @@ -242,20 +242,17 @@ | |||
| 242 | 242 | ||
| 243 | 243 | document.querySelectorAll('.download-version-btn').forEach(function(btn) { | |
| 244 | 244 | btn.addEventListener('click', function() { | |
| 245 | - | fetch('/api/versions/' + btn.dataset.versionId + '/download') | |
| 246 | - | .then(function(res) { | |
| 247 | - | if (!res.ok) throw new Error('Failed to get download URL'); | |
| 248 | - | return res.json(); | |
| 249 | - | }) | |
| 250 | - | .then(function(data) { window.location.href = data.download_url; }) | |
| 251 | - | .catch(function(err) { showToast(err.message); }); | |
| 245 | + | window.location.href = | |
| 246 | + | '/api/versions/' + btn.dataset.versionId + '/download'; | |
| 252 | 247 | }); | |
| 253 | 248 | }); | |
| 254 | 249 | ||
| 255 | 250 | // The delete button is described: `crate::quasi::version_delete_act`, | |
| 256 | 251 | // Shape 3 step 4. Route, row, prompt and tone all live on the act, and | |
| 257 | - | // htmx performs it. The download button above stays here because its | |
| 258 | - | // route answers JSON carrying a presigned URL rather than the file. | |
| 252 | + | // htmx performs it. The download button above is now a plain navigation | |
| 253 | + | // because its route answers 303 to the presigned URL (`8fc6b1af`); it is | |
| 254 | + | // describable as an `Action::get` whenever the files tab converts, which | |
| 255 | + | // is what taking the JSON away bought. | |
| 259 | 256 | ||
| 260 | 257 | document.getElementById('cancel-version-upload-btn').addEventListener('click', function() { | |
| 261 | 258 | uploader.cancel(); |
| @@ -1,13 +1,6 @@ | |||
| 1 | + | // The route answers 303 to a presigned URL, so navigating to it IS the | |
| 2 | + | // download (`8fc6b1af`, option (a)). It used to answer JSON describing the | |
| 3 | + | // URL, which is why this was a fetch-then-assign. | |
| 1 | 4 | function downloadVersion(versionId) { | |
| 2 | - | fetch('/api/versions/' + versionId + '/download') | |
| 3 | - | .then(function(res) { | |
| 4 | - | if (!res.ok) throw new Error('Failed to get download URL'); | |
| 5 | - | return res.json(); | |
| 6 | - | }) | |
| 7 | - | .then(function(data) { | |
| 8 | - | window.location.href = data.download_url; | |
| 9 | - | }) | |
| 10 | - | .catch(function(err) { | |
| 11 | - | showToast(err.message || 'Download failed'); | |
| 12 | - | }); | |
| 5 | + | window.location.href = '/api/versions/' + versionId + '/download'; | |
| 13 | 6 | } |
| @@ -16,16 +16,9 @@ | |||
| 16 | 16 | } | |
| 17 | 17 | })(); | |
| 18 | 18 | ||
| 19 | + | // The route answers 303 to a presigned URL, so navigating to it IS the | |
| 20 | + | // download (`8fc6b1af`, option (a)). It used to answer JSON describing the | |
| 21 | + | // URL, which is why this was a fetch-then-assign. | |
| 19 | 22 | function downloadVersion(versionId) { | |
| 20 | - | fetch('/api/versions/' + versionId + '/download') | |
| 21 | - | .then(function(res) { | |
| 22 | - | if (!res.ok) throw new Error('Failed to get download URL'); | |
| 23 | - | return res.json(); | |
| 24 | - | }) | |
| 25 | - | .then(function(data) { | |
| 26 | - | window.location.href = data.download_url; | |
| 27 | - | }) | |
| 28 | - | .catch(function(err) { | |
| 29 | - | showToast(err.message || 'Download failed'); | |
| 30 | - | }); | |
| 23 | + | window.location.href = '/api/versions/' + versionId + '/download'; | |
| 31 | 24 | } |
| @@ -1,13 +1,6 @@ | |||
| 1 | + | // The route answers 303 to a presigned URL, so navigating to it IS the | |
| 2 | + | // download (`8fc6b1af`, option (a)). It used to answer JSON describing the | |
| 3 | + | // URL, which is why this was a fetch-then-assign. | |
| 1 | 4 | function downloadVersion(versionId) { | |
| 2 | - | fetch('/api/versions/' + versionId + '/download') | |
| 3 | - | .then(function(res) { | |
| 4 | - | if (!res.ok) throw new Error('Failed to get download URL'); | |
| 5 | - | return res.json(); | |
| 6 | - | }) | |
| 7 | - | .then(function(data) { | |
| 8 | - | window.location.href = data.download_url; | |
| 9 | - | }) | |
| 10 | - | .catch(function(err) { | |
| 11 | - | showToast(err.message || 'Download failed'); | |
| 12 | - | }); | |
| 5 | + | window.location.href = '/api/versions/' + versionId + '/download'; | |
| 13 | 6 | } |
| @@ -32,17 +32,19 @@ | |||
| 32 | 32 | //! a fact the page does not have. A version row has an id in the database and | |
| 33 | 33 | //! the template was simply not writing it down. | |
| 34 | 34 | //! | |
| 35 | - | //! # The download button beside it is not here, and that is measured | |
| 35 | + | //! # The download button beside it is sayable now, and was not when this landed | |
| 36 | 36 | //! | |
| 37 | - | //! Wiki [[mnw-shape-conversion-plans]] Shape 3 step 4 says | |
| 38 | - | //! `download-version-btn` is `Action::get`. It is not. | |
| 39 | - | //! `GET /api/versions/{id}/download` (`routes::storage::downloads`) answers | |
| 40 | - | //! **JSON carrying a presigned URL**, and the handler then navigates to it, so | |
| 41 | - | //! a described `Action::get` would send the reader to a JSON body. Making it | |
| 42 | - | //! sayable is a route change — answer a redirect, or serve the route through | |
| 43 | - | //! the description layer so it can answer `Outcome::Goto` with a | |
| 44 | - | //! `Destination::External` — rather than a control change, so the handler | |
| 45 | - | //! stays. | |
| 37 | + | //! Wiki [[mnw-shape-conversion-plans]] Shape 3 step 4 said | |
| 38 | + | //! `download-version-btn` is `Action::get`. When this module landed that was | |
| 39 | + | //! wrong: `GET /api/versions/{id}/download` (`routes::storage::downloads`) | |
| 40 | + | //! answered JSON carrying a presigned URL and the handler navigated to it, so a | |
| 41 | + | //! described `Action::get` would have sent the reader to a JSON body. | |
| 42 | + | //! | |
| 43 | + | //! Fixed 2026-08-26 by the route rather than by the control (`8fc6b1af`, | |
| 44 | + | //! option (a)): it answers **303 to the presigned URL**, so navigating to it is | |
| 45 | + | //! the download and `Action::get` says the truth. The button is a plain | |
| 46 | + | //! navigation in `static/item-upload.js` today and becomes an act whenever the | |
| 47 | + | //! files tab converts. Nothing blocks it any more. | |
| 46 | 48 | ||
| 47 | 49 | use makeover_layout::Tone; | |
| 48 | 50 | use quasi_router::screen::Act; |
| @@ -292,11 +292,11 @@ | |||
| 292 | 292 | .client | |
| 293 | 293 | .get(&format!("/api/versions/{version_id}/download")) | |
| 294 | 294 | .await; | |
| 295 | - | assert_eq!(resp.status, 200, "Download failed: {}", resp.text); | |
| 296 | - | let data: Value = resp.json(); | |
| 295 | + | // 303 to the presigned URL, not JSON describing it (`8fc6b1af`, option (a)). | |
| 296 | + | assert_eq!(resp.status, 303, "Download failed: {}", resp.text); | |
| 297 | 297 | assert!( | |
| 298 | - | data["download_url"].is_string(), | |
| 299 | - | "Should return download_url" | |
| 298 | + | resp.headers.contains_key("location"), | |
| 299 | + | "303 should carry the presigned URL in Location" | |
| 300 | 300 | ); | |
| 301 | 301 | } | |
| 302 | 302 |
| @@ -469,13 +469,21 @@ | |||
| 469 | 469 | .client | |
| 470 | 470 | .get(&format!("/api/versions/{version_id}/download")) | |
| 471 | 471 | .await; | |
| 472 | - | assert_eq!(resp.status, 200, "Version download failed: {}", resp.text); | |
| 473 | - | let data: Value = resp.json(); | |
| 472 | + | // 303 to the presigned URL, not JSON describing it (`8fc6b1af`, option (a)). | |
| 473 | + | assert_eq!( | |
| 474 | + | resp.status, 303, | |
| 475 | + | "Version download should redirect: {}", | |
| 476 | + | resp.text | |
| 477 | + | ); | |
| 478 | + | let location = resp | |
| 479 | + | .headers | |
| 480 | + | .get("location") | |
| 481 | + | .expect("303 carries a Location") | |
| 482 | + | .to_str() | |
| 483 | + | .unwrap(); | |
| 474 | 484 | assert!( | |
| 475 | - | data["download_url"] | |
| 476 | - | .as_str() | |
| 477 | - | .unwrap() | |
| 478 | - | .starts_with("http://test-storage/") | |
| 485 | + | location.starts_with("http://test-storage/"), | |
| 486 | + | "Location should be the presigned URL, got: {location}" | |
| 479 | 487 | ); | |
| 480 | 488 | } | |
| 481 | 489 |
| @@ -3,7 +3,7 @@ | |||
| 3 | 3 | use axum::{ | |
| 4 | 4 | Json, | |
| 5 | 5 | extract::{Path, State}, | |
| 6 | - | response::IntoResponse, | |
| 6 | + | response::{IntoResponse, Redirect}, | |
| 7 | 7 | }; | |
| 8 | 8 | use serde::Serialize; | |
| 9 | 9 | use sqlx::PgPool; | |
| @@ -23,16 +23,6 @@ | |||
| 23 | 23 | pub expires_in: u64, | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | - | /// JSON response containing a presigned download URL for a version. | |
| 27 | - | #[derive(Debug, Serialize)] | |
| 28 | - | pub(super) struct VersionDownloadResponse { | |
| 29 | - | pub download_url: String, | |
| 30 | - | pub file_name: Option<String>, | |
| 31 | - | pub expires_in: u64, | |
| 32 | - | #[serde(skip_serializing_if = "Option::is_none")] | |
| 33 | - | pub license_url: Option<String>, | |
| 34 | - | } | |
| 35 | - | ||
| 36 | 26 | /// Resolve a content URL for downloadable media. Always a presigned, expiring | |
| 37 | 27 | /// URL, even for free content. | |
| 38 | 28 | /// | |
| @@ -165,11 +155,27 @@ | |||
| 165 | 155 | })) | |
| 166 | 156 | } | |
| 167 | 157 | ||
| 168 | - | /// Generate a presigned URL for downloading a version file | |
| 158 | + | /// Redirect to a presigned URL for downloading a version file | |
| 169 | 159 | /// | |
| 170 | 160 | /// GET /api/versions/{version_id}/download | |
| 171 | 161 | /// | |
| 172 | 162 | /// Access control: free items are accessible to anyone, paid items require purchase. | |
| 163 | + | /// | |
| 164 | + | /// Answers **303 See Other** to the presigned URL rather than JSON describing it. | |
| 165 | + | /// Ruled 2026-08-26 (`8fc6b1af`, option (a)): a described `Action::get` sends the | |
| 166 | + | /// reader wherever the route sends them, so a JSON body made this control the one | |
| 167 | + | /// thing on the files tab that could not be said in the description layer. | |
| 168 | + | /// | |
| 169 | + | /// Three fields retired with the JSON, all measured unread: `file_name` (every | |
| 170 | + | /// surface already renders it server-side from `version.file_name`), `license_url` | |
| 171 | + | /// (no reader anywhere in the tree) and `expires_in` (the expiry is 3600 either | |
| 172 | + | /// way). `download_url` was the only one anyone consumed, and a redirect *is* it. | |
| 173 | + | /// | |
| 174 | + | /// A redirect is a webview fact, so a terminal or egui host cannot perform it. | |
| 175 | + | /// Serving this route through the description layer so it can answer | |
| 176 | + | /// `Outcome::Goto` with a `Destination::External` remains the cross-host spelling, | |
| 177 | + | /// and stays available: it waits on the `QUASI_SCREENS` flip chain (`64b33b26`), | |
| 178 | + | /// which is why it was not chosen now. | |
| 173 | 179 | #[tracing::instrument(skip_all, name = "storage::version_download", fields(version_id))] | |
| 174 | 180 | pub(super) async fn version_download( | |
| 175 | 181 | State(db): State<PgPool>, | |
| @@ -228,7 +234,7 @@ | |||
| 228 | 234 | } | |
| 229 | 235 | } | |
| 230 | 236 | ||
| 231 | - | let (download_url, expires_in) = resolve_content_url(s3.as_ref(), s3_key, 3600).await?; | |
| 237 | + | let (download_url, _expires_in) = resolve_content_url(s3.as_ref(), s3_key, 3600).await?; | |
| 232 | 238 | ||
| 233 | 239 | // Increment per-version and item-level download counts | |
| 234 | 240 | db::versions::increment_download_count(&db, version_id).await?; | |
| @@ -239,16 +245,8 @@ | |||
| 239 | 245 | let _ = db::versions::record_user_download(&db, user.id, version.item_id, version_id).await; | |
| 240 | 246 | } | |
| 241 | 247 | ||
| 242 | - | let license_url = if item.license_preset.is_some() { | |
| 243 | - | Some(format!("/api/items/{}/license.txt", version.item_id)) | |
| 244 | - | } else { | |
| 245 | - | None | |
| 246 | - | }; | |
| 247 | - | ||
| 248 | - | Ok(Json(VersionDownloadResponse { | |
| 249 | - | download_url, | |
| 250 | - | file_name: version.file_name, | |
| 251 | - | expires_in, | |
| 252 | - | license_url, | |
| 253 | - | })) | |
| 248 | + | // The counters above run before the redirect for the same reason they ran | |
| 249 | + | // before the JSON: this handler is the only place the intent to download is | |
| 250 | + | // observable. Once the reader is at the storage host we never hear about it. | |
| 251 | + | Ok(Redirect::to(&download_url)) | |
| 254 | 252 | } |