Skip to main content

max / makenotwork

18.5 KB · 538 lines History Blame Raw
1 //! Public item detail page handler.
2
3 use axum::{
4 extract::{Path, State},
5 response::{IntoResponse, Response},
6 };
7 use tower_sessions::Session;
8
9 use crate::{
10 auth::{MaybeUserVerified, SessionUser},
11 db::{self, ContentData, ItemId, ItemType},
12 error::{AppError, Result},
13 helpers::{fetch_discussion_info, get_csrf_token, get_initials},
14 pricing,
15 templates::*,
16 types::*,
17 AppState,
18 };
19
20 /// Render a public item detail page (text reader, audio player, or download).
21 #[tracing::instrument(skip_all, name = "content::item_page")]
22 pub(in crate::routes::pages::public) async fn item_page(
23 State(state): State<AppState>,
24 session: Session,
25 MaybeUserVerified(maybe_user): MaybeUserVerified,
26 Path(item_id): Path<String>,
27 ) -> Result<Response> {
28 let csrf_token = get_csrf_token(&session).await;
29 let id: ItemId = item_id.parse().map_err(|_| AppError::NotFound)?;
30 let db_item = db::items::get_item_by_id(&state.db, id)
31 .await?
32 .ok_or(AppError::NotFound)?;
33 let db_project = db::projects::get_project_by_id(&state.db, db_item.project_id)
34 .await?
35 .ok_or(AppError::NotFound)?;
36 let db_user = db::users::get_user_by_id(&state.db, db_project.user_id)
37 .await?
38 .ok_or(AppError::NotFound)?;
39 if db_user.is_sandbox {
40 return Err(AppError::NotFound);
41 }
42 // View tracking moved to /l/{id} — consumption is the meaningful signal,
43 // not store-page traffic.
44 render_item_page(&state, &db_item, &db_project, &db_user, csrf_token, maybe_user).await
45 }
46
47 /// Shared item page renderer, used by both named routes and custom domain fallback.
48 pub(crate) async fn render_item_page(
49 state: &AppState,
50 db_item: &db::DbItem,
51 db_project: &db::DbProject,
52 db_user: &db::DbUser,
53 csrf_token: Option<String>,
54 maybe_user: Option<SessionUser>,
55 ) -> Result<Response> {
56 // Visibility check: unpublished items only visible to owner
57 let is_owner = maybe_user
58 .as_ref()
59 .map(|u| u.id == db_project.user_id)
60 .unwrap_or(false);
61 if !db_item.is_public && !is_owner {
62 return Err(AppError::NotFound);
63 }
64 if db_item.deleted_at.is_some() && !is_owner {
65 return Err(AppError::NotFound);
66 }
67
68 let cdn_base = state.config.cdn_base_url.as_deref().unwrap_or("https://cdn.makenot.work");
69 // Store page never renders the full article body — that lives on /l/{id}.
70 // Compute a short plain-text excerpt from the raw markdown for the deck.
71 let (excerpt, reading_time) = match db_item.content() {
72 ContentData::Text {
73 body,
74 reading_time_minutes,
75 ..
76 } => (
77 body.as_ref().map(|b| make_excerpt(b, 280)),
78 reading_time_minutes.map(|m| format!("{} min read", m)),
79 ),
80 _ => (None, None),
81 };
82
83 let item_pricing = pricing::for_item(db_item);
84 let in_library = if let Some(ref user) = maybe_user {
85 db::transactions::has_purchased_item(&state.db, user.id, db_item.id).await?
86 } else {
87 false
88 };
89 let item_sub = if let Some(ref user) = maybe_user {
90 db::subscriptions::SubscriptionGate::check(&state.db, user.id, db::subscriptions::SubscriptionScope::Item(db_item.id)).await?
91 } else {
92 None
93 };
94 let ctx = pricing::AccessContext {
95 is_creator: is_owner,
96 has_purchased: in_library,
97 subscription: item_sub,
98 };
99 let mut has_access = item_pricing.can_access(&ctx);
100 let is_free = item_pricing.is_free();
101
102 // Bundle access: user may have purchased a bundle containing this item
103 if !has_access
104 && let Some(ref user) = maybe_user
105 && db::bundles::has_access_via_bundle(&state.db, user.id, db_item.id).await?
106 {
107 has_access = true;
108 }
109
110 // For unlisted items, load the bundles that contain them (for "Available in" display)
111 let containing_bundle_ids = if !db_item.listed {
112 db::bundles::get_bundles_containing_item(&state.db, db_item.id).await?
113 } else {
114 vec![]
115 };
116 let containing_bundles: Vec<db::DbItem> = {
117 let mut bundles = Vec::new();
118 for bid in &containing_bundle_ids {
119 if let Some(b) = db::items::get_item_by_id(&state.db, *bid).await?
120 && b.is_public
121 {
122 bundles.push(b);
123 }
124 }
125 bundles
126 };
127
128 // For bundle-type items, load the child items
129 let bundle_child_items = if db_item.item_type == ItemType::Bundle {
130 db::bundles::get_bundle_items(&state.db, db_item.id).await?
131 } else {
132 vec![]
133 };
134
135 let item_tags = db::tags::get_tags_for_item(&state.db, db_item.id).await?;
136 let item = Item::from_db_detail(
137 db_item,
138 &item_tags,
139 None,
140 reading_time.clone(),
141 is_free,
142 has_access,
143 );
144
145 if db_item.item_type == ItemType::Text {
146 let avatar_initials =
147 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
148 let project_slug_str = db_project.slug.to_string();
149 let (discussion_url, discussion_count) =
150 fetch_discussion_info(state, db_item.mt_thread_id, &project_slug_str, "items").await;
151 return Ok(TextReaderTemplate {
152 csrf_token: csrf_token.clone(),
153 session_user: maybe_user,
154 item,
155 creator_username: db_user.username.to_string(),
156 creator_display_name: db_user.display_name.clone(),
157 creator_avatar_initials: avatar_initials,
158 project_title: db_project.title.clone(),
159 project_slug: project_slug_str,
160 is_free,
161 in_library,
162 has_access,
163 reading_time,
164 excerpt,
165 host_url: state.config.host_url.clone(),
166 discussion_url,
167 discussion_count,
168 }
169 .into_response());
170 }
171
172 if db_item.item_type == ItemType::Audio {
173 let avatar_initials =
174 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
175 let project_slug_str = db_project.slug.to_string();
176 let (discussion_url, discussion_count) =
177 fetch_discussion_info(state, db_item.mt_thread_id, &project_slug_str, "items").await;
178 return Ok(AudioPlayerTemplate {
179 csrf_token: csrf_token.clone(),
180 session_user: maybe_user,
181 item,
182 creator_username: db_user.username.to_string(),
183 creator_display_name: db_user.display_name.clone(),
184 creator_avatar_initials: avatar_initials,
185 project_title: Some(db_project.title.clone()),
186 project_slug: project_slug_str,
187 is_free,
188 in_library,
189 has_access,
190 host_url: state.config.host_url.clone(),
191 discussion_url,
192 discussion_count,
193 }
194 .into_response());
195 }
196
197 if db_item.item_type == ItemType::Video {
198 let avatar_initials =
199 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
200 let project_slug_str = db_project.slug.to_string();
201 let (discussion_url, discussion_count) =
202 fetch_discussion_info(state, db_item.mt_thread_id, &project_slug_str, "items").await;
203 return Ok(VideoPlayerTemplate {
204 csrf_token: csrf_token.clone(),
205 session_user: maybe_user,
206 item,
207 creator_username: db_user.username.to_string(),
208 creator_display_name: db_user.display_name.clone(),
209 creator_avatar_initials: avatar_initials,
210 project_title: Some(db_project.title.clone()),
211 project_slug: project_slug_str,
212 is_free,
213 in_library,
214 has_access,
215 host_url: state.config.host_url.clone(),
216 discussion_url,
217 discussion_count,
218 }
219 .into_response());
220 }
221
222 let project_slug_str = db_project.slug.to_string();
223 let (discussion_url, discussion_count) =
224 fetch_discussion_info(state, db_item.mt_thread_id, &project_slug_str, "items").await;
225
226 // Convert bundle child items to view models
227 let bundle_item_views: Vec<Item> = bundle_child_items
228 .iter()
229 .map(|child| {
230 let child_tags = Vec::new(); // Tags not needed for bundle child list display
231 Item::from_db_list(child, &child_tags, child.price_cents == 0, false)
232 })
233 .collect();
234
235 // Convert containing bundles to view models
236 let containing_bundle_views: Vec<Item> = containing_bundles
237 .iter()
238 .map(|b| {
239 let b_tags = Vec::new();
240 Item::from_db_list(b, &b_tags, b.price_cents == 0, false)
241 })
242 .collect();
243
244 let db_sections = db::item_sections::list_by_item(&state.db, db_item.id).await?;
245 let sections: Vec<ItemSection> = db_sections.iter().map(|s| ItemSection::from_db(s, db_project.user_id, cdn_base)).collect();
246
247 let is_wishlisted = if let Some(ref user) = maybe_user {
248 db::wishlists::is_wishlisted(&state.db, user.id, db_item.id).await.unwrap_or(false)
249 } else {
250 false
251 };
252
253 let in_cart = if let Some(ref user) = maybe_user {
254 db::cart::is_in_cart(&state.db, user.id, db_item.id).await.unwrap_or(false)
255 } else {
256 false
257 };
258
259 let collection_count = if let Some(ref user) = maybe_user {
260 db::collections::count_user_collections_containing_item(&state.db, user.id, db_item.id)
261 .await
262 .unwrap_or(0) as u32
263 } else {
264 0
265 };
266
267 // Ordered gallery → carousel frames (additive to the cover image). Alt is
268 // creator-optional; fall back to a title-based description rather than an
269 // empty string (the carousel relies on alt for screen-reader parity, and
270 // CarouselFrame::new debug-asserts non-empty, so build the struct directly).
271 let gallery = db::gallery_images::list_for_item(&state.db, db_item.id)
272 .await
273 .unwrap_or_default()
274 .into_iter()
275 .map(|g| crate::templates::CarouselFrame {
276 image: g.image_url,
277 alt: if g.alt.trim().is_empty() {
278 format!("{} gallery image", db_item.title)
279 } else {
280 g.alt
281 },
282 caption: None,
283 })
284 .collect();
285
286 Ok(ItemTemplate {
287 csrf_token,
288 session_user: maybe_user,
289 item,
290 creator_username: db_user.username.to_string(),
291 project_title: db_project.title.clone(),
292 project_slug: project_slug_str,
293 host_url: state.config.host_url.clone(),
294 project_cover_image_url: db_project.cover_image_url.clone(),
295 discussion_url,
296 discussion_count,
297 bundle_items: bundle_item_views,
298 containing_bundles: containing_bundle_views,
299 sections,
300 is_owner,
301 is_wishlisted,
302 in_cart,
303 collection_count,
304 has_access,
305 gallery,
306 }
307 .into_response())
308 }
309
310 // =============================================================================
311 // Segment builder for insertion playback
312 // =============================================================================
313
314 /// A player segment for the JS segment playlist.
315 #[derive(serde::Serialize)]
316 struct PlayerSegment {
317 url: String,
318 duration_ms: u32,
319 segment_type: String,
320 title: Option<String>,
321 }
322
323 /// Build the segments JSON for the media player. Returns "null" if no insertions.
324 pub(super) async fn build_segments_json(
325 state: &AppState,
326 item_id: ItemId,
327 media_url: &Option<String>,
328 db_item: &db::DbItem,
329 ) -> String {
330 let placements =
331 match db::content_insertions::list_placements_for_item(&state.db, item_id).await {
332 Ok(p) => p,
333 Err(_) => return "null".to_string(),
334 };
335
336 if placements.is_empty() {
337 return "null".to_string();
338 }
339
340 let s3 = match &state.s3 {
341 Some(s3) => s3,
342 None => return "null".to_string(),
343 };
344
345 // Presign each insertion URL
346 let mut segments: Vec<PlayerSegment> = Vec::new();
347 let mut presigned_cache: std::collections::HashMap<String, String> =
348 std::collections::HashMap::new();
349
350 // Collect pre-rolls, mid-rolls (sorted by offset), and post-rolls
351 let mut pre_rolls = Vec::new();
352 let mut mid_rolls = Vec::new();
353 let mut post_rolls = Vec::new();
354
355 for p in &placements {
356 let url = if let Some(cached) = presigned_cache.get(&p.insertion_storage_key) {
357 cached.clone()
358 } else {
359 match s3
360 .presign_download(&p.insertion_storage_key, Some(3600))
361 .await
362 {
363 Ok(url) => {
364 presigned_cache.insert(p.insertion_storage_key.clone(), url.clone());
365 url
366 }
367 Err(_) => continue,
368 }
369 };
370
371 let seg = PlayerSegment {
372 url,
373 duration_ms: p.insertion_duration_ms.max(0) as u32,
374 segment_type: p.position.to_string(),
375 title: Some(p.insertion_title.clone()),
376 };
377
378 match p.position {
379 db::InsertionPosition::PreRoll => pre_rolls.push(seg),
380 db::InsertionPosition::MidRoll => mid_rolls.push((p.offset_ms.unwrap_or(0), seg)),
381 db::InsertionPosition::PostRoll => post_rolls.push(seg),
382 }
383 }
384
385 // Get main content duration in ms
386 let main_duration_ms = match db_item.content() {
387 ContentData::Audio { duration_seconds, .. }
388 | ContentData::Video { duration_seconds, .. } => {
389 duration_seconds.map(|s| (s.max(0) as u64 * 1000).min(u32::MAX as u64) as u32).unwrap_or(0)
390 }
391 _ => 0,
392 };
393
394 // Add pre-rolls
395 for seg in pre_rolls {
396 segments.push(seg);
397 }
398
399 // Sort mid-rolls by offset, then interleave with main content segments
400 mid_rolls.sort_by_key(|(offset, _)| *offset);
401
402 if mid_rolls.is_empty() {
403 // No mid-rolls: single main segment
404 if let Some(url) = media_url {
405 segments.push(PlayerSegment {
406 url: url.clone(),
407 duration_ms: main_duration_ms,
408 segment_type: "main".to_string(),
409 title: None,
410 });
411 }
412 } else {
413 // Split main content around mid-roll offsets
414 let mut last_offset_ms: u32 = 0;
415 if let Some(url) = media_url {
416 for (offset_ms, mid_seg) in mid_rolls {
417 let offset = offset_ms.max(0) as u32;
418 if offset > last_offset_ms {
419 // Main segment before this mid-roll
420 segments.push(PlayerSegment {
421 url: url.clone(),
422 duration_ms: offset - last_offset_ms,
423 segment_type: "main".to_string(),
424 title: None,
425 });
426 }
427 segments.push(mid_seg);
428 last_offset_ms = offset;
429 }
430 // Remaining main content after the last mid-roll
431 if last_offset_ms < main_duration_ms {
432 segments.push(PlayerSegment {
433 url: url.clone(),
434 duration_ms: main_duration_ms - last_offset_ms,
435 segment_type: "main".to_string(),
436 title: None,
437 });
438 }
439 }
440 }
441
442 // Add post-rolls
443 for seg in post_rolls {
444 segments.push(seg);
445 }
446
447 let json = match serde_json::to_string(&segments) {
448 Ok(j) => j,
449 Err(_) => return "null".to_string(),
450 };
451 escape_json_for_script_tag(json)
452 }
453
454 /// Neutralize `</` so a JSON value can't break out of the surrounding
455 /// `<script>` block (serde_json does not escape `<`, `>`, or `/` by default, and
456 /// the result is emitted through Askama's `|safe` filter). Load-bearing: a
457 /// creator-controlled insertion title containing `</script>` would otherwise
458 /// inject markup. Pinned by `script_tag_breakout_is_neutralized`.
459 fn escape_json_for_script_tag(json: String) -> String {
460 json.replace("</", "<\\/")
461 }
462
463 /// Build a short plain-text excerpt from raw markdown.
464 ///
465 /// Takes the first paragraph (up to the first blank line), strips obvious
466 /// markdown syntax, collapses whitespace, and truncates at `max_chars` with a
467 /// trailing ellipsis. Used for the store-page deck so visitors can preview a
468 /// paid article without unlocking the full body.
469 fn make_excerpt(body: &str, max_chars: usize) -> String {
470 let first_para = body.split("\n\n").find(|p| !p.trim().is_empty()).unwrap_or("");
471 let stripped: String = first_para
472 .lines()
473 .map(|line| line.trim_start_matches(['#', '>', '-', '*', ' ']))
474 .collect::<Vec<_>>()
475 .join(" ");
476 let plain: String = stripped
477 .replace(['*', '_', '`', '[', ']'], "")
478 .split_whitespace()
479 .collect::<Vec<_>>()
480 .join(" ");
481 if plain.chars().count() <= max_chars {
482 plain
483 } else {
484 let truncated: String = plain.chars().take(max_chars).collect();
485 format!("{}", truncated.trim_end())
486 }
487 }
488
489 #[cfg(test)]
490 mod tests {
491 use super::{escape_json_for_script_tag, make_excerpt};
492
493 #[test]
494 fn script_tag_breakout_is_neutralized() {
495 // A serialized value carrying `</script>` must not survive verbatim into
496 // the <script type="application/json"> block.
497 let raw = serde_json::to_string("</script><script>alert(1)</script>").unwrap();
498 let escaped = escape_json_for_script_tag(raw);
499 assert!(!escaped.contains("</script>"), "literal </script> leaked: {escaped}");
500 assert!(!escaped.contains("</"), "no unescaped </ should remain: {escaped}");
501 assert!(escaped.contains("<\\/script>"), "expected escaped form: {escaped}");
502 }
503
504 #[test]
505 fn excerpt_short_passes_through() {
506 assert_eq!(make_excerpt("Hello world", 100), "Hello world");
507 }
508
509 #[test]
510 fn excerpt_first_paragraph_only() {
511 let body = "First paragraph.\n\nSecond paragraph should be ignored.";
512 assert_eq!(make_excerpt(body, 100), "First paragraph.");
513 }
514
515 #[test]
516 fn excerpt_strips_markdown_markers() {
517 let body = "# Heading\n**bold** and *italic* and `code` and [link](url)";
518 let out = make_excerpt(body, 100);
519 assert!(out.contains("Heading"));
520 assert!(out.contains("bold"));
521 assert!(!out.contains("**"));
522 assert!(!out.contains('`'));
523 }
524
525 #[test]
526 fn excerpt_truncates_with_ellipsis() {
527 let body = "a".repeat(500);
528 let out = make_excerpt(&body, 50);
529 assert_eq!(out.chars().count(), 51); // 50 chars + ellipsis
530 assert!(out.ends_with(''));
531 }
532
533 #[test]
534 fn excerpt_empty_body() {
535 assert_eq!(make_excerpt("", 100), "");
536 }
537 }
538