Skip to main content

max / makenotwork

16.8 KB · 500 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 has_item_sub = if let Some(ref user) = maybe_user {
90 db::subscriptions::has_active_subscription_to_item(&state.db, user.id, db_item.id).await?
91 } else {
92 false
93 };
94 let ctx = pricing::AccessContext {
95 is_creator: is_owner,
96 has_purchased: in_library,
97 has_active_subscription: has_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 Ok(ItemTemplate {
268 csrf_token,
269 session_user: maybe_user,
270 item,
271 creator_username: db_user.username.to_string(),
272 project_title: db_project.title.clone(),
273 project_slug: project_slug_str,
274 host_url: state.config.host_url.clone(),
275 project_cover_image_url: db_project.cover_image_url.clone(),
276 discussion_url,
277 discussion_count,
278 bundle_items: bundle_item_views,
279 containing_bundles: containing_bundle_views,
280 sections,
281 is_owner,
282 is_wishlisted,
283 in_cart,
284 collection_count,
285 has_access,
286 }
287 .into_response())
288 }
289
290 // =============================================================================
291 // Segment builder for insertion playback
292 // =============================================================================
293
294 /// A player segment for the JS segment playlist.
295 #[derive(serde::Serialize)]
296 struct PlayerSegment {
297 url: String,
298 duration_ms: u32,
299 segment_type: String,
300 title: Option<String>,
301 }
302
303 /// Build the segments JSON for the media player. Returns "null" if no insertions.
304 pub(super) async fn build_segments_json(
305 state: &AppState,
306 item_id: ItemId,
307 media_url: &Option<String>,
308 db_item: &db::DbItem,
309 ) -> String {
310 let placements =
311 match db::content_insertions::list_placements_for_item(&state.db, item_id).await {
312 Ok(p) => p,
313 Err(_) => return "null".to_string(),
314 };
315
316 if placements.is_empty() {
317 return "null".to_string();
318 }
319
320 let s3 = match &state.s3 {
321 Some(s3) => s3,
322 None => return "null".to_string(),
323 };
324
325 // Presign each insertion URL
326 let mut segments: Vec<PlayerSegment> = Vec::new();
327 let mut presigned_cache: std::collections::HashMap<String, String> =
328 std::collections::HashMap::new();
329
330 // Collect pre-rolls, mid-rolls (sorted by offset), and post-rolls
331 let mut pre_rolls = Vec::new();
332 let mut mid_rolls = Vec::new();
333 let mut post_rolls = Vec::new();
334
335 for p in &placements {
336 let url = if let Some(cached) = presigned_cache.get(&p.insertion_storage_key) {
337 cached.clone()
338 } else {
339 match s3
340 .presign_download(&p.insertion_storage_key, Some(3600))
341 .await
342 {
343 Ok(url) => {
344 presigned_cache.insert(p.insertion_storage_key.clone(), url.clone());
345 url
346 }
347 Err(_) => continue,
348 }
349 };
350
351 let seg = PlayerSegment {
352 url,
353 duration_ms: p.insertion_duration_ms.max(0) as u32,
354 segment_type: p.position.to_string(),
355 title: Some(p.insertion_title.clone()),
356 };
357
358 match p.position {
359 db::InsertionPosition::PreRoll => pre_rolls.push(seg),
360 db::InsertionPosition::MidRoll => mid_rolls.push((p.offset_ms.unwrap_or(0), seg)),
361 db::InsertionPosition::PostRoll => post_rolls.push(seg),
362 }
363 }
364
365 // Get main content duration in ms
366 let main_duration_ms = match db_item.content() {
367 ContentData::Audio { duration_seconds, .. }
368 | ContentData::Video { duration_seconds, .. } => {
369 duration_seconds.map(|s| (s.max(0) as u64 * 1000).min(u32::MAX as u64) as u32).unwrap_or(0)
370 }
371 _ => 0,
372 };
373
374 // Add pre-rolls
375 for seg in pre_rolls {
376 segments.push(seg);
377 }
378
379 // Sort mid-rolls by offset, then interleave with main content segments
380 mid_rolls.sort_by_key(|(offset, _)| *offset);
381
382 if mid_rolls.is_empty() {
383 // No mid-rolls: single main segment
384 if let Some(url) = media_url {
385 segments.push(PlayerSegment {
386 url: url.clone(),
387 duration_ms: main_duration_ms,
388 segment_type: "main".to_string(),
389 title: None,
390 });
391 }
392 } else {
393 // Split main content around mid-roll offsets
394 let mut last_offset_ms: u32 = 0;
395 if let Some(url) = media_url {
396 for (offset_ms, mid_seg) in mid_rolls {
397 let offset = offset_ms.max(0) as u32;
398 if offset > last_offset_ms {
399 // Main segment before this mid-roll
400 segments.push(PlayerSegment {
401 url: url.clone(),
402 duration_ms: offset - last_offset_ms,
403 segment_type: "main".to_string(),
404 title: None,
405 });
406 }
407 segments.push(mid_seg);
408 last_offset_ms = offset;
409 }
410 // Remaining main content after the last mid-roll
411 if last_offset_ms < main_duration_ms {
412 segments.push(PlayerSegment {
413 url: url.clone(),
414 duration_ms: main_duration_ms - last_offset_ms,
415 segment_type: "main".to_string(),
416 title: None,
417 });
418 }
419 }
420 }
421
422 // Add post-rolls
423 for seg in post_rolls {
424 segments.push(seg);
425 }
426
427 let json = match serde_json::to_string(&segments) {
428 Ok(j) => j,
429 Err(_) => return "null".to_string(),
430 };
431 // Replace </ with <\/ to prevent </script> injection when embedded in a
432 // <script> tag via Askama's |safe filter.
433 json.replace("</", "<\\/")
434 }
435
436 /// Build a short plain-text excerpt from raw markdown.
437 ///
438 /// Takes the first paragraph (up to the first blank line), strips obvious
439 /// markdown syntax, collapses whitespace, and truncates at `max_chars` with a
440 /// trailing ellipsis. Used for the store-page deck so visitors can preview a
441 /// paid article without unlocking the full body.
442 fn make_excerpt(body: &str, max_chars: usize) -> String {
443 let first_para = body.split("\n\n").find(|p| !p.trim().is_empty()).unwrap_or("");
444 let stripped: String = first_para
445 .lines()
446 .map(|line| line.trim_start_matches(['#', '>', '-', '*', ' ']))
447 .collect::<Vec<_>>()
448 .join(" ");
449 let plain: String = stripped
450 .replace(['*', '_', '`', '[', ']'], "")
451 .split_whitespace()
452 .collect::<Vec<_>>()
453 .join(" ");
454 if plain.chars().count() <= max_chars {
455 plain
456 } else {
457 let truncated: String = plain.chars().take(max_chars).collect();
458 format!("{}", truncated.trim_end())
459 }
460 }
461
462 #[cfg(test)]
463 mod tests {
464 use super::make_excerpt;
465
466 #[test]
467 fn excerpt_short_passes_through() {
468 assert_eq!(make_excerpt("Hello world", 100), "Hello world");
469 }
470
471 #[test]
472 fn excerpt_first_paragraph_only() {
473 let body = "First paragraph.\n\nSecond paragraph should be ignored.";
474 assert_eq!(make_excerpt(body, 100), "First paragraph.");
475 }
476
477 #[test]
478 fn excerpt_strips_markdown_markers() {
479 let body = "# Heading\n**bold** and *italic* and `code` and [link](url)";
480 let out = make_excerpt(body, 100);
481 assert!(out.contains("Heading"));
482 assert!(out.contains("bold"));
483 assert!(!out.contains("**"));
484 assert!(!out.contains('`'));
485 }
486
487 #[test]
488 fn excerpt_truncates_with_ellipsis() {
489 let body = "a".repeat(500);
490 let out = make_excerpt(&body, 50);
491 assert_eq!(out.chars().count(), 51); // 50 chars + ellipsis
492 assert!(out.ends_with(''));
493 }
494
495 #[test]
496 fn excerpt_empty_body() {
497 assert_eq!(make_excerpt("", 100), "");
498 }
499 }
500