Skip to main content

max / makenotwork

19.8 KB · 548 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::{BuyPageTemplate, PurchaseTemplate, ReceiptTemplate},
29 types::{Collection, CustomLink, Item, Project, User},
30 };
31
32 /// Fire-and-forget page view recording. Never blocks the response.
33 ///
34 /// Routes through the bounded `PageViewTx` batcher (single bg flush task,
35 /// bulk UPSERT every 500ms), the prior per-request `tokio::spawn` pattern
36 /// saturated the DB pool under any view burst.
37 pub(crate) fn track_view(
38 page_view_tx: &crate::db::page_views::PageViewTx,
39 target_type: &'static str,
40 target_id: uuid::Uuid,
41 ) {
42 page_view_tx.try_record(target_type, target_id);
43 }
44
45 /// Returns true if the User-Agent looks like a bot/crawler.
46 pub(crate) fn is_bot(user_agent: &str) -> bool {
47 let ua = user_agent.to_ascii_lowercase();
48 ua.contains("bot")
49 || ua.contains("crawler")
50 || ua.contains("spider")
51 || ua.contains("slurp")
52 || ua.contains("facebookexternalhit")
53 || ua.contains("twitterbot")
54 || ua.contains("linkedinbot")
55 || ua.contains("mediapartners")
56 || ua.contains("curl")
57 || ua.contains("wget")
58 || ua.contains("python-requests")
59 }
60
61 /// Query parameters for the purchase page.
62 #[derive(Debug, Deserialize)]
63 pub(crate) struct PurchaseQuery {
64 pub code: Option<String>,
65 }
66
67 /// Render a public user profile page with projects and custom links.
68 #[tracing::instrument(skip_all, name = "content::user_page")]
69 pub(super) async fn user_page(
70 State(db): State<PgPool>,
71 State(config): State<Config>,
72 State(page_view_tx): State<crate::db::page_views::PageViewTx>,
73 session: Session,
74 headers: axum::http::HeaderMap,
75 MaybeUserVerified(maybe_user): MaybeUserVerified,
76 Path(username): Path<String>,
77 ) -> Result<Response> {
78 let csrf_token = get_csrf_token(&session).await;
79 let username = Username::new(&username).map_err(|_| AppError::NotFound)?;
80 let db_user = db::users::get_user_by_username(&db, &username)
81 .await?
82 .ok_or(AppError::NotFound)?;
83 // Sandbox accounts are not publicly visible
84 if db_user.is_sandbox {
85 return Err(AppError::NotFound);
86 }
87 let response = render_user_profile(&db, &config, &db_user, csrf_token, maybe_user).await?;
88 let ua = headers
89 .get(axum::http::header::USER_AGENT)
90 .and_then(|v| v.to_str().ok())
91 .unwrap_or("");
92 if !is_bot(ua) {
93 track_view(&page_view_tx, "user", *db_user.id);
94 }
95 Ok(response)
96 }
97
98 /// Shared user profile renderer, used by both named routes and custom domain fallback.
99 pub(crate) async fn render_user_profile(
100 db: &PgPool,
101 config: &Config,
102 db_user: &db::DbUser,
103 csrf_token: Option<String>,
104 maybe_user: Option<SessionUser>,
105 ) -> Result<Response> {
106 let db_projects = db::projects::get_public_projects_with_item_counts(db, db_user.id).await?;
107 let db_links = db::custom_links::get_custom_links_by_user(db, db_user.id).await?;
108
109 let user = User::from(db_user);
110 let projects: Vec<Project> = db_projects.iter().map(Project::from).collect();
111 let custom_links: Vec<CustomLink> = db_links.iter().map(CustomLink::from).collect();
112
113 let db_collections = db::collections::get_public_collections_by_user(db, db_user.id).await?;
114 let public_collections: Vec<Collection> = db_collections.iter().map(Collection::from).collect();
115
116 let follower_count =
117 db::follows::get_follower_count(db, FollowTargetType::User, db_user.id.into()).await?;
118 let is_following = if let Some(ref viewer) = maybe_user {
119 db::follows::is_following(db, viewer.id, FollowTargetType::User, db_user.id.into()).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 let user_id = db_user.id.to_string();
127 let profile = crate::quasi::user::Profile {
128 user: &user,
129 user_id: &user_id,
130 host_url: &config.host_url,
131 custom_links: &custom_links,
132 projects: &projects,
133 collections: &public_collections,
134 follower_count,
135 is_following,
136 is_own_profile,
137 signed_in: maybe_user.is_some(),
138 paused: db_user.is_creator_paused(),
139 tips_enabled: db_user.tips_enabled && db_user.stripe_charges_enabled,
140 };
141 Ok(axum::response::Html(crate::quasi::user::document(
142 maybe_user.as_ref(),
143 csrf_token.as_deref(),
144 &profile,
145 crate::theming::theme_css(db_user.theme_id.as_deref()),
146 ))
147 .into_response())
148 }
149
150 /// Render the purchase confirmation page with fee breakdown.
151 #[tracing::instrument(skip_all, name = "content::purchase_page")]
152 pub(super) async fn purchase_page(
153 State(db): State<PgPool>,
154 session: Session,
155 MaybeUserVerified(maybe_user): MaybeUserVerified,
156 Path(item_id): Path<String>,
157 ValidatedQuery(query): ValidatedQuery<PurchaseQuery>,
158 ) -> Result<impl IntoResponse> {
159 let csrf_token = get_csrf_token(&session).await;
160 let is_logged_in = maybe_user.is_some();
161 let id: ItemId = item_id.parse().map_err(|_| AppError::NotFound)?;
162
163 let db_item = db::items::get_item_by_id(&db, id)
164 .await?
165 .ok_or(AppError::NotFound)?;
166
167 let db_project = db::projects::get_project_by_id(&db, db_item.project_id)
168 .await?
169 .ok_or(AppError::NotFound)?;
170
171 let db_user = db::users::get_user_by_id(&db, db_project.user_id)
172 .await?
173 .ok_or(AppError::NotFound)?;
174
175 // Visibility gate, mirror item_page: a draft/deleted/sandbox item's title
176 // and price must not leak to anyone but the owner who holds its UUID.
177 let is_owner = maybe_user
178 .as_ref()
179 .is_some_and(|u| u.id == db_project.user_id);
180 if db_user.is_sandbox && !is_owner {
181 return Err(AppError::NotFound);
182 }
183 if !db_item.is_public && !is_owner {
184 return Err(AppError::NotFound);
185 }
186 if db_item.deleted_at.is_some() && !is_owner {
187 return Err(AppError::NotFound);
188 }
189
190 let price_cents = db_item.price_cents;
191
192 // Free items don't need the purchase page, redirect to item page
193 if price_cents == 0 && !db_item.pwyw_enabled {
194 return Ok(Redirect::to(&format!("/i/{id}")).into_response());
195 }
196
197 // Calculate fee breakdown for transparency
198 let (stripe_fee_cents, creator_receives_cents) =
199 crate::helpers::estimate_stripe_fee(price_cents);
200 // These render next to a subtotal, so they carry their own symbol rather
201 // than relying on a hardcoded `$` in the template.
202 let stripe_fee =
203 crate::formatting::format_revenue(stripe_fee_cents as i64, db_user.settlement_currency);
204 let creator_receives = crate::formatting::format_revenue(
205 creator_receives_cents as i64,
206 db_user.settlement_currency,
207 );
208
209 let purchase_tags = db::tags::get_tags_for_item(&db, id).await?;
210 let item = Item::from_db_list(
211 &db_item,
212 &purchase_tags,
213 price_cents == 0,
214 false,
215 db_user.settlement_currency,
216 );
217
218 let suggested_price = crate::formatting::format_dollars_plain(db_item.price_cents);
219 // The floor the charge path will accept, not the creator's raw minimum:
220 // a sub-floor minimum here would put an amount in the box that Stripe
221 // refuses after the buyer has typed it. Same rule the project paywall uses.
222 let item_pricing = crate::pricing::for_item(&db_item);
223 let (pwyw_min_dollars, pwyw_min_note) =
224 pwyw_field_bounds(item_pricing.as_ref(), db_user.settlement_currency);
225 let pwyw_min = pwyw_field_min_cents(item_pricing.as_ref(), db_user.settlement_currency);
226
227 let pending_started = if let Some(ref u) = maybe_user {
228 match db::transactions::get_pending_item_purchase(&db, u.id, id).await? {
229 Some((_, created_at)) => format_relative_ago(created_at),
230 None => String::new(),
231 }
232 } else {
233 String::new()
234 };
235
236 Ok(PurchaseTemplate {
237 csrf_token,
238 item,
239 creator_username: db_user.username.to_string(),
240 currency_symbol: db_user.settlement_currency.symbol(),
241 show_fee_estimate: crate::helpers::stripe_fee_estimate_applies(db_user.settlement_currency),
242 stripe_fee,
243 creator_receives,
244 promo_code: query.code.unwrap_or_default(),
245 pwyw_enabled: db_item.pwyw_enabled,
246 pwyw_min_cents: pwyw_min,
247 suggested_price,
248 pwyw_min_dollars,
249 pwyw_min_note,
250 stripe_tax_enabled: db_user.stripe_tax_enabled,
251 is_logged_in,
252 pending_started,
253 }
254 .into_response())
255 }
256
257 fn format_relative_ago(ts: chrono::DateTime<chrono::Utc>) -> String {
258 let delta = chrono::Utc::now().signed_duration_since(ts);
259 let secs = delta.num_seconds().max(0);
260 if secs < 60 {
261 "just now".to_string()
262 } else if secs < 3600 {
263 let m = secs / 60;
264 format!("{m} minute{} ago", if m == 1 { "" } else { "s" })
265 } else if secs < 86400 {
266 let h = secs / 3600;
267 format!("{h} hour{} ago", if h == 1 { "" } else { "s" })
268 } else {
269 let d = secs / 86400;
270 format!("{d} day{} ago", if d == 1 { "" } else { "s" })
271 }
272 }
273
274 /// Render a purchase receipt page.
275 #[tracing::instrument(skip_all, name = "content::receipt_page")]
276 pub(super) async fn receipt_page(
277 State(db): State<PgPool>,
278 session: Session,
279 MaybeUserVerified(maybe_user): MaybeUserVerified,
280 Path(transaction_id): Path<String>,
281 ) -> Result<impl IntoResponse> {
282 let csrf_token = get_csrf_token(&session).await;
283 let tx_id: db::TransactionId = transaction_id.parse().map_err(|_| AppError::NotFound)?;
284
285 let tx = db::transactions::get_transaction_by_id(&db, tx_id)
286 .await?
287 .ok_or(AppError::NotFound)?;
288
289 // Only the buyer or the seller can view a receipt. An anonymous viewer must
290 // never match: guest transactions persist `buyer_id = NULL`, so comparing an
291 // `Option` viewer directly (`None == tx.buyer_id`) would let any anonymous
292 // caller read a guest receipt. Require an authenticated viewer, then compare
293 // against the concrete `Some(id)`.
294 let Some(viewer_id) = maybe_user.as_ref().map(|u| u.id) else {
295 return Err(AppError::Forbidden);
296 };
297 let is_buyer = tx.buyer_id == Some(viewer_id);
298 let is_seller = tx.seller_id == Some(viewer_id);
299 if !is_buyer && !is_seller {
300 return Err(AppError::Forbidden);
301 }
302
303 let amount_cents = *tx.amount_cents;
304 let is_free = amount_cents == 0;
305 let amount = if is_free {
306 "Free".to_string()
307 } else {
308 crate::formatting::format_revenue(amount_cents, tx.currency())
309 };
310
311 // Read before the fields move below.
312 let currency_symbol = tx.currency().symbol();
313 // Rendered with the presentment currency's own code rather than a symbol:
314 // Stripe presents in 150+ markets, so this can be a currency MNW has no
315 // symbol for, and an ISO code is never wrong.
316 let presented_amount = match (
317 tx.presentment_amount_cents,
318 tx.presentment_currency.as_deref(),
319 ) {
320 (Some(cents), Some(code)) => format!(
321 "{} {}",
322 crate::formatting::format_dollars_plain(cents),
323 code.to_uppercase()
324 ),
325 _ => String::new(),
326 };
327 let item_id = tx.item_id.map(|id| id.to_string()).unwrap_or_default();
328 let item_title = tx
329 .item_title
330 .unwrap_or_else(|| "[Deleted item]".to_string());
331 let seller_username = tx
332 .seller_username
333 .unwrap_or_else(|| "[Deleted user]".to_string());
334 let date = tx
335 .completed_at
336 .unwrap_or(tx.created_at)
337 .format("%B %d, %Y at %H:%M UTC")
338 .to_string();
339
340 Ok(ReceiptTemplate {
341 csrf_token,
342 currency_symbol,
343 presented_amount,
344 session_user: maybe_user,
345 transaction_id: tx.id.to_string(),
346 item_id,
347 item_title,
348 seller_username,
349 amount,
350 is_free,
351 status: tx.status.to_string(),
352 date,
353 }
354 .into_response())
355 }
356
357 /// Minimal direct purchase page; no navigation chrome, optimized for link-in-bio
358 /// and social media sharing. Shows item summary + guest checkout button.
359 #[tracing::instrument(skip_all, name = "content::buy_page")]
360 pub(super) async fn buy_page(
361 State(db): State<PgPool>,
362 State(config): State<Config>,
363 Path(item_id): Path<String>,
364 ) -> Result<impl IntoResponse> {
365 let id: ItemId = item_id.parse().map_err(|_| AppError::NotFound)?;
366
367 let db_item = db::items::get_item_by_id(&db, id)
368 .await?
369 .ok_or(AppError::NotFound)?;
370
371 if !db_item.is_public {
372 return Err(AppError::NotFound);
373 }
374
375 let db_project = db::projects::get_project_by_id(&db, db_item.project_id)
376 .await?
377 .ok_or(AppError::NotFound)?;
378
379 let db_user = db::users::get_user_by_id(&db, db_project.user_id)
380 .await?
381 .ok_or(AppError::NotFound)?;
382
383 let purchase_tags = db::tags::get_tags_for_item(&db, id).await?;
384 let item = Item::from_db_list(
385 &db_item,
386 &purchase_tags,
387 db_item.price_cents == 0,
388 false,
389 db_user.settlement_currency,
390 );
391
392 let suggested_price = crate::formatting::format_dollars_plain(db_item.price_cents);
393 let pwyw_min_dollars = crate::formatting::format_dollars_plain(pwyw_field_min_cents(
394 crate::pricing::for_item(&db_item).as_ref(),
395 db_user.settlement_currency,
396 ));
397
398 Ok(BuyPageTemplate {
399 item,
400 creator_username: db_user.username.to_string(),
401 currency_symbol: db_user.settlement_currency.symbol(),
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
410 /// What the paywall's amount box will accept: the `min` attribute, and the line
411 /// under it when `min` cannot say the whole rule.
412 ///
413 /// The domain is not always an interval. A project whose creator set no
414 /// minimum accepts a $0 claim *or* a real charge at the settlement currency's
415 /// floor and up, with a hole between them: 25c is neither free nor chargeable.
416 /// `min="0"` alone would wave that through to a server-side refusal after the
417 /// buyer has typed an amount, and `min="0.50"` alone would hide the free claim
418 /// the creator is offering. So the attribute keeps the low end and the note
419 /// carries the hole.
420 ///
421 /// A creator who did set a minimum has no hole: the floor is
422 /// `max(their minimum, the currency's)`, one interval, and the `min` attribute
423 /// states it without help.
424 pub(super) fn pwyw_field_bounds(
425 model: &dyn crate::pricing::PricingModel,
426 currency: crate::currency::SettlementCurrency,
427 ) -> (String, Option<String>) {
428 use crate::formatting::{format_dollars_plain, format_revenue};
429
430 let field_min = pwyw_field_min_cents(model, currency);
431 if field_min > 0 || model.checkout_type() != crate::pricing::CheckoutType::PayWhatYouWant {
432 return (format_dollars_plain(field_min), None);
433 }
434
435 (
436 format_dollars_plain(0),
437 Some(format!(
438 "Pay {}, or {} and up.",
439 format_revenue(0, currency),
440 format_revenue(
441 i64::from(model.chargeable_minimum_cents(currency)),
442 currency
443 )
444 )),
445 )
446 }
447
448 /// The `min` attribute for a pay-what-you-want amount box, in cents.
449 ///
450 /// Zero for a project or item whose creator set no minimum, because a $0 claim
451 /// is a real outcome there and the attribute is the only thing standing between
452 /// the buyer and it. Otherwise the chargeable floor, which is the creator's
453 /// minimum raised to what the settlement currency will settle.
454 pub(super) fn pwyw_field_min_cents(
455 model: &dyn crate::pricing::PricingModel,
456 currency: crate::currency::SettlementCurrency,
457 ) -> i32 {
458 if model.checkout_type() != crate::pricing::CheckoutType::PayWhatYouWant
459 || model.minimum_cents().unwrap_or(0) <= 0
460 {
461 return 0;
462 }
463 model.chargeable_minimum_cents(currency)
464 }
465
466 #[cfg(test)]
467 mod tests {
468 //! The paywall's amount box, whose bounds have to match what the charge
469 //! path will actually accept. A box that accepts an amount checkout then
470 //! refuses spends the buyer's typing and hands back an error that reads as
471 //! the creator's price being wrong.
472
473 use super::*;
474 use crate::currency::SettlementCurrency::{Gbp, Usd};
475 use crate::pricing::{PricingModel, PwywPricing};
476
477 fn bounds(
478 min_cents: Option<i32>,
479 currency: crate::currency::SettlementCurrency,
480 ) -> (String, Option<String>) {
481 pwyw_field_bounds(&PwywPricing { min_cents }, currency)
482 }
483
484 #[test]
485 fn a_sub_floor_creator_minimum_is_raised_to_what_stripe_settles() {
486 // 25c is a price no card network will move. The box says 50c.
487 assert_eq!(bounds(Some(25), Usd).0, "0.50");
488 // And the floor is the currency's, not a flat 50: GBP settles at 30p.
489 assert_eq!(bounds(Some(25), Gbp).0, "0.30");
490 }
491
492 #[test]
493 fn a_creator_minimum_above_the_floor_is_left_alone() {
494 assert_eq!(bounds(Some(999), Usd).0, "9.99");
495 assert_eq!(bounds(Some(999), Gbp).0, "9.99");
496 }
497
498 #[test]
499 fn a_stated_minimum_needs_no_note_because_min_says_it_all() {
500 assert_eq!(bounds(Some(999), Usd).1, None);
501 assert_eq!(bounds(Some(25), Usd).1, None);
502 }
503
504 #[test]
505 fn no_minimum_keeps_the_free_claim_and_names_the_hole() {
506 let (min, note) = bounds(None, Usd);
507 assert_eq!(min, "0.00", "a $0 claim must stay reachable");
508 assert_eq!(note.as_deref(), Some("Pay $0.00, or $0.50 and up."));
509 }
510
511 #[test]
512 fn the_note_is_denominated_in_the_creators_currency() {
513 let (_, note) = bounds(Some(0), Gbp);
514 assert_eq!(note.as_deref(), Some("Pay £0.00, or £0.30 and up."));
515 }
516
517 #[test]
518 fn a_non_pwyw_project_gets_no_bounds_at_all() {
519 // The box is not drawn for these, so the fields carry inert values
520 // rather than a floor that would be wrong if a template ever read it.
521 let fixed = crate::pricing::FixedPricing { price_cents: 1999 };
522 assert_eq!(pwyw_field_bounds(&fixed, Usd), ("0.00".to_string(), None));
523 }
524
525 #[test]
526 fn the_box_and_the_charge_path_agree_on_every_amount() {
527 // The property the whole fix exists for: the rule the buyer is shown
528 // and the rule the charge path enforces are the same rule. "Shown"
529 // means the attribute plus the note, because the free-claim hole
530 // cannot live in a `min`. Walked in cents either side of both floors.
531 for min_cents in [None, Some(0), Some(1), Some(25), Some(50), Some(120)] {
532 let model = PwywPricing { min_cents };
533 let (field_min, note) = pwyw_field_bounds(&model, Usd);
534 let field_min_cents = (field_min.parse::<f64>().unwrap() * 100.0).round() as i32;
535 let free_claim_offered = note.is_some();
536 for amount in 0..=200 {
537 let field_accepts =
538 amount >= field_min_cents && !(free_claim_offered && (1..50).contains(&amount));
539 let charge_accepts = model.validate_amount(amount, Usd).is_ok();
540 assert_eq!(
541 field_accepts, charge_accepts,
542 "min_cents {min_cents:?}, amount {amount}: box says {field_accepts}, charge path says {charge_accepts}"
543 );
544 }
545 }
546 }
547 }
548