Skip to main content

max / makenotwork

7.3 KB · 229 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::{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 caching. Framing + CSP for `/embed/*` are owned by the
84 /// global `security_headers_middleware` (which runs on the way out and
85 /// overwrites any X-Frame-Options/CSP set here), so this only sets Cache-Control
86 /// — the one header the middleware does not touch.
87 pub(super) fn set_embed_headers(response: &mut Response) {
88 let headers = response.headers_mut();
89 headers.insert(
90 header::CACHE_CONTROL,
91 HeaderValue::from_static("public, max-age=300"),
92 );
93 }
94
95 // ─── Buy Button ─────────────────────────────────────────────────────────────
96
97 #[tracing::instrument(skip_all, name = "embed::item_button")]
98 /// GET /embed/i/{item_id}/button
99 pub(super) async fn item_button(
100 State(state): State<AppState>,
101 Path(item_id): Path<ItemId>,
102 ) -> Result<Response> {
103 let ctx = fetch_item_embed_context(&state, item_id).await?;
104
105 let mut response = crate::templates::EmbedItemButtonTemplate {
106 title: ctx.title,
107 price_display: ctx.price_display,
108 purchase_url: ctx.purchase_url,
109 button_text: ctx.button_text,
110 cover_image_url: ctx.cover_image_url,
111 }
112 .into_response();
113 set_embed_headers(&mut response);
114 Ok(response)
115 }
116
117 // ─── Product Card ───────────────────────────────────────────────────────────
118
119 #[derive(Debug, Deserialize)]
120 pub(super) struct CardQuery {
121 pub layout: Option<String>,
122 }
123
124 #[tracing::instrument(skip_all, name = "embed::item_card")]
125 /// GET /embed/i/{item_id}/card
126 pub(super) async fn item_card(
127 State(state): State<AppState>,
128 Path(item_id): Path<ItemId>,
129 Query(query): Query<CardQuery>,
130 ) -> Result<Response> {
131 let ctx = fetch_item_embed_context(&state, item_id).await?;
132 let layout = query.layout.as_deref().unwrap_or("vertical");
133 let is_horizontal = layout == "horizontal";
134
135 let profile_url = format!("{}/u/{}", state.config.host_url, ctx.creator_username);
136
137 let mut response = crate::templates::EmbedItemCardTemplate {
138 title: ctx.title,
139 price_display: ctx.price_display,
140 purchase_url: ctx.purchase_url,
141 button_text: ctx.button_text,
142 cover_image_url: ctx.cover_image_url,
143 creator_display_name: ctx.creator_display_name,
144 profile_url,
145 description_excerpt: ctx.description_excerpt,
146 is_horizontal,
147 }
148 .into_response();
149 set_embed_headers(&mut response);
150 Ok(response)
151 }
152
153 // ─── Audio Player ───────────────────────────────────────────────────────────
154
155 #[tracing::instrument(skip_all, name = "embed::item_player")]
156 /// GET /embed/i/{item_id}/player
157 ///
158 /// Audio preview player embed. Returns 404 for non-audio items.
159 #[tracing::instrument(skip_all, name = "embed::item_player")]
160 pub(super) async fn item_player(
161 State(state): State<AppState>,
162 Path(item_id): Path<ItemId>,
163 ) -> Result<Response> {
164 let ctx = fetch_item_embed_context(&state, item_id).await?;
165
166 if !ctx.has_audio {
167 return Err(AppError::NotFound);
168 }
169
170 // Preview URL would be /embed/i/{item_id}/preview.mp3 once ffmpeg generation is built.
171 // For now, link to the item page — the player embed is a stub until preview generation ships.
172 let preview_url = format!("{}/api/stream/{}", state.config.host_url, item_id);
173
174 let mut response = crate::templates::EmbedItemPlayerTemplate {
175 title: ctx.title,
176 price_display: ctx.price_display,
177 purchase_url: ctx.purchase_url,
178 button_text: ctx.button_text,
179 creator_display_name: ctx.creator_display_name,
180 cover_image_url: ctx.cover_image_url,
181 preview_url,
182 }
183 .into_response();
184 set_embed_headers(&mut response);
185 Ok(response)
186 }
187
188 #[cfg(test)]
189 mod tests {
190 #[test]
191 fn price_display_free() {
192 let (price, btn) = if 0 == 0 {
193 ("Free".to_string(), "Get".to_string())
194 } else {
195 unreachable!()
196 };
197 assert_eq!(price, "Free");
198 assert_eq!(btn, "Get");
199 }
200
201 #[test]
202 fn price_display_fixed() {
203 let cents = 1999;
204 let price = format!("${:.2}", cents as f64 / 100.0);
205 assert_eq!(price, "$19.99");
206 }
207
208 #[test]
209 fn price_display_pwyw() {
210 let cents = 500;
211 let price = format!("${:.2}+", cents as f64 / 100.0);
212 assert_eq!(price, "$5.00+");
213 }
214
215 #[test]
216 fn description_excerpt_truncation() {
217 let long = "a".repeat(200);
218 let excerpt: String = long.chars().take(150).collect();
219 assert_eq!(excerpt.len(), 150);
220 }
221
222 #[test]
223 fn description_excerpt_short() {
224 let short = "Hello";
225 let excerpt: String = short.chars().take(150).collect();
226 assert_eq!(excerpt, "Hello");
227 }
228 }
229