Skip to main content

max / makenotwork

14.2 KB · 409 lines History Blame Raw
1 //! Public user, project, and item detail pages.
2
3 mod item;
4 mod library;
5 mod project;
6
7 pub(in crate::routes::pages::public) use item::item_page;
8 pub(crate) use item::render_item_page;
9 pub(in crate::routes::pages::public) use library::library_page;
10 pub(in crate::routes::pages::public) use project::project_page;
11 pub(crate) use project::render_project_page;
12
13 use axum::{
14 extract::{Path, Query, State},
15 response::{IntoResponse, Redirect, Response},
16 };
17 use serde::Deserialize;
18 use sqlx::PgPool;
19 use tower_sessions::Session;
20
21 use crate::{
22 auth::{MaybeUserVerified, SessionUser},
23 config::Config,
24 db::{self, FollowTargetType, ItemId, Username},
25 error::{AppError, Result},
26 helpers::get_csrf_token,
27 templates::{
28 BuyPageTemplate, CollectionTemplate, PurchaseTemplate, ReceiptTemplate, UserTemplate,
29 },
30 types::{Collection, CollectionItem, CustomLink, Item, Project, User},
31 };
32
33 /// Fire-and-forget page view recording. Never blocks the response.
34 ///
35 /// Routes through the bounded `PageViewTx` batcher (single bg flush task,
36 /// bulk UPSERT every 500ms), the prior per-request `tokio::spawn` pattern
37 /// saturated the DB pool under any view burst.
38 pub(crate) fn track_view(
39 page_view_tx: &crate::db::page_views::PageViewTx,
40 target_type: &'static str,
41 target_id: uuid::Uuid,
42 ) {
43 page_view_tx.try_record(target_type, target_id);
44 }
45
46 /// Returns true if the User-Agent looks like a bot/crawler.
47 pub(crate) fn is_bot(user_agent: &str) -> bool {
48 let ua = user_agent.to_ascii_lowercase();
49 ua.contains("bot")
50 || ua.contains("crawler")
51 || ua.contains("spider")
52 || ua.contains("slurp")
53 || ua.contains("facebookexternalhit")
54 || ua.contains("twitterbot")
55 || ua.contains("linkedinbot")
56 || ua.contains("mediapartners")
57 || ua.contains("curl")
58 || ua.contains("wget")
59 || ua.contains("python-requests")
60 }
61
62 /// Query parameters for the purchase page.
63 #[derive(Debug, Deserialize)]
64 pub(crate) struct PurchaseQuery {
65 pub code: Option<String>,
66 }
67
68 /// Render a public user profile page with projects and custom links.
69 #[tracing::instrument(skip_all, name = "content::user_page")]
70 pub(super) async fn user_page(
71 State(db): State<PgPool>,
72 State(config): State<Config>,
73 State(page_view_tx): State<crate::db::page_views::PageViewTx>,
74 session: Session,
75 headers: axum::http::HeaderMap,
76 MaybeUserVerified(maybe_user): MaybeUserVerified,
77 Path(username): Path<String>,
78 ) -> Result<Response> {
79 let csrf_token = get_csrf_token(&session).await;
80 let username = Username::new(&username).map_err(|_| AppError::NotFound)?;
81 let db_user = db::users::get_user_by_username(&db, &username)
82 .await?
83 .ok_or(AppError::NotFound)?;
84 // Sandbox accounts are not publicly visible
85 if db_user.is_sandbox {
86 return Err(AppError::NotFound);
87 }
88 let response = render_user_profile(&db, &config, &db_user, csrf_token, maybe_user).await?;
89 let ua = headers
90 .get(axum::http::header::USER_AGENT)
91 .and_then(|v| v.to_str().ok())
92 .unwrap_or("");
93 if !is_bot(ua) {
94 track_view(&page_view_tx, "user", *db_user.id);
95 }
96 Ok(response)
97 }
98
99 /// Shared user profile renderer, used by both named routes and custom domain fallback.
100 pub(crate) async fn render_user_profile(
101 db: &PgPool,
102 config: &Config,
103 db_user: &db::DbUser,
104 csrf_token: Option<String>,
105 maybe_user: Option<SessionUser>,
106 ) -> Result<Response> {
107 let db_projects = db::projects::get_public_projects_with_item_counts(db, db_user.id).await?;
108 let db_links = db::custom_links::get_custom_links_by_user(db, db_user.id).await?;
109
110 let user = User::from(db_user);
111 let projects: Vec<Project> = db_projects.iter().map(Project::from).collect();
112 let custom_links: Vec<CustomLink> = db_links.iter().map(CustomLink::from).collect();
113
114 let db_collections = db::collections::get_public_collections_by_user(db, db_user.id).await?;
115 let public_collections: Vec<Collection> = db_collections.iter().map(Collection::from).collect();
116
117 let follower_count =
118 db::follows::get_follower_count(db, FollowTargetType::User, db_user.id.into()).await?;
119 let is_following = if let Some(ref viewer) = maybe_user {
120 db::follows::is_following(db, viewer.id, FollowTargetType::User, db_user.id.into()).await?
121 } else {
122 false
123 };
124
125 let is_own_profile = maybe_user.as_ref().is_some_and(|v| v.id == db_user.id);
126
127 Ok(UserTemplate {
128 csrf_token,
129 session_user: maybe_user,
130 creator_paused: db_user.is_creator_paused(),
131 tips_enabled: db_user.tips_enabled && db_user.stripe_charges_enabled,
132 creator_id: db_user.id.to_string(),
133 tip_project_id: None,
134 user,
135 custom_links,
136 projects,
137 public_collections,
138 user_id: db_user.id.to_string(),
139 is_own_profile,
140 is_following,
141 follower_count,
142 host_url: config.host_url.clone(),
143 theme_css: crate::theming::theme_css(db_user.theme_id.as_deref()),
144 }
145 .into_response())
146 }
147
148 /// Render the purchase confirmation page with fee breakdown.
149 #[tracing::instrument(skip_all, name = "content::purchase_page")]
150 pub(super) async fn purchase_page(
151 State(db): State<PgPool>,
152 session: Session,
153 MaybeUserVerified(maybe_user): MaybeUserVerified,
154 Path(item_id): Path<String>,
155 Query(query): Query<PurchaseQuery>,
156 ) -> Result<impl IntoResponse> {
157 let csrf_token = get_csrf_token(&session).await;
158 let is_logged_in = maybe_user.is_some();
159 let id: ItemId = item_id.parse().map_err(|_| AppError::NotFound)?;
160
161 let db_item = db::items::get_item_by_id(&db, id)
162 .await?
163 .ok_or(AppError::NotFound)?;
164
165 let db_project = db::projects::get_project_by_id(&db, db_item.project_id)
166 .await?
167 .ok_or(AppError::NotFound)?;
168
169 let db_user = db::users::get_user_by_id(&db, db_project.user_id)
170 .await?
171 .ok_or(AppError::NotFound)?;
172
173 // Visibility gate, mirror item_page: a draft/deleted/sandbox item's title
174 // and price must not leak to anyone but the owner who holds its UUID.
175 let is_owner = maybe_user
176 .as_ref()
177 .is_some_and(|u| u.id == db_project.user_id);
178 if db_user.is_sandbox && !is_owner {
179 return Err(AppError::NotFound);
180 }
181 if !db_item.is_public && !is_owner {
182 return Err(AppError::NotFound);
183 }
184 if db_item.deleted_at.is_some() && !is_owner {
185 return Err(AppError::NotFound);
186 }
187
188 let price_cents = db_item.price_cents;
189
190 // Free items don't need the purchase page, redirect to item page
191 if price_cents == 0 && !db_item.pwyw_enabled {
192 return Ok(Redirect::to(&format!("/i/{id}")).into_response());
193 }
194
195 // Calculate fee breakdown for transparency
196 let (stripe_fee_cents, creator_receives_cents) =
197 crate::helpers::estimate_stripe_fee(price_cents);
198 let stripe_fee = crate::formatting::format_dollars_plain(stripe_fee_cents);
199 let creator_receives = crate::formatting::format_dollars_plain(creator_receives_cents);
200
201 let purchase_tags = db::tags::get_tags_for_item(&db, id).await?;
202 let item = Item::from_db_list(&db_item, &purchase_tags, price_cents == 0, false);
203
204 let suggested_price = crate::formatting::format_dollars_plain(db_item.price_cents);
205 let pwyw_min = db_item.pwyw_min_cents.unwrap_or(0);
206 let pwyw_min_dollars = crate::formatting::format_dollars_plain(pwyw_min);
207
208 let pending_started = if let Some(ref u) = maybe_user {
209 match db::transactions::get_pending_item_purchase(&db, u.id, id).await? {
210 Some((_, created_at)) => format_relative_ago(created_at),
211 None => String::new(),
212 }
213 } else {
214 String::new()
215 };
216
217 Ok(PurchaseTemplate {
218 csrf_token,
219 item,
220 creator_username: db_user.username.to_string(),
221 stripe_fee,
222 creator_receives,
223 promo_code: query.code.unwrap_or_default(),
224 pwyw_enabled: db_item.pwyw_enabled,
225 pwyw_min_cents: pwyw_min,
226 suggested_price,
227 pwyw_min_dollars,
228 stripe_tax_enabled: db_user.stripe_tax_enabled,
229 is_logged_in,
230 pending_started,
231 }
232 .into_response())
233 }
234
235 fn format_relative_ago(ts: chrono::DateTime<chrono::Utc>) -> String {
236 let delta = chrono::Utc::now().signed_duration_since(ts);
237 let secs = delta.num_seconds().max(0);
238 if secs < 60 {
239 "just now".to_string()
240 } else if secs < 3600 {
241 let m = secs / 60;
242 format!("{m} minute{} ago", if m == 1 { "" } else { "s" })
243 } else if secs < 86400 {
244 let h = secs / 3600;
245 format!("{h} hour{} ago", if h == 1 { "" } else { "s" })
246 } else {
247 let d = secs / 86400;
248 format!("{d} day{} ago", if d == 1 { "" } else { "s" })
249 }
250 }
251
252 /// Render a purchase receipt page.
253 #[tracing::instrument(skip_all, name = "content::receipt_page")]
254 pub(super) async fn receipt_page(
255 State(db): State<PgPool>,
256 session: Session,
257 MaybeUserVerified(maybe_user): MaybeUserVerified,
258 Path(transaction_id): Path<String>,
259 ) -> Result<impl IntoResponse> {
260 let csrf_token = get_csrf_token(&session).await;
261 let tx_id: db::TransactionId = transaction_id.parse().map_err(|_| AppError::NotFound)?;
262
263 let tx = db::transactions::get_transaction_by_id(&db, tx_id)
264 .await?
265 .ok_or(AppError::NotFound)?;
266
267 // Only the buyer or the seller can view a receipt. An anonymous viewer must
268 // never match: guest transactions persist `buyer_id = NULL`, so comparing an
269 // `Option` viewer directly (`None == tx.buyer_id`) would let any anonymous
270 // caller read a guest receipt. Require an authenticated viewer, then compare
271 // against the concrete `Some(id)`.
272 let Some(viewer_id) = maybe_user.as_ref().map(|u| u.id) else {
273 return Err(AppError::Forbidden);
274 };
275 let is_buyer = tx.buyer_id == Some(viewer_id);
276 let is_seller = tx.seller_id == Some(viewer_id);
277 if !is_buyer && !is_seller {
278 return Err(AppError::Forbidden);
279 }
280
281 let amount_cents = *tx.amount_cents;
282 let is_free = amount_cents == 0;
283 let amount = if is_free {
284 "Free".to_string()
285 } else {
286 crate::formatting::format_revenue(amount_cents)
287 };
288
289 let item_id = tx.item_id.map(|id| id.to_string()).unwrap_or_default();
290 let item_title = tx
291 .item_title
292 .unwrap_or_else(|| "[Deleted item]".to_string());
293 let seller_username = tx
294 .seller_username
295 .unwrap_or_else(|| "[Deleted user]".to_string());
296 let date = tx
297 .completed_at
298 .unwrap_or(tx.created_at)
299 .format("%B %d, %Y at %H:%M UTC")
300 .to_string();
301
302 Ok(ReceiptTemplate {
303 csrf_token,
304 session_user: maybe_user,
305 transaction_id: tx.id.to_string(),
306 item_id,
307 item_title,
308 seller_username,
309 amount,
310 is_free,
311 status: tx.status.to_string(),
312 date,
313 }
314 .into_response())
315 }
316
317 /// Render a public collection page.
318 #[tracing::instrument(skip_all, name = "content::collection_page")]
319 pub(super) async fn collection_page(
320 State(db): State<PgPool>,
321 session: Session,
322 MaybeUserVerified(maybe_user): MaybeUserVerified,
323 Path((username, slug)): Path<(String, String)>,
324 ) -> Result<impl IntoResponse> {
325 let csrf_token = get_csrf_token(&session).await;
326 let username = Username::new(&username).map_err(|_| AppError::NotFound)?;
327 let db_user = db::users::get_user_by_username(&db, &username)
328 .await?
329 .ok_or(AppError::NotFound)?;
330
331 let slug = db::Slug::new(&slug).map_err(|_| AppError::NotFound)?;
332 let collection = db::collections::get_collection_by_user_and_slug(&db, db_user.id, &slug)
333 .await?
334 .ok_or(AppError::NotFound)?;
335
336 // Private collections are only visible to the owner
337 let is_owner = maybe_user.as_ref().is_some_and(|u| u.id == db_user.id);
338 if !collection.is_public && !is_owner {
339 return Err(AppError::NotFound);
340 }
341
342 let db_items = db::collections::get_collection_items(&db, collection.id).await?;
343 let items: Vec<CollectionItem> = db_items.iter().map(CollectionItem::from).collect();
344
345 let item_count = items.len() as i64;
346
347 Ok(CollectionTemplate {
348 csrf_token,
349 session_user: maybe_user,
350 collection: Collection {
351 id: collection.id.to_string(),
352 slug: collection.slug.to_string(),
353 title: collection.title.clone(),
354 description: collection.description.clone(),
355 is_public: collection.is_public,
356 item_count,
357 created_at: collection.created_at.format("%b %d, %Y").to_string(),
358 },
359 items,
360 owner_username: db_user.username.to_string(),
361 owner_display_name: db_user.display_name.clone(),
362 is_owner,
363 })
364 }
365
366 /// Minimal direct purchase page; no navigation chrome, optimized for link-in-bio
367 /// and social media sharing. Shows item summary + guest checkout button.
368 #[tracing::instrument(skip_all, name = "content::buy_page")]
369 pub(super) async fn buy_page(
370 State(db): State<PgPool>,
371 State(config): State<Config>,
372 Path(item_id): Path<String>,
373 ) -> Result<impl IntoResponse> {
374 let id: ItemId = item_id.parse().map_err(|_| AppError::NotFound)?;
375
376 let db_item = db::items::get_item_by_id(&db, id)
377 .await?
378 .ok_or(AppError::NotFound)?;
379
380 if !db_item.is_public {
381 return Err(AppError::NotFound);
382 }
383
384 let db_project = db::projects::get_project_by_id(&db, db_item.project_id)
385 .await?
386 .ok_or(AppError::NotFound)?;
387
388 let db_user = db::users::get_user_by_id(&db, db_project.user_id)
389 .await?
390 .ok_or(AppError::NotFound)?;
391
392 let purchase_tags = db::tags::get_tags_for_item(&db, id).await?;
393 let item = Item::from_db_list(&db_item, &purchase_tags, db_item.price_cents == 0, false);
394
395 let suggested_price = crate::formatting::format_dollars_plain(db_item.price_cents);
396 let pwyw_min = db_item.pwyw_min_cents.unwrap_or(0);
397 let pwyw_min_dollars = crate::formatting::format_dollars_plain(pwyw_min);
398
399 Ok(BuyPageTemplate {
400 item,
401 creator_username: db_user.username.to_string(),
402 creator_display_name: db_user.display_name.clone(),
403 pwyw_enabled: db_item.pwyw_enabled,
404 pwyw_min_dollars,
405 suggested_price,
406 host_url: config.host_url.clone(),
407 })
408 }
409