Skip to main content

max / makenotwork

12.2 KB · 328 lines History Blame Raw
1 //! Public project page handler.
2
3 use axum::{
4 extract::{Path, State},
5 response::{IntoResponse, Response},
6 };
7 use sqlx::PgPool;
8 use tower_sessions::Session;
9
10 use crate::{
11 auth::{MaybeUserVerified, SessionUser},
12 config::Config,
13 db::{self, FollowTargetType, ItemId, ItemType, Slug},
14 error::{AppError, Result},
15 helpers::get_csrf_token,
16 pricing,
17 templates::ProjectPaywallTemplate,
18 types::{Item, Project, ProjectSection, SubscriptionTier},
19 };
20
21 /// Render a public project page with its published items.
22 #[tracing::instrument(skip_all, name = "content::project_page", fields(%slug))]
23 pub(in crate::routes::pages::public) async fn project_page(
24 State(db): State<PgPool>,
25 State(config): State<Config>,
26 State(page_view_tx): State<crate::db::page_views::PageViewTx>,
27 session: Session,
28 headers: axum::http::HeaderMap,
29 MaybeUserVerified(maybe_user): MaybeUserVerified,
30 Path(slug): Path<String>,
31 ) -> Result<Response> {
32 let csrf_token = get_csrf_token(&session).await;
33 let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
34 let db_project = db::projects::get_public_project_by_slug(&db, &slug)
35 .await?
36 .ok_or(AppError::NotFound)?;
37 let response = render_project_page(&db, &config, &db_project, csrf_token, maybe_user).await?;
38 let ua = headers
39 .get(axum::http::header::USER_AGENT)
40 .and_then(|v| v.to_str().ok())
41 .unwrap_or("");
42 if !super::is_bot(ua) {
43 super::track_view(&page_view_tx, "project", *db_project.id);
44 }
45 Ok(response)
46 }
47
48 /// Shared project page renderer, used by both named routes and custom domain fallback.
49 #[tracing::instrument(
50 skip_all,
51 name = "content::render_project_page",
52 fields(project_id = %db_project.id, project_slug = %db_project.slug, viewer_id = ?maybe_user.as_ref().map(|u| u.id))
53 )]
54 pub(crate) async fn render_project_page(
55 db: &PgPool,
56 config: &Config,
57 db_project: &db::DbProject,
58 csrf_token: Option<String>,
59 maybe_user: Option<SessionUser>,
60 ) -> Result<Response> {
61 let db_user = db::users::get_user_by_id(db, db_project.user_id)
62 .await?
63 .ok_or(AppError::NotFound)?;
64
65 // Project-level paywall gate
66 let project_pricing = pricing::for_project(db_project);
67 if !project_pricing.is_free() {
68 let project_ctx = pricing::build_project_access_context(
69 db,
70 maybe_user.as_ref().map(|u| u.id),
71 db_project.id,
72 db_project.user_id,
73 )
74 .await?;
75 if !project_pricing.can_access(&project_ctx) {
76 tracing::warn!(
77 project_id = %db_project.id,
78 project_slug = %db_project.slug,
79 creator_user_id = %db_project.user_id,
80 viewer_user_id = ?maybe_user.as_ref().map(|u| u.id),
81 is_creator = project_ctx.is_creator,
82 has_purchased = project_ctx.has_purchased,
83 has_active_subscription = project_ctx.has_active_subscription(),
84 pricing_kind = ?project_pricing.kind(),
85 "project paywall gate: showing paywall"
86 );
87 let db_tiers =
88 db::subscriptions::get_active_tiers_by_project(db, db_project.id).await?;
89 let subscription_tiers: Vec<SubscriptionTier> = db_tiers
90 .iter()
91 .map(|t| SubscriptionTier::from_db(t, db_user.settlement_currency))
92 .collect();
93 // Show how much is behind the paywall. A project with a lot in it
94 // sells better than a bare price, and the count is not the thing
95 // being sold: the items themselves stay gated. Reads the
96 // denormalized column, so it matches the discover card exactly and
97 // costs one indexed lookup.
98 let item_count = db::projects::get_active_item_count(db, db_project.id)
99 .await
100 .unwrap_or_else(|e| {
101 tracing::warn!(
102 project_id = %db_project.id,
103 error = %e,
104 "paywall item count lookup failed; rendering without a count"
105 );
106 0
107 });
108 let project = Project::from_db(db_project, item_count.max(0) as u32);
109 let (pwyw_field_min, pwyw_note) =
110 super::pwyw_field_bounds(project_pricing.as_ref(), db_user.settlement_currency);
111 return Ok(ProjectPaywallTemplate {
112 csrf_token,
113 session_user: maybe_user,
114 project,
115 creator_username: db_user.username.to_string(),
116 price_display: project_pricing.price_display(db_user.settlement_currency),
117 checkout_type: project_pricing.checkout_type(),
118 pwyw_min_dollars: pwyw_field_min,
119 pwyw_min_note: pwyw_note,
120 pwyw_currency_code: db_user.settlement_currency.code_upper(),
121 subscription_tiers,
122 host_url: config.host_url.clone(),
123 }
124 .into_response());
125 }
126 }
127
128 let db_items = db::items::get_public_items_by_project(db, db_project.id).await?;
129
130 let is_creator = maybe_user
131 .as_ref()
132 .is_some_and(|u| u.id == db_project.user_id);
133
134 // Scope the purchase lookup to the items actually rendered on this page
135 // rather than fetching the viewer's entire purchase history (fuzz 2026-07-06
136 // F6). `purchased_subset` is a single `item_id = ANY($2)` roundtrip.
137 let purchased_item_ids: std::collections::HashSet<ItemId> = if let Some(ref user) = maybe_user {
138 let item_ids: Vec<ItemId> = db_items.iter().map(|i| i.id).collect();
139 db::transactions::purchased_subset(db, user.id, &item_ids).await?
140 } else {
141 std::collections::HashSet::new()
142 };
143
144 // Per-item subscription proofs for this user, so each item's AccessContext
145 // gets its own gate witness rather than a bare membership bool.
146 #[allow(
147 clippy::zero_sized_map_values,
148 reason = "SubscriptionGate is a deliberate zero-sized capability witness, not data"
149 )]
150 let subscribed_gates = if let Some(ref user) = maybe_user {
151 db::subscriptions::SubscriptionGate::subscribed_item_gates(db, user.id).await?
152 } else {
153 std::collections::HashMap::new()
154 };
155
156 let has_subscription = if let Some(ref user) = maybe_user {
157 db::subscriptions::has_access(
158 db,
159 user.id,
160 db::subscriptions::SubscriptionScope::Project(db_project.id),
161 )
162 .await?
163 } else {
164 false
165 };
166
167 let project = Project::from_db(db_project, db_items.len() as u32);
168
169 let item_ids: Vec<ItemId> = db_items.iter().map(|i| i.id).collect();
170 let tags_map = db::tags::get_tags_for_items(db, &item_ids).await?;
171
172 // Batch child-counts for all bundles on the page in one query (was one
173 // COUNT per bundle inside the loop below).
174 let bundle_ids: Vec<ItemId> = db_items
175 .iter()
176 .filter(|i| i.item_type == ItemType::Bundle)
177 .map(|i| i.id)
178 .collect();
179 let bundle_counts = if bundle_ids.is_empty() {
180 std::collections::HashMap::new()
181 } else {
182 db::bundles::get_bundle_item_counts(db, &bundle_ids).await?
183 };
184
185 let mut items: Vec<Item> = Vec::with_capacity(db_items.len());
186 for i in &db_items {
187 let item_pricing = pricing::for_item(i);
188 let ctx = pricing::AccessContext {
189 is_creator,
190 has_purchased: purchased_item_ids.contains(&i.id),
191 subscription: subscribed_gates.get(&i.id).copied(),
192 };
193 let can_access = item_pricing.can_access(&ctx);
194 let is_free = item_pricing.is_free();
195 let item_tags = tags_map.get(&i.id).map_or(&[][..], std::vec::Vec::as_slice);
196 let mut item = Item::from_db_list(
197 i,
198 item_tags,
199 is_free,
200 can_access,
201 db_user.settlement_currency,
202 );
203 if i.item_type == ItemType::Bundle {
204 item.bundle_item_count = bundle_counts.get(&i.id).copied().unwrap_or(0);
205 }
206 items.push(item);
207 }
208
209 let follower_count =
210 db::follows::get_follower_count(db, FollowTargetType::Project, db_project.id.into())
211 .await?;
212 let is_following = if let Some(ref viewer) = maybe_user {
213 db::follows::is_following(
214 db,
215 viewer.id,
216 FollowTargetType::Project,
217 db_project.id.into(),
218 )
219 .await?
220 } else {
221 false
222 };
223
224 let db_tiers = db::subscriptions::get_active_tiers_by_project(db, db_project.id).await?;
225 let subscription_tiers: Vec<SubscriptionTier> = db_tiers
226 .iter()
227 .map(|t| SubscriptionTier::from_db(t, db_user.settlement_currency))
228 .collect();
229
230 let git_repos = if config.build.git_repos_path.is_some() {
231 let linked = db::git_repos::get_repos_by_project(db, db_project.id)
232 .await
233 .unwrap_or_else(|e| {
234 tracing::warn!(project_id = %db_project.id, error = %e, "linked git repos lookup failed; omitting repo links");
235 Vec::default()
236 });
237 linked
238 .into_iter()
239 .map(|r| {
240 let url = format!("/git/{}/{}", db_user.username, r.name);
241 (r.name, url)
242 })
243 .collect()
244 } else {
245 Vec::new()
246 };
247
248 let has_blog_posts = db::blog_posts::has_published_posts(db, db_project.id).await?;
249
250 let community_url = if db_project.mt_community_id.is_some() {
251 config
252 .integrations
253 .mt_base_url
254 .as_ref()
255 .map(|base| format!("{}/p/{}", base, db_project.slug))
256 } else {
257 None
258 };
259
260 let is_owner = maybe_user
261 .as_ref()
262 .is_some_and(|u| u.id == db_project.user_id);
263
264 let db_sections = db::project_sections::list_by_project(db, db_project.id).await?;
265 let cdn_base = config.cdn_base_url.as_str();
266 let sections: Vec<ProjectSection> = db_sections
267 .iter()
268 .map(|s| ProjectSection::from_db(s, db_project.user_id, cdn_base))
269 .collect();
270
271 // Ordered gallery → carousel frames (additive to the cover image). Alt is
272 // creator-optional; fall back to a title-based description (CarouselFrame::new
273 // debug-asserts non-empty alt, so build the struct directly).
274 let gallery: Vec<crate::templates::CarouselFrame> =
275 db::gallery_images::list_for_project(db, db_project.id)
276 .await
277 .unwrap_or_else(|e| {
278 tracing::warn!(project_id = %db_project.id, error = %e, "gallery image lookup failed; rendering without carousel");
279 Vec::default()
280 })
281 .into_iter()
282 .map(|g| crate::templates::CarouselFrame {
283 // A creator upload: `gallery_images` records a byte count and never
284 // recorded a size, so there is nothing to reserve with. Filed as
285 // the reason galleries still shift while the landing page does not.
286 intrinsic: None,
287 image: g.image_url,
288 alt: if g.alt.trim().is_empty() {
289 format!("{} gallery image", db_project.title)
290 } else {
291 g.alt
292 },
293 caption: None,
294 })
295 .collect();
296
297 let project_id = db_project.id.to_string();
298 let creator_id = db_user.id.to_string();
299 let creator_username = db_user.username.to_string();
300 let store = crate::quasi::project::Store {
301 project: &project,
302 project_id: &project_id,
303 creator_username: &creator_username,
304 creator_id: &creator_id,
305 host_url: &config.host_url,
306 items: &items,
307 sections: &sections,
308 gallery: &gallery,
309 tiers: &subscription_tiers,
310 git_repos: &git_repos,
311 community_url: community_url.as_deref(),
312 follower_count,
313 is_following,
314 has_subscription,
315 is_owner,
316 signed_in: maybe_user.is_some(),
317 has_blog_posts,
318 tips_enabled: db_user.tips_enabled && db_user.stripe_charges_enabled,
319 };
320 Ok(axum::response::Html(crate::quasi::project::document(
321 maybe_user.as_ref(),
322 csrf_token.as_deref(),
323 &store,
324 crate::theming::theme_css(db_project.theme_id.as_deref()),
325 ))
326 .into_response())
327 }
328