Skip to main content

max / makenotwork

16.7 KB · 487 lines History Blame Raw
1 //! Item embed handlers: buy button, product card, audio player.
2
3 use axum::{
4 extract::{Path, Query, State},
5 http::{header, HeaderValue},
6 response::{Html, IntoResponse, Response},
7 };
8 use serde::Deserialize;
9
10 use crate::{
11 db::{self, ItemId},
12 error::{AppError, Result},
13 AppState,
14 };
15
16 /// Shared context fetched for all item embeds.
17 struct ItemEmbedContext {
18 title: String,
19 price_display: String,
20 button_text: String,
21 purchase_url: String,
22 cover_image_url: Option<String>,
23 creator_username: String,
24 creator_display_name: String,
25 description_excerpt: String,
26 #[allow(dead_code)]
27 item_type_label: String,
28 /// Whether the item has audio (for player embed eligibility).
29 has_audio: bool,
30 }
31
32 async fn fetch_item_embed_context(state: &AppState, item_id: ItemId) -> Result<ItemEmbedContext> {
33 let item = db::items::get_item_by_id(&state.db, item_id)
34 .await?
35 .ok_or(AppError::NotFound)?;
36
37 if !item.is_public {
38 return Err(AppError::NotFound);
39 }
40
41 let project = db::projects::get_project_by_id(&state.db, item.project_id)
42 .await?
43 .ok_or(AppError::NotFound)?;
44
45 let user = db::users::get_user_by_id(&state.db, project.user_id)
46 .await?
47 .ok_or(AppError::NotFound)?;
48
49 if user.is_suspended() || user.is_deactivated() {
50 return Err(AppError::NotFound);
51 }
52
53 let (price_display, button_text) = if item.price_cents == 0 {
54 ("Free".to_string(), "Get".to_string())
55 } else if item.pwyw_enabled {
56 (format!("${:.2}+", item.price_cents as f64 / 100.0), "Buy".to_string())
57 } else {
58 (format!("${:.2}", item.price_cents as f64 / 100.0), "Buy".to_string())
59 };
60
61 let purchase_url = format!("{}/buy/{}", state.config.host_url, item_id);
62
63 let description_excerpt = item.description.as_deref()
64 .unwrap_or("")
65 .chars()
66 .take(150)
67 .collect::<String>();
68
69 Ok(ItemEmbedContext {
70 title: item.title.clone(),
71 price_display,
72 button_text,
73 purchase_url,
74 cover_image_url: item.cover_image_url.clone(),
75 creator_username: user.username.to_string(),
76 creator_display_name: user.display_name.unwrap_or_else(|| user.username.to_string()),
77 description_excerpt,
78 item_type_label: item.item_type.label().to_string(),
79 has_audio: item.audio_s3_key.is_some(),
80 })
81 }
82
83 /// Set embed-specific headers (allow framing, cache 5 min).
84 pub(super) fn set_embed_headers(response: &mut Response) {
85 let headers = response.headers_mut();
86 headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("ALLOWALL"));
87 headers.insert(
88 header::HeaderName::from_static("content-security-policy"),
89 HeaderValue::from_static("frame-ancestors *"),
90 );
91 headers.insert(
92 header::CACHE_CONTROL,
93 HeaderValue::from_static("public, max-age=300"),
94 );
95 }
96
97 // ─── Buy Button ─────────────────────────────────────────────────────────────
98
99 #[tracing::instrument(skip_all, name = "embed::item_button")]
100 /// GET /embed/i/{item_id}/button
101 pub(super) async fn item_button(
102 State(state): State<AppState>,
103 Path(item_id): Path<ItemId>,
104 ) -> Result<Response> {
105 let ctx = fetch_item_embed_context(&state, item_id).await?;
106
107 let cover_html = ctx.cover_image_url.as_ref()
108 .map(|url| format!(r#"<img class="cover" src="{}" alt="">"#, html_escape(url)))
109 .unwrap_or_default();
110
111 let html = format!(
112 r#"<!doctype html>
113 <html lang="en">
114 <head>
115 <meta charset="UTF-8">
116 <meta name="viewport" content="width=device-width, initial-scale=1.0">
117 <title>{title} — Makenot.work</title>
118 <style>
119 * {{ margin: 0; padding: 0; box-sizing: border-box; }}
120 body {{
121 font-family: Lato, -apple-system, sans-serif;
122 background: #ede8e1; color: #3d3530;
123 display: flex; align-items: center;
124 height: 100vh; padding: 8px 12px;
125 }}
126 .embed-button {{ display: flex; align-items: center; gap: 10px; width: 100%; }}
127 .cover {{ width: 40px; height: 40px; border-radius: 4px; object-fit: cover; }}
128 .info {{ flex: 1; min-width: 0; }}
129 .title {{ font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }}
130 .price {{ font-size: 12px; opacity: 0.7; font-family: 'IBM Plex Mono', monospace; }}
131 .buy-btn {{
132 background: #6c5ce7; color: #fff; border: none; border-radius: 4px;
133 padding: 8px 14px; font-size: 12px; font-weight: 600;
134 cursor: pointer; text-decoration: none; white-space: nowrap;
135 }}
136 .buy-btn:hover {{ background: #5a4bd6; }}
137 </style>
138 </head>
139 <body>
140 <div class="embed-button">
141 {cover}
142 <div class="info">
143 <div class="title">{title}</div>
144 <div class="price">{price}</div>
145 </div>
146 <a class="buy-btn" href="{url}" target="_blank" rel="noopener">{btn}</a>
147 </div>
148 </body>
149 </html>"#,
150 title = html_escape(&ctx.title),
151 cover = cover_html,
152 price = ctx.price_display,
153 url = ctx.purchase_url,
154 btn = ctx.button_text,
155 );
156
157 let mut response = Html(html).into_response();
158 set_embed_headers(&mut response);
159 Ok(response)
160 }
161
162 // ─── Product Card ───────────────────────────────────────────────────────────
163
164 #[derive(Debug, Deserialize)]
165 pub(super) struct CardQuery {
166 pub layout: Option<String>,
167 }
168
169 #[tracing::instrument(skip_all, name = "embed::item_card")]
170 /// GET /embed/i/{item_id}/card
171 pub(super) async fn item_card(
172 State(state): State<AppState>,
173 Path(item_id): Path<ItemId>,
174 Query(query): Query<CardQuery>,
175 ) -> Result<Response> {
176 let ctx = fetch_item_embed_context(&state, item_id).await?;
177 let layout = query.layout.as_deref().unwrap_or("vertical");
178 let is_horizontal = layout == "horizontal";
179
180 let profile_url = format!("{}/u/{}", state.config.host_url, ctx.creator_username);
181
182 let html = format!(
183 r#"<!doctype html>
184 <html lang="en">
185 <head>
186 <meta charset="UTF-8">
187 <meta name="viewport" content="width=device-width, initial-scale=1.0">
188 <title>{title} — Makenot.work</title>
189 <style>
190 * {{ margin: 0; padding: 0; box-sizing: border-box; }}
191 body {{
192 font-family: Lato, -apple-system, sans-serif;
193 background: #ede8e1; color: #3d3530;
194 display: flex; align-items: center; justify-content: center;
195 min-height: 100vh; padding: 12px;
196 }}
197 .card {{
198 background: #fff; border-radius: 8px; overflow: hidden;
199 border: 1px solid rgba(61,53,48,0.1);
200 display: flex; flex-direction: {direction}; width: 100%;
201 max-width: {max_width};
202 }}
203 .cover {{
204 {cover_style}
205 background: #f5f0eb; flex-shrink: 0;
206 }}
207 .cover img {{ width: 100%; height: 100%; object-fit: cover; }}
208 .body {{ padding: 14px; display: flex; flex-direction: column; flex: 1; min-width: 0; }}
209 .title {{ font-family: 'Young Serif', Georgia, serif; font-size: 15px; font-weight: bold; margin-bottom: 4px; }}
210 .creator {{ font-family: 'IBM Plex Mono', monospace; font-size: 11px; opacity: 0.6; margin-bottom: 8px; }}
211 .creator a {{ color: inherit; text-decoration: none; }}
212 .creator a:hover {{ text-decoration: underline; }}
213 .desc {{ font-size: 12px; opacity: 0.8; line-height: 1.4; margin-bottom: 12px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }}
214 .footer {{ display: flex; align-items: center; justify-content: space-between; margin-top: auto; }}
215 .price {{ font-family: 'IBM Plex Mono', monospace; font-size: 14px; }}
216 .buy-btn {{
217 background: #6c5ce7; color: #fff; border: none; border-radius: 4px;
218 padding: 8px 14px; font-size: 12px; font-weight: 600;
219 cursor: pointer; text-decoration: none; white-space: nowrap;
220 }}
221 .buy-btn:hover {{ background: #5a4bd6; }}
222 </style>
223 </head>
224 <body>
225 <div class="card">
226 <div class="cover">{cover_content}</div>
227 <div class="body">
228 <div class="title">{title}</div>
229 <div class="creator">by <a href="{profile_url}" target="_blank" rel="noopener">{creator}</a></div>
230 {desc_html}
231 <div class="footer">
232 <span class="price">{price}</span>
233 <a class="buy-btn" href="{purchase_url}" target="_blank" rel="noopener">{btn}</a>
234 </div>
235 </div>
236 </div>
237 </body>
238 </html>"#,
239 title = html_escape(&ctx.title),
240 direction = if is_horizontal { "row" } else { "column" },
241 max_width = if is_horizontal { "600px" } else { "350px" },
242 cover_style = if is_horizontal {
243 "width: 150px; height: 200px;"
244 } else {
245 "width: 100%; height: 200px;"
246 },
247 cover_content = ctx.cover_image_url.as_ref()
248 .map(|url| format!(r#"<img src="{}" alt="">"#, html_escape(url)))
249 .unwrap_or_default(),
250 profile_url = profile_url,
251 creator = html_escape(&ctx.creator_display_name),
252 desc_html = if ctx.description_excerpt.is_empty() {
253 String::new()
254 } else {
255 format!(r#"<div class="desc">{}</div>"#, html_escape(&ctx.description_excerpt))
256 },
257 price = ctx.price_display,
258 purchase_url = ctx.purchase_url,
259 btn = ctx.button_text,
260 );
261
262 let mut response = Html(html).into_response();
263 set_embed_headers(&mut response);
264 Ok(response)
265 }
266
267 // ─── Audio Player ───────────────────────────────────────────────────────────
268
269 #[tracing::instrument(skip_all, name = "embed::item_player")]
270 /// GET /embed/i/{item_id}/player
271 ///
272 /// Audio preview player embed. Returns 404 for non-audio items.
273 #[tracing::instrument(skip_all, name = "embed::item_player")]
274 pub(super) async fn item_player(
275 State(state): State<AppState>,
276 Path(item_id): Path<ItemId>,
277 ) -> Result<Response> {
278 let ctx = fetch_item_embed_context(&state, item_id).await?;
279
280 if !ctx.has_audio {
281 return Err(AppError::NotFound);
282 }
283
284 // Preview URL would be /embed/i/{item_id}/preview.mp3 once ffmpeg generation is built.
285 // For now, link to the item page — the player embed is a stub until preview generation ships.
286 let preview_url = format!("{}/api/stream/{}", state.config.host_url, item_id);
287
288 let cover_html = ctx.cover_image_url.as_ref()
289 .map(|url| format!(r#"<img class="cover" src="{}" alt="">"#, html_escape(url)))
290 .unwrap_or(r#"<div class="cover placeholder"></div>"#.to_string());
291
292 let html = format!(
293 r#"<!doctype html>
294 <html lang="en">
295 <head>
296 <meta charset="UTF-8">
297 <meta name="viewport" content="width=device-width, initial-scale=1.0">
298 <title>{title} — Makenot.work</title>
299 <style>
300 * {{ margin: 0; padding: 0; box-sizing: border-box; }}
301 body {{
302 font-family: Lato, -apple-system, sans-serif;
303 background: #ede8e1; color: #3d3530;
304 display: flex; align-items: center;
305 height: 100vh; padding: 10px 12px;
306 }}
307 .player {{ display: flex; align-items: center; gap: 12px; width: 100%; }}
308 .cover {{ width: 80px; height: 80px; border-radius: 6px; object-fit: cover; flex-shrink: 0; }}
309 .placeholder {{ background: #f5f0eb; }}
310 .right {{ flex: 1; min-width: 0; }}
311 .top-row {{ display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 4px; }}
312 .title {{ font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; }}
313 .price {{ font-size: 12px; font-family: 'IBM Plex Mono', monospace; margin-left: 8px; white-space: nowrap; }}
314 .creator {{ font-size: 11px; opacity: 0.6; margin-bottom: 8px; }}
315 .controls {{ display: flex; align-items: center; gap: 8px; }}
316 .play-btn {{
317 width: 32px; height: 32px; border-radius: 50%;
318 background: #6c5ce7; color: #fff; border: none;
319 font-size: 14px; cursor: pointer; display: flex;
320 align-items: center; justify-content: center; flex-shrink: 0;
321 }}
322 .play-btn:hover {{ background: #5a4bd6; }}
323 .progress-bar {{
324 flex: 1; height: 4px; background: rgba(61,53,48,0.15);
325 border-radius: 2px; cursor: pointer; position: relative;
326 }}
327 .progress-fill {{ height: 100%; background: #6c5ce7; border-radius: 2px; width: 0%; transition: width 0.1s; }}
328 .time {{ font-size: 10px; font-family: 'IBM Plex Mono', monospace; opacity: 0.6; white-space: nowrap; }}
329 .bottom-row {{ display: flex; justify-content: space-between; align-items: center; margin-top: 8px; }}
330 .preview-label {{ font-size: 10px; opacity: 0.5; }}
331 .buy-btn {{
332 background: #6c5ce7; color: #fff; border: none; border-radius: 4px;
333 padding: 6px 12px; font-size: 11px; font-weight: 600;
334 cursor: pointer; text-decoration: none; white-space: nowrap;
335 }}
336 .buy-btn:hover {{ background: #5a4bd6; }}
337 </style>
338 </head>
339 <body>
340 <div class="player">
341 {cover}
342 <div class="right">
343 <div class="top-row">
344 <div class="title">{title}</div>
345 <div class="price">{price}</div>
346 </div>
347 <div class="creator">by {creator}</div>
348 <div class="controls">
349 <button class="play-btn" id="play" onclick="togglePlay()">&#9654;</button>
350 <div class="progress-bar" id="progress-bar" onclick="seek(event)">
351 <div class="progress-fill" id="progress"></div>
352 </div>
353 <span class="time" id="time">0:00</span>
354 </div>
355 <div class="bottom-row">
356 <span class="preview-label">Preview</span>
357 <a class="buy-btn" href="{purchase_url}" target="_blank" rel="noopener">{btn}</a>
358 </div>
359 </div>
360 </div>
361 <script>
362 const audio = new Audio();
363 let loaded = false;
364 function togglePlay() {{
365 if (!loaded) {{ audio.src = '{preview_url}'; loaded = true; }}
366 if (audio.paused) {{ audio.play(); document.getElementById('play').innerHTML = '&#9646;&#9646;'; }}
367 else {{ audio.pause(); document.getElementById('play').innerHTML = '&#9654;'; }}
368 }}
369 audio.ontimeupdate = () => {{
370 const pct = (audio.currentTime / audio.duration) * 100;
371 document.getElementById('progress').style.width = pct + '%';
372 const m = Math.floor(audio.currentTime / 60);
373 const s = Math.floor(audio.currentTime % 60);
374 document.getElementById('time').textContent = m + ':' + (s < 10 ? '0' : '') + s;
375 }};
376 audio.onended = () => {{ document.getElementById('play').innerHTML = '&#9654;'; }};
377 function seek(e) {{
378 if (!audio.duration) return;
379 const rect = e.currentTarget.getBoundingClientRect();
380 audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration;
381 }}
382 </script>
383 </body>
384 </html>"#,
385 title = html_escape(&ctx.title),
386 cover = cover_html,
387 price = ctx.price_display,
388 creator = html_escape(&ctx.creator_display_name),
389 purchase_url = ctx.purchase_url,
390 btn = ctx.button_text,
391 preview_url = preview_url,
392 );
393
394 let mut response = Html(html).into_response();
395 set_embed_headers(&mut response);
396 Ok(response)
397 }
398
399 // ─── Utility ────────────────────────────────────────────────────────────────
400
401 fn html_escape(s: &str) -> String {
402 s.replace('&', "&amp;")
403 .replace('<', "&lt;")
404 .replace('>', "&gt;")
405 .replace('"', "&quot;")
406 }
407
408 #[cfg(test)]
409 mod tests {
410 use super::*;
411
412 #[test]
413 fn html_escape_plain_text() {
414 assert_eq!(html_escape("hello world"), "hello world");
415 }
416
417 #[test]
418 fn html_escape_empty() {
419 assert_eq!(html_escape(""), "");
420 }
421
422 #[test]
423 fn html_escape_ampersand() {
424 assert_eq!(html_escape("a & b"), "a &amp; b");
425 }
426
427 #[test]
428 fn html_escape_angle_brackets() {
429 assert_eq!(html_escape("<script>"), "&lt;script&gt;");
430 }
431
432 #[test]
433 fn html_escape_quotes() {
434 assert_eq!(html_escape(r#"say "hi""#), "say &quot;hi&quot;");
435 }
436
437 #[test]
438 fn html_escape_all_special_chars() {
439 assert_eq!(html_escape(r#"<a href="x&y">"#), "&lt;a href=&quot;x&amp;y&quot;&gt;");
440 }
441
442 #[test]
443 fn html_escape_already_escaped() {
444 // Double-escaping is expected: & in &amp; becomes &amp;amp;
445 assert_eq!(html_escape("&amp;"), "&amp;amp;");
446 }
447
448 #[test]
449 fn price_display_free() {
450 let (price, btn) = if 0 == 0 {
451 ("Free".to_string(), "Get".to_string())
452 } else {
453 unreachable!()
454 };
455 assert_eq!(price, "Free");
456 assert_eq!(btn, "Get");
457 }
458
459 #[test]
460 fn price_display_fixed() {
461 let cents = 1999;
462 let price = format!("${:.2}", cents as f64 / 100.0);
463 assert_eq!(price, "$19.99");
464 }
465
466 #[test]
467 fn price_display_pwyw() {
468 let cents = 500;
469 let price = format!("${:.2}+", cents as f64 / 100.0);
470 assert_eq!(price, "$5.00+");
471 }
472
473 #[test]
474 fn description_excerpt_truncation() {
475 let long = "a".repeat(200);
476 let excerpt: String = long.chars().take(150).collect();
477 assert_eq!(excerpt.len(), 150);
478 }
479
480 #[test]
481 fn description_excerpt_short() {
482 let short = "Hello";
483 let excerpt: String = short.chars().take(150).collect();
484 assert_eq!(excerpt, "Hello");
485 }
486 }
487