Skip to main content

max / makenotwork

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