Skip to main content

max / makenotwork

13.5 KB · 426 lines History Blame Raw
1 //! `/l/{item_id}`: library (consumption) view for items the viewer has access to.
2 //!
3 //! Separate from `/i/{id}` (store page). 403 if the viewer doesn't have access;
4 //! 404 if the item is missing, unpublished/deleted, or owned by a sandbox seller.
5
6 use axum::{
7 extract::{Path, State},
8 response::{IntoResponse, Response},
9 };
10 use tower_sessions::Session;
11
12 use crate::{
13 auth::MaybeUserVerified,
14 constants,
15 db::{self, ContentData, ItemId, ItemType},
16 error::{AppError, Result},
17 helpers::{fetch_discussion_info, get_csrf_token, get_initials},
18 pricing,
19 templates::*,
20 types::*,
21 AppState,
22 };
23
24 /// `GET /l/{item_id}`: render the library (consumption) view.
25 #[tracing::instrument(skip_all, name = "content::library_page")]
26 pub(in crate::routes::pages::public) async fn library_page(
27 State(state): State<AppState>,
28 session: Session,
29 headers: axum::http::HeaderMap,
30 MaybeUserVerified(maybe_user): MaybeUserVerified,
31 Path(item_id): Path<String>,
32 ) -> Result<Response> {
33 let csrf_token = get_csrf_token(&session).await;
34 let id: ItemId = item_id.parse().map_err(|_| AppError::NotFound)?;
35
36 let db_item = db::items::get_item_by_id(&state.db, id)
37 .await?
38 .ok_or(AppError::NotFound)?;
39 let db_project = db::projects::get_project_by_id(&state.db, db_item.project_id)
40 .await?
41 .ok_or(AppError::NotFound)?;
42 let db_user = db::users::get_user_by_id(&state.db, db_project.user_id)
43 .await?
44 .ok_or(AppError::NotFound)?;
45 if db_user.is_sandbox {
46 return Err(AppError::NotFound);
47 }
48
49 let is_owner = maybe_user
50 .as_ref()
51 .map(|u| u.id == db_project.user_id)
52 .unwrap_or(false);
53
54 // Unpublished or soft-deleted items: hide from non-owners (don't leak draft existence).
55 if (!db_item.is_public || db_item.deleted_at.is_some()) && !is_owner {
56 return Err(AppError::NotFound);
57 }
58
59 // Compute access using the same logic as item_page.
60 let item_pricing = pricing::for_item(&db_item);
61 let in_library = if let Some(ref user) = maybe_user {
62 db::transactions::has_purchased_item(&state.db, user.id, db_item.id).await?
63 } else {
64 false
65 };
66 let item_sub = if let Some(ref user) = maybe_user {
67 db::subscriptions::SubscriptionGate::check(&state.db, user.id, db::subscriptions::SubscriptionScope::Item(db_item.id)).await?
68 } else {
69 None
70 };
71 let ctx = pricing::AccessContext {
72 is_creator: is_owner,
73 has_purchased: in_library,
74 subscription: item_sub,
75 };
76 let mut has_access = item_pricing.can_access(&ctx);
77 if !has_access
78 && let Some(ref user) = maybe_user
79 && db::bundles::has_access_via_bundle(&state.db, user.id, db_item.id).await?
80 {
81 has_access = true;
82 }
83
84 let item_tags = db::tags::get_tags_for_item(&state.db, db_item.id).await?;
85 let is_free = item_pricing.is_free();
86 let item = Item::from_db_detail(&db_item, &item_tags, None, None, is_free, has_access);
87
88 if !has_access {
89 // Render 403 with link back to /i/{id}. For unlisted items, list containing bundles.
90 let containing_bundles: Vec<Item> = if !db_item.listed {
91 let bundle_ids =
92 db::bundles::get_bundles_containing_item(&state.db, db_item.id).await?;
93 let mut bundles = Vec::new();
94 for bid in &bundle_ids {
95 if let Some(b) = db::items::get_item_by_id(&state.db, *bid).await?
96 && b.is_public
97 {
98 let tags = Vec::new();
99 bundles.push(Item::from_db_list(&b, &tags, b.price_cents == 0, false));
100 }
101 }
102 bundles
103 } else {
104 Vec::new()
105 };
106
107 let is_logged_in = maybe_user.is_some();
108 return Ok((
109 axum::http::StatusCode::FORBIDDEN,
110 LibraryLockedTemplate {
111 csrf_token,
112 session_user: maybe_user,
113 item,
114 creator_username: db_user.username.to_string(),
115 host_url: state.config.host_url.clone(),
116 containing_bundles,
117 is_logged_in,
118 },
119 )
120 .into_response());
121 }
122
123 // View tracking belongs on /l/ (consumption signal), not /i/.
124 let ua = headers
125 .get(axum::http::header::USER_AGENT)
126 .and_then(|v| v.to_str().ok())
127 .unwrap_or("");
128 if !super::is_bot(ua) {
129 super::track_view(&state, "item", *db_item.id);
130 }
131
132 let db_versions = db::versions::get_versions_by_item(&state.db, db_item.id).await?;
133 let versions: Vec<Version> = db_versions.iter().map(Version::from_db).collect();
134
135 let project_slug_str = db_project.slug.to_string();
136 let (discussion_url, discussion_count) =
137 fetch_discussion_info(&state, db_item.mt_thread_id, &project_slug_str, "items").await;
138
139 // Phase 2: audio items get their own player template.
140 if db_item.item_type == ItemType::Audio {
141 return render_audio_library(
142 &state,
143 &db_item,
144 &db_user,
145 &db_project,
146 csrf_token,
147 maybe_user,
148 item,
149 versions,
150 discussion_url,
151 discussion_count,
152 is_owner,
153 )
154 .await;
155 }
156
157 // Phase 4: text items get their own reader template.
158 if db_item.item_type == ItemType::Text {
159 return render_text_library(
160 &state,
161 &db_item,
162 &db_user,
163 &db_project,
164 csrf_token,
165 maybe_user,
166 item,
167 discussion_url,
168 discussion_count,
169 is_owner,
170 )
171 .await;
172 }
173
174 // Phase 3: video items get their own player template.
175 if db_item.item_type == ItemType::Video {
176 return render_video_library(
177 &state,
178 &db_item,
179 &db_user,
180 &db_project,
181 csrf_token,
182 maybe_user,
183 item,
184 versions,
185 discussion_url,
186 discussion_count,
187 is_owner,
188 )
189 .await;
190 }
191
192 // Phase 1: downloads / bundle / other items render here. Audio, video, and
193 // text branches above handle their own templates.
194 let bundle_child_items = if db_item.item_type == ItemType::Bundle {
195 db::bundles::get_bundle_items(&state.db, db_item.id).await?
196 } else {
197 Vec::new()
198 };
199 let bundle_items: Vec<Item> = bundle_child_items
200 .iter()
201 .map(|child| {
202 let child_tags = Vec::new();
203 Item::from_db_list(child, &child_tags, child.price_cents == 0, false)
204 })
205 .collect();
206
207 let cdn_base = state
208 .config
209 .cdn_base_url
210 .as_deref()
211 .unwrap_or("https://cdn.makenot.work");
212 let db_sections = db::item_sections::list_by_item(&state.db, db_item.id).await?;
213 let sections: Vec<ItemSection> = db_sections
214 .iter()
215 .map(|s| ItemSection::from_db(s, db_project.user_id, cdn_base))
216 .collect();
217
218 Ok(LibraryDownloadsTemplate {
219 csrf_token,
220 session_user: maybe_user,
221 item,
222 creator_username: db_user.username.to_string(),
223 project_title: db_project.title.clone(),
224 project_slug: project_slug_str,
225 host_url: state.config.host_url.clone(),
226 versions,
227 bundle_items,
228 sections,
229 discussion_url,
230 discussion_count,
231 is_owner,
232 }
233 .into_response())
234 }
235
236 #[allow(clippy::too_many_arguments)]
237 async fn render_audio_library(
238 state: &AppState,
239 db_item: &db::DbItem,
240 db_user: &db::DbUser,
241 db_project: &db::DbProject,
242 csrf_token: Option<String>,
243 maybe_user: Option<crate::auth::SessionUser>,
244 item: Item,
245 versions: Vec<Version>,
246 discussion_url: Option<String>,
247 discussion_count: Option<i64>,
248 is_owner: bool,
249 ) -> Result<Response> {
250 let avatar_initials =
251 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
252 let db_chapters = db::chapters::get_chapters_by_item(&state.db, db_item.id).await?;
253 let chapters: Vec<Chapter> = db_chapters.iter().map(Chapter::from).collect();
254
255 let audio_url = match db_item.content() {
256 ContentData::Audio {
257 audio_s3_key,
258 duration_seconds,
259 audio_url,
260 ..
261 } => {
262 if let (Some(s3_key), Some(s3)) = (&audio_s3_key, &state.s3) {
263 let expiry_secs = match duration_seconds {
264 Some(duration) => ((duration as u64) * 2)
265 .clamp(3600, constants::STREAMING_CACHE_MAX_SECS),
266 None => 3600,
267 };
268 match s3.presign_download(s3_key, Some(expiry_secs)).await {
269 Ok(url) => Some(url),
270 Err(e) => {
271 tracing::warn!(s3_key = %s3_key, error = ?e, "failed to generate presigned url");
272 audio_url
273 }
274 }
275 } else {
276 audio_url
277 }
278 }
279 _ => None,
280 };
281
282 let segments_json =
283 super::item::build_segments_json(state, db_item.id, &audio_url, db_item).await;
284
285 Ok(LibraryAudioTemplate {
286 csrf_token,
287 session_user: maybe_user,
288 item,
289 creator_username: db_user.username.to_string(),
290 creator_display_name: db_user.display_name.clone(),
291 creator_avatar_initials: avatar_initials,
292 project_title: Some(db_project.title.clone()),
293 project_slug: db_project.slug.to_string(),
294 audio_url,
295 chapters,
296 segments_json,
297 versions,
298 host_url: state.config.host_url.clone(),
299 discussion_url,
300 discussion_count,
301 is_owner,
302 }
303 .into_response())
304 }
305
306 #[allow(clippy::too_many_arguments)]
307 async fn render_video_library(
308 state: &AppState,
309 db_item: &db::DbItem,
310 db_user: &db::DbUser,
311 db_project: &db::DbProject,
312 csrf_token: Option<String>,
313 maybe_user: Option<crate::auth::SessionUser>,
314 item: Item,
315 versions: Vec<Version>,
316 discussion_url: Option<String>,
317 discussion_count: Option<i64>,
318 is_owner: bool,
319 ) -> Result<Response> {
320 let avatar_initials =
321 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
322 let db_chapters = db::chapters::get_chapters_by_item(&state.db, db_item.id).await?;
323 let chapters: Vec<Chapter> = db_chapters.iter().map(Chapter::from).collect();
324
325 let video_url = match db_item.content() {
326 ContentData::Video {
327 video_s3_key,
328 duration_seconds,
329 ..
330 } => {
331 if let (Some(s3_key), Some(s3)) = (&video_s3_key, &state.s3) {
332 let expiry_secs = match duration_seconds {
333 Some(duration) => ((duration as u64) * 2)
334 .clamp(3600, constants::STREAMING_CACHE_MAX_SECS),
335 None => 3600,
336 };
337 match s3.presign_download(s3_key, Some(expiry_secs)).await {
338 Ok(url) => Some(url),
339 Err(e) => {
340 tracing::warn!(s3_key = %s3_key, error = ?e, "failed to generate presigned video url");
341 None
342 }
343 }
344 } else {
345 None
346 }
347 }
348 _ => None,
349 };
350
351 let segments_json =
352 super::item::build_segments_json(state, db_item.id, &video_url, db_item).await;
353
354 Ok(LibraryVideoTemplate {
355 csrf_token,
356 session_user: maybe_user,
357 item,
358 creator_username: db_user.username.to_string(),
359 creator_display_name: db_user.display_name.clone(),
360 creator_avatar_initials: avatar_initials,
361 project_title: Some(db_project.title.clone()),
362 project_slug: db_project.slug.to_string(),
363 video_url,
364 chapters,
365 segments_json,
366 versions,
367 host_url: state.config.host_url.clone(),
368 discussion_url,
369 discussion_count,
370 is_owner,
371 }
372 .into_response())
373 }
374
375 #[allow(clippy::too_many_arguments)]
376 async fn render_text_library(
377 state: &AppState,
378 db_item: &db::DbItem,
379 db_user: &db::DbUser,
380 db_project: &db::DbProject,
381 csrf_token: Option<String>,
382 maybe_user: Option<crate::auth::SessionUser>,
383 item: Item,
384 discussion_url: Option<String>,
385 discussion_count: Option<i64>,
386 is_owner: bool,
387 ) -> Result<Response> {
388 let avatar_initials =
389 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
390 let cdn_base = state
391 .config
392 .cdn_base_url
393 .as_deref()
394 .unwrap_or("https://cdn.makenot.work");
395 let (body_html, reading_time) = match db_item.content() {
396 ContentData::Text {
397 body,
398 reading_time_minutes,
399 ..
400 } => (
401 body.as_ref()
402 .map(|b| crate::markdown::render_creator_markdown(b, db_project.user_id, cdn_base)),
403 reading_time_minutes.map(|m| format!("{} min read", m)),
404 ),
405 _ => (None, None),
406 };
407
408 Ok(LibraryTextTemplate {
409 csrf_token,
410 session_user: maybe_user,
411 item,
412 creator_username: db_user.username.to_string(),
413 creator_display_name: db_user.display_name.clone(),
414 creator_avatar_initials: avatar_initials,
415 project_title: db_project.title.clone(),
416 project_slug: db_project.slug.to_string(),
417 body_html,
418 reading_time,
419 host_url: state.config.host_url.clone(),
420 discussion_url,
421 discussion_count,
422 is_owner,
423 }
424 .into_response())
425 }
426