Skip to main content

max / makenotwork

14.3 KB · 364 lines History Blame Raw
1 //! The Askama entry point for a described upload.
2 //!
3 //! Shape 3 of the conversion plan (wiki `mnw-shape-conversion-plans`), and the
4 //! one that had to wait for two vocabulary rulings rather than one. `f7261a5a`
5 //! settled what an upload says about itself: what it takes
6 //! ([`Field::upload`] plus its accept list), how many at a time
7 //! ([`Field::many`]), where the bytes go (the field's action) and that it
8 //! waits ([`Action::awaiting`]). `a81384d4` settled who makes the calls, which
9 //! none of those four axes covered: an MNW upload is presign, then a PUT
10 //! straight to storage from the browser, then confirm, and a renderer posting
11 //! the field at the first of those would be wrong about the response shape and
12 //! about where the bytes end up. [`Action::by_host`] is the answer. The
13 //! renderer emits the address as `data-sends` and performs nothing;
14 //! `static/upload.js` reads it and runs the chain.
15 //!
16 //! # What is described here and what stays the host's
17 //!
18 //! Described: the accept list, the multiplicity, the destination, the payload
19 //! that rides with it, and that the call waits. Every one of those was
20 //! hand-written in a template before this, three times over with three
21 //! spellings of the same accept list.
22 //!
23 //! Host: the drop gesture and the progress bar. Neither is an omission.
24 //! `makeover-layout`'s `FieldKind::File` doc says a drop area, a picker button
25 //! and a typed path are one field and that the gesture "is not described for
26 //! the reason no gesture is", so the dragging stays in `upload.js`. Progress is
27 //! `Awaiting`'s ruling: the mark says the call waits and carries a measured
28 //! size when there is one, and the renderer observes the rest. The server
29 //! renders this markup before a file exists, so there is no size to write down
30 //! and the mark is the unmeasured one.
31 //!
32 //! # Why a function per surface rather than one builder
33 //!
34 //! Three call sites, each with a fixed accept list that is a fact about MNW
35 //! rather than about the template it sits in. A generic entry point would put
36 //! `.zip,.dmg,.exe,.appimage,.deb,.tar.gz,.clap,.vst3` back in Askama, in two
37 //! places, which is the duplication this shape exists to remove. So the lists
38 //! live here once and each surface is named for what it is.
39
40 use makeover_layout::Family;
41 use quasi_router::{Accepted, Action, Field, Node};
42
43 /// Every suffix a version file may carry.
44 ///
45 /// One list, read by both version surfaces. A suffix names no family by ruling
46 /// (`f7261a5a`: a suffix-to-family table rots), so these are `Suffix` and the
47 /// reader gets no media disclosure from them, which is right for a build
48 /// artifact.
49 fn version_suffixes() -> Vec<Accepted> {
50 [
51 ".zip",
52 ".dmg",
53 ".exe",
54 ".appimage",
55 ".deb",
56 ".tar.gz",
57 ".clap",
58 ".vst3",
59 ]
60 .into_iter()
61 .map(Accepted::suffix)
62 .collect()
63 }
64
65 /// The drop area an upload field sits in, and the field itself.
66 ///
67 /// The wrapper is this app's and the group inside it is the renderer's, the
68 /// same division `widgets::carousel` and `quasi::rich_field` make. What the
69 /// wrapper carries is the gesture, which is why it is here: `upload.js` binds
70 /// drag, drop and click-to-open on `.file-upload-area`, and finds the
71 /// destination on the field wrapper the renderer emitted inside it.
72 ///
73 /// `fallback` is what to call the bytes when the browser offers no media type
74 /// for them, which happens for the suffixes S3 has never heard of. It is a host
75 /// fact and not a described one: the presigned URL binds the content type, so
76 /// the PUT has to send back the exact string the presign was asked for, and
77 /// `S3Client::validate_content_type` refuses `application/octet-stream` for an
78 /// item's audio. It rides as an attribute here rather than as a literal in
79 /// Askama so that both halves of the pair stay in one file.
80 fn area(prompt: &str, hint: &str, fallback: &str, field: Field) -> String {
81 use quasi_axum::Serves as _;
82
83 // No shell: a fragment landing inside a document Askama already built.
84 let inner = quasi_webview::Webview::new().fragment(&Node::field(field));
85 format!(
86 "<div class=\"file-upload-area\" data-upload-fallback=\"{}\">\
87 <div class=\"upload-text\">{}</div>\
88 <div class=\"upload-hint\">{}</div>{inner}</div>",
89 crate::helpers::escape_html(fallback),
90 crate::helpers::escape_html(prompt),
91 crate::helpers::escape_html(hint),
92 )
93 }
94
95 /// An item's audio file, replaced whole each time.
96 ///
97 /// One file, any audio media type, landing through `/api/upload/presign`. The
98 /// two values the chain needs beyond the file ride as the action's parameters
99 /// and reach the host as `data-vals`, which is the whole of what the old
100 /// `data-item-id` attribute and the hard-coded `file_type: 'audio'` literal
101 /// were doing.
102 #[must_use]
103 pub fn audio(item_id: &str) -> String {
104 let field = Field::upload("audio", "Audio file", [Accepted::family(Family::Audio)]).writes(
105 Action::post("/api/upload/presign")
106 .with("item_id", item_id)
107 .with("file_type", "audio")
108 .awaiting()
109 .by_host(),
110 );
111
112 area(
113 "Drop audio file here or choose one to upload",
114 "Supports MP3, WAV, FLAC, M4A up to 500 MB",
115 "audio/mpeg",
116 field,
117 )
118 }
119
120 /// One file for a version that already exists.
121 ///
122 /// The same accept list as [`version_queue`] and a destination of its own,
123 /// since the version this belongs to is already in the database and its id is
124 /// in the address rather than in the payload.
125 #[must_use]
126 pub fn existing_version(version_id: &str) -> String {
127 let field = Field::upload("version-file", "Version file", version_suffixes()).writes(
128 Action::post(format!("/api/versions/{version_id}/upload/presign"))
129 .awaiting()
130 .by_host(),
131 );
132
133 area(
134 "Drop file to upload for this version",
135 "ZIP, DMG, EXE, AppImage, DEB, tar.gz, CLAP, VST3",
136 "application/octet-stream",
137 field,
138 )
139 }
140
141 /// The files a new version is being built from, picked before it exists.
142 ///
143 /// Several at once, and deliberately with no destination: nothing can be sent
144 /// until the reader has named the version and labelled each file, so the
145 /// address belongs to the button that does that and not to this field. What is
146 /// described is what it takes and how many, which is the half that was written
147 /// out by hand in the template.
148 #[must_use]
149 pub fn version_queue() -> String {
150 use quasi_axum::Serves as _;
151
152 let field = Field::upload("version-files", "Files", version_suffixes()).many();
153 quasi_webview::Webview::new().fragment(&Node::field(field))
154 }
155
156 #[cfg(test)]
157 mod tests {
158 /// The four axes `f7261a5a` settled, on the surface that has all of them.
159 /// Read together they are the whole point of the conversion: before this
160 /// the accept list was an attribute in Askama, the destination was a
161 /// string literal in `item-upload.js`, and nothing said the call waits.
162 #[test]
163 fn an_audio_upload_says_what_it_takes_where_it_goes_and_that_it_waits() {
164 let html = super::audio("11111111-1111-1111-1111-111111111111");
165
166 assert!(html.contains(r#"type="file""#), "{html}");
167 assert!(html.contains(r#"accept="audio/*""#), "{html}");
168 assert!(!html.contains(" multiple"), "{html}");
169 assert!(
170 html.contains(r#"data-sends="/api/upload/presign""#),
171 "{html}"
172 );
173 assert!(html.contains("data-awaiting="), "{html}");
174 }
175
176 /// The host makes this call, so no transport comes out. A `hx-post` here
177 /// would be htmx sending the file to the signing endpoint, which answers
178 /// JSON and is not where the bytes go.
179 #[test]
180 fn no_transport_is_emitted_for_a_host_made_call() {
181 let html = super::audio("11111111-1111-1111-1111-111111111111");
182
183 assert!(!html.contains("hx-post"), "{html}");
184 assert!(!html.contains("hx-trigger"), "{html}");
185 assert!(!html.contains("href="), "{html}");
186 }
187
188 /// The payload the chain needs, carried by the description rather than by
189 /// a `data-item-id` attribute on an ancestor. This is the assertion that
190 /// would be silent if it were wrong: the upload would presign against no
191 /// item and fail at the far end.
192 #[test]
193 fn the_payload_rides_with_the_destination() {
194 let html = super::audio("11111111-1111-1111-1111-111111111111");
195
196 assert!(html.contains("data-vals="), "{html}");
197 assert!(html.contains("item_id"), "{html}");
198 assert!(
199 html.contains("11111111-1111-1111-1111-111111111111"),
200 "{html}"
201 );
202 assert!(html.contains("file_type"), "{html}");
203 assert!(!html.contains("hx-vals"), "{html}");
204 }
205
206 /// One list, two surfaces, and it is written once. Both spellings of it
207 /// were in Askama before, eleven lines apart in the same file.
208 #[test]
209 fn both_version_surfaces_take_the_same_files() {
210 let single = super::existing_version("22222222-2222-2222-2222-222222222222");
211 let queue = super::version_queue();
212 let accept = r#"accept=".zip,.dmg,.exe,.appimage,.deb,.tar.gz,.clap,.vst3""#;
213
214 assert!(single.contains(accept), "{single}");
215 assert!(queue.contains(accept), "{queue}");
216 }
217
218 /// The version id is in the address, which is where that route puts it.
219 #[test]
220 fn an_existing_version_is_addressed_by_id() {
221 let html = super::existing_version("22222222-2222-2222-2222-222222222222");
222
223 assert!(
224 html.contains(
225 r#"data-sends="/api/versions/22222222-2222-2222-2222-222222222222/upload/presign""#
226 ),
227 "{html}"
228 );
229 }
230
231 /// Several files, and no destination at all. The queue is uploaded by the
232 /// button that also carries the version number, so a `data-sends` here
233 /// would be a second answer to where the bytes go.
234 #[test]
235 fn the_queue_takes_many_files_and_sends_none_of_them() {
236 let html = super::version_queue();
237
238 assert!(html.contains(" multiple"), "{html}");
239 assert!(!html.contains("data-sends"), "{html}");
240 assert!(!html.contains("data-awaiting"), "{html}");
241 }
242
243 /// The gesture is the host's and needs something to bind to. Without the
244 /// area there is still a working file input, which is the no-script
245 /// rendering rather than a failure, so this is the test that says the
246 /// enhancement has a target.
247 #[test]
248 fn the_drop_area_is_there_for_the_binder() {
249 let html = super::audio("11111111-1111-1111-1111-111111111111");
250
251 assert!(html.contains(r#"class="file-upload-area""#), "{html}");
252 assert!(html.contains("Drop audio file here"), "{html}");
253 }
254
255 /// An item whose only interesting field is the audio it holds. The rest is
256 /// what `Item` needs to exist, and none of it reaches this markup.
257 fn audio_item(audio_s3_key: Option<String>) -> crate::types::Item {
258 crate::types::Item {
259 id: "11111111-1111-1111-1111-111111111111".into(),
260 title: "A recording".into(),
261 price: "0".into(),
262 price_cents: 0,
263 item_type: "audio".into(),
264 description: String::new(),
265 thumbnail: String::new(),
266 release_date: String::new(),
267 sales_count: 0,
268 tags: Vec::new(),
269 content: crate::types::ItemContent::Audio {
270 duration: None,
271 duration_seconds: None,
272 cover_url: None,
273 episode_number: None,
274 audio_s3_key,
275 },
276 cover_image_url: None,
277 is_free: true,
278 can_access: true,
279 enable_license_keys: false,
280 default_max_activations: None,
281 pwyw_enabled: false,
282 pwyw_min_cents: None,
283 publish_at: None,
284 is_public: true,
285 listed: true,
286 bundle_item_count: 0,
287 license_preset: None,
288 custom_license_text: None,
289 ai_tier: crate::db::AiTier::Handmade,
290 ai_disclosure: None,
291 }
292 }
293
294 /// The details tab, rendered whole. The two call sites are Askama's, so
295 /// one of each is rendered here to say the wiring holds: this one also
296 /// carries the address the binder follows once the bytes have landed,
297 /// which is a host fact and has nowhere else to live.
298 #[test]
299 fn the_audio_call_site_is_wired_and_carries_where_it_goes_after() {
300 use askama::Template as _;
301
302 let html = crate::templates::ItemDetailsTabTemplate {
303 item: audio_item(None),
304 bundle_items: Vec::new(),
305 bundleable_items: Vec::new(),
306 sections: Vec::new(),
307 }
308 .render()
309 .expect("render the details tab");
310
311 assert!(
312 html.contains(r#"data-sends="/api/upload/presign""#),
313 "{html}"
314 );
315 assert!(
316 html.contains(
317 r#"data-upload-goes="/dashboard/item/11111111-1111-1111-1111-111111111111?tab=files""#
318 ),
319 "{html}"
320 );
321 }
322
323 /// The files tab, rendered whole. Two assertions the JS depends on: the
324 /// queue's input keeps the id `item-upload.js` reads it back by, and a
325 /// version with no file gets a field of its own addressed to that version.
326 #[test]
327 fn the_files_call_sites_keep_what_the_host_reads_them_by() {
328 use askama::Template as _;
329
330 let version = crate::types::Version {
331 id: "22222222-2222-2222-2222-222222222222".into(),
332 number: "1.0".into(),
333 uploaded_date: "2026-08-20".into(),
334 file_count: 0,
335 size: "0 B".into(),
336 downloads: 0,
337 status: "draft".into(),
338 is_current: true,
339 has_file: false,
340 file_name: None,
341 label: None,
342 };
343
344 let html = crate::templates::ItemVersionUploadTemplate {
345 item: audio_item(None),
346 versions: vec![version],
347 }
348 .render()
349 .expect("render the uploader");
350
351 assert!(html.contains(r#"id="version-files""#), "{html}");
352 assert!(
353 html.contains(r#"id="existing-version-upload-22222222-2222-2222-2222-222222222222""#),
354 "{html}"
355 );
356 assert!(
357 html.contains(
358 r#"data-sends="/api/versions/22222222-2222-2222-2222-222222222222/upload/presign""#
359 ),
360 "{html}"
361 );
362 }
363 }
364