Skip to main content

max / makenotwork

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