Skip to main content

max / makenotwork

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