//! The Askama entry point for a described upload. //! //! Shape 3 of the conversion plan (wiki `mnw-shape-conversion-plans`), and the //! one that had to wait for two vocabulary rulings rather than one. `f7261a5a` //! settled what an upload says about itself: what it takes //! ([`Field::upload`] plus its accept list), how many at a time //! ([`Field::many`]), where the bytes go (the field's action) and that it //! waits ([`Action::awaiting`]). `a81384d4` settled who makes the calls, which //! none of those four axes covered: an MNW upload is presign, then a PUT //! straight to storage from the browser, then confirm, and a renderer posting //! the field at the first of those would be wrong about the response shape and //! about where the bytes end up. [`Action::by_host`] is the answer. The //! renderer emits the address as `data-sends` and performs nothing; //! `static/upload.js` reads it and runs the chain. //! //! # What is described here and what stays the host's //! //! Described: the accept list, the multiplicity, the destination, the payload //! that rides with it, and that the call waits. Every one of those was //! hand-written in a template before this, three times over with three //! spellings of the same accept list. //! //! Host: the drop gesture and the progress bar. Neither is an omission. //! `makeover-layout`'s `FieldKind::File` doc says a drop area, a picker button //! and a typed path are one field and that the gesture "is not described for //! the reason no gesture is", so the dragging stays in `upload.js`. Progress is //! `Awaiting`'s ruling: the mark says the call waits and carries a measured //! size when there is one, and the renderer observes the rest. The server //! renders this markup before a file exists, so there is no size to write down //! and the mark is the unmeasured one. //! //! # Why a function per surface rather than one builder //! //! Three call sites, each with a fixed accept list that is a fact about MNW //! rather than about the template it sits in. A generic entry point would put //! `.zip,.dmg,.exe,.appimage,.deb,.tar.gz,.clap,.vst3` back in Askama, in two //! places, which is the duplication this shape exists to remove. So the lists //! live here once and each surface is named for what it is. use makeover_layout::Family; use quasi_router::{Accepted, Action, Field, Node}; /// Every suffix a version file may carry. /// /// One list, read by both version surfaces. A suffix names no family by ruling /// (`f7261a5a`: a suffix-to-family table rots), so these are `Suffix` and the /// reader gets no media disclosure from them, which is right for a build /// artifact. fn version_suffixes() -> Vec { [ ".zip", ".dmg", ".exe", ".appimage", ".deb", ".tar.gz", ".clap", ".vst3", ] .into_iter() .map(Accepted::suffix) .collect() } /// The drop area an upload field sits in, and the field itself. /// /// The wrapper is this app's and the group inside it is the renderer's, the /// same division `widgets::carousel` and `quasi::rich_field` make. What the /// wrapper carries is the gesture, which is why it is here: `upload.js` binds /// drag, drop and click-to-open on `.file-upload-area`, and finds the /// destination on the field wrapper the renderer emitted inside it. /// /// `fallback` is what to call the bytes when the browser offers no media type /// for them, which happens for the suffixes S3 has never heard of. It is a host /// fact and not a described one: the presigned URL binds the content type, so /// the PUT has to send back the exact string the presign was asked for, and /// `S3Client::validate_content_type` refuses `application/octet-stream` for an /// item's audio. It rides as an attribute here rather than as a literal in /// Askama so that both halves of the pair stay in one file. fn area(prompt: &str, hint: &str, fallback: &str, field: Field) -> String { use quasi_axum::Serves as _; // No shell: a fragment landing inside a document Askama already built. let inner = quasi_webview::Webview::new().fragment(&Node::field(field)); format!( "
\
{}
\
{}
{inner}
", crate::helpers::escape_html(fallback), crate::helpers::escape_html(prompt), crate::helpers::escape_html(hint), ) } /// An item's audio file, replaced whole each time. /// /// One file, any audio media type, landing through `/api/upload/presign`. The /// two values the chain needs beyond the file ride as the action's parameters /// and reach the host as `data-vals`, which is the whole of what the old /// `data-item-id` attribute and the hard-coded `file_type: 'audio'` literal /// were doing. #[must_use] pub fn audio(item_id: &str) -> String { let field = Field::upload("audio", "Audio file", [Accepted::family(Family::Audio)]).changes( Action::post("/api/upload/presign") .with("item_id", item_id) .with("file_type", "audio") .awaiting() .by_host(), ); area( "Drop audio file here or choose one to upload", "Supports MP3, WAV, FLAC, M4A up to 500 MB", "audio/mpeg", field, ) } /// One file for a version that already exists. /// /// The same accept list as [`version_queue`] and a destination of its own, /// since the version this belongs to is already in the database and its id is /// in the address rather than in the payload. #[must_use] pub fn existing_version(version_id: &str) -> String { let field = Field::upload("version-file", "Version file", version_suffixes()).changes( Action::post(format!("/api/versions/{version_id}/upload/presign")) .awaiting() .by_host(), ); area( "Drop file to upload for this version", "ZIP, DMG, EXE, AppImage, DEB, tar.gz, CLAP, VST3", "application/octet-stream", field, ) } /// The files a new version is being built from, picked before it exists. /// /// Several at once, and deliberately with no destination: nothing can be sent /// until the reader has named the version and labelled each file, so the /// address belongs to the button that does that and not to this field. What is /// described is what it takes and how many, which is the half that was written /// out by hand in the template. #[must_use] pub fn version_queue() -> String { use quasi_axum::Serves as _; let field = Field::upload("version-files", "Files", version_suffixes()).many(); quasi_webview::Webview::new().fragment(&Node::field(field)) } #[cfg(test)] mod tests { /// The four axes `f7261a5a` settled, on the surface that has all of them. /// Read together they are the whole point of the conversion: before this /// the accept list was an attribute in Askama, the destination was a /// string literal in `item-upload.js`, and nothing said the call waits. #[test] fn an_audio_upload_says_what_it_takes_where_it_goes_and_that_it_waits() { let html = super::audio("11111111-1111-1111-1111-111111111111"); assert!(html.contains(r#"type="file""#), "{html}"); assert!(html.contains(r#"accept="audio/*""#), "{html}"); assert!(!html.contains(" multiple"), "{html}"); assert!( html.contains(r#"data-sends="/api/upload/presign""#), "{html}" ); assert!(html.contains("data-awaiting="), "{html}"); } /// The host makes this call, so no transport comes out. A `hx-post` here /// would be htmx sending the file to the signing endpoint, which answers /// JSON and is not where the bytes go. #[test] fn no_transport_is_emitted_for_a_host_made_call() { let html = super::audio("11111111-1111-1111-1111-111111111111"); assert!(!html.contains("hx-post"), "{html}"); assert!(!html.contains("hx-trigger"), "{html}"); assert!(!html.contains("href="), "{html}"); } /// The payload the chain needs, carried by the description rather than by /// a `data-item-id` attribute on an ancestor. This is the assertion that /// would be silent if it were wrong: the upload would presign against no /// item and fail at the far end. #[test] fn the_payload_rides_with_the_destination() { let html = super::audio("11111111-1111-1111-1111-111111111111"); assert!(html.contains("data-vals="), "{html}"); assert!(html.contains("item_id"), "{html}"); assert!( html.contains("11111111-1111-1111-1111-111111111111"), "{html}" ); assert!(html.contains("file_type"), "{html}"); assert!(!html.contains("hx-vals"), "{html}"); } /// One list, two surfaces, and it is written once. Both spellings of it /// were in Askama before, eleven lines apart in the same file. #[test] fn both_version_surfaces_take_the_same_files() { let single = super::existing_version("22222222-2222-2222-2222-222222222222"); let queue = super::version_queue(); let accept = r#"accept=".zip,.dmg,.exe,.appimage,.deb,.tar.gz,.clap,.vst3""#; assert!(single.contains(accept), "{single}"); assert!(queue.contains(accept), "{queue}"); } /// The version id is in the address, which is where that route puts it. #[test] fn an_existing_version_is_addressed_by_id() { let html = super::existing_version("22222222-2222-2222-2222-222222222222"); assert!( html.contains( r#"data-sends="/api/versions/22222222-2222-2222-2222-222222222222/upload/presign""# ), "{html}" ); } /// Several files, and no destination at all. The queue is uploaded by the /// button that also carries the version number, so a `data-sends` here /// would be a second answer to where the bytes go. #[test] fn the_queue_takes_many_files_and_sends_none_of_them() { let html = super::version_queue(); assert!(html.contains(" multiple"), "{html}"); assert!(!html.contains("data-sends"), "{html}"); assert!(!html.contains("data-awaiting"), "{html}"); } /// The gesture is the host's and needs something to bind to. Without the /// area there is still a working file input, which is the no-script /// rendering rather than a failure, so this is the test that says the /// enhancement has a target. #[test] fn the_drop_area_is_there_for_the_binder() { let html = super::audio("11111111-1111-1111-1111-111111111111"); assert!(html.contains(r#"class="file-upload-area""#), "{html}"); assert!(html.contains("Drop audio file here"), "{html}"); } /// An item whose only interesting field is the audio it holds. The rest is /// what `Item` needs to exist, and none of it reaches this markup. fn audio_item(audio_s3_key: Option) -> crate::types::Item { crate::types::Item { id: "11111111-1111-1111-1111-111111111111".into(), title: "A recording".into(), price: "0".into(), price_cents: 0, item_type: "audio".into(), description: String::new(), thumbnail: String::new(), release_date: String::new(), sales_count: 0, tags: Vec::new(), content: crate::types::ItemContent::Audio { duration: None, duration_seconds: None, cover_url: None, episode_number: None, audio_s3_key, }, cover_image_url: None, is_free: true, can_access: true, enable_license_keys: false, default_max_activations: None, pwyw_enabled: false, pwyw_min_cents: None, publish_at: None, is_public: true, listed: true, bundle_item_count: 0, license_preset: None, custom_license_text: None, ai_tier: crate::db::AiTier::Handmade, ai_disclosure: None, } } /// The details tab, rendered whole. The two call sites are Askama's, so /// one of each is rendered here to say the wiring holds: this one also /// carries the address the binder follows once the bytes have landed, /// which is a host fact and has nowhere else to live. #[test] fn the_audio_call_site_is_wired_and_carries_where_it_goes_after() { use askama::Template as _; let html = crate::templates::ItemDetailsTabTemplate { item: audio_item(None), bundle_items: Vec::new(), bundleable_items: Vec::new(), sections: Vec::new(), } .render() .expect("render the details tab"); assert!( html.contains(r#"data-sends="/api/upload/presign""#), "{html}" ); assert!( html.contains( r#"data-upload-goes="/dashboard/item/11111111-1111-1111-1111-111111111111?tab=files""# ), "{html}" ); } /// The files tab, rendered whole. Two assertions the JS depends on: the /// queue's input keeps the id `item-upload.js` reads it back by, and a /// version with no file gets a field of its own addressed to that version. #[test] fn the_files_call_sites_keep_what_the_host_reads_them_by() { use askama::Template as _; let version = crate::types::Version { id: "22222222-2222-2222-2222-222222222222".into(), number: "1.0".into(), uploaded_date: "2026-08-20".into(), file_count: 0, size: "0 B".into(), downloads: 0, status: "draft".into(), is_current: true, has_file: false, file_name: None, label: None, }; let html = crate::templates::ItemFilesTabTemplate { item: audio_item(None), versions: vec![version], } .render() .expect("render the files tab"); assert!(html.contains(r#"id="version-files""#), "{html}"); assert!( html.contains(r#"id="existing-version-upload-22222222-2222-2222-2222-222222222222""#), "{html}" ); assert!( html.contains( r#"data-sends="/api/versions/22222222-2222-2222-2222-222222222222/upload/presign""# ), "{html}" ); } }