Skip to main content

max / makenotwork

9.8 KB · 278 lines History Blame Raw
1 //! Public project page handler.
2
3 use axum::{
4 extract::{Path, State},
5 response::{IntoResponse, Response},
6 };
7 use tower_sessions::Session;
8
9 use crate::{
10 auth::{MaybeUserVerified, SessionUser},
11 db::{self, FollowTargetType, ItemId, ItemType, Slug},
12 error::{AppError, Result},
13 helpers::get_csrf_token,
14 pricing,
15 templates::*,
16 types::*,
17 AppState,
18 };
19
20 /// Render a public project page with its published items.
21 #[tracing::instrument(skip_all, name = "content::project_page", fields(%slug))]
22 pub(in crate::routes::pages::public) async fn project_page(
23 State(state): State<AppState>,
24 session: Session,
25 headers: axum::http::HeaderMap,
26 MaybeUserVerified(maybe_user): MaybeUserVerified,
27 Path(slug): Path<String>,
28 ) -> Result<Response> {
29 let csrf_token = get_csrf_token(&session).await;
30 let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
31 let db_project = db::projects::get_public_project_by_slug(&state.db, &slug)
32 .await?
33 .ok_or(AppError::NotFound)?;
34 let response = render_project_page(&state, &db_project, csrf_token, maybe_user).await?;
35 let ua = headers.get(axum::http::header::USER_AGENT)
36 .and_then(|v| v.to_str().ok())
37 .unwrap_or("");
38 if !super::is_bot(ua) {
39 super::track_view(&state, "project", *db_project.id);
40 }
41 Ok(response)
42 }
43
44 /// Shared project page renderer, used by both named routes and custom domain fallback.
45 #[tracing::instrument(
46 skip_all,
47 name = "content::render_project_page",
48 fields(project_id = %db_project.id, project_slug = %db_project.slug, viewer_id = ?maybe_user.as_ref().map(|u| u.id))
49 )]
50 pub(crate) async fn render_project_page(
51 state: &AppState,
52 db_project: &db::DbProject,
53 csrf_token: Option<String>,
54 maybe_user: Option<SessionUser>,
55 ) -> Result<Response> {
56 let db_user = db::users::get_user_by_id(&state.db, db_project.user_id)
57 .await?
58 .ok_or(AppError::NotFound)?;
59
60 // Project-level paywall gate
61 let project_pricing = pricing::for_project(db_project);
62 if !project_pricing.is_free() {
63 let project_ctx = pricing::build_project_access_context(
64 &state.db,
65 maybe_user.as_ref().map(|u| u.id),
66 db_project.id,
67 db_project.user_id,
68 )
69 .await?;
70 if !project_pricing.can_access(&project_ctx) {
71 tracing::warn!(
72 project_id = %db_project.id,
73 project_slug = %db_project.slug,
74 creator_user_id = %db_project.user_id,
75 viewer_user_id = ?maybe_user.as_ref().map(|u| u.id),
76 is_creator = project_ctx.is_creator,
77 has_purchased = project_ctx.has_purchased,
78 has_active_subscription = project_ctx.has_active_subscription(),
79 pricing_kind = ?project_pricing.kind(),
80 "project paywall gate: showing paywall"
81 );
82 let db_tiers =
83 db::subscriptions::get_active_tiers_by_project(&state.db, db_project.id).await?;
84 let subscription_tiers: Vec<SubscriptionTier> =
85 db_tiers.iter().map(SubscriptionTier::from).collect();
86 let project = Project::from_db(db_project, 0);
87 return Ok(ProjectPaywallTemplate {
88 csrf_token,
89 session_user: maybe_user,
90 project,
91 creator_username: db_user.username.to_string(),
92 price_display: project_pricing.price_display(),
93 checkout_type: project_pricing.checkout_type(),
94 subscription_tiers,
95 host_url: state.config.host_url.clone(),
96 }
97 .into_response());
98 }
99 }
100
101 let db_items = db::items::get_public_items_by_project(&state.db, db_project.id).await?;
102
103 let is_creator = maybe_user
104 .as_ref()
105 .map(|u| u.id == db_project.user_id)
106 .unwrap_or(false);
107
108 let purchased_item_ids: std::collections::HashSet<ItemId> = if let Some(ref user) = maybe_user
109 {
110 db::transactions::get_user_purchased_item_ids(&state.db, user.id)
111 .await?
112 .into_iter()
113 .collect()
114 } else {
115 std::collections::HashSet::new()
116 };
117
118 // Per-item subscription proofs for this user, so each item's AccessContext
119 // gets its own gate witness rather than a bare membership bool.
120 let subscribed_gates = if let Some(ref user) = maybe_user {
121 db::subscriptions::SubscriptionGate::subscribed_item_gates(&state.db, user.id).await?
122 } else {
123 std::collections::HashMap::new()
124 };
125
126 let has_subscription = if let Some(ref user) = maybe_user {
127 db::subscriptions::has_access(&state.db, user.id, db::subscriptions::SubscriptionScope::Project(db_project.id))
128 .await?
129 } else {
130 false
131 };
132
133 let project = Project::from_db(db_project, db_items.len() as u32);
134
135 let item_ids: Vec<ItemId> = db_items.iter().map(|i| i.id).collect();
136 let tags_map = db::tags::get_tags_for_items(&state.db, &item_ids).await?;
137
138 // Batch child-counts for all bundles on the page in one query (was one
139 // COUNT per bundle inside the loop below).
140 let bundle_ids: Vec<ItemId> = db_items
141 .iter()
142 .filter(|i| i.item_type == ItemType::Bundle)
143 .map(|i| i.id)
144 .collect();
145 let bundle_counts = if bundle_ids.is_empty() {
146 std::collections::HashMap::new()
147 } else {
148 db::bundles::get_bundle_item_counts(&state.db, &bundle_ids).await?
149 };
150
151 let mut items: Vec<Item> = Vec::with_capacity(db_items.len());
152 for i in &db_items {
153 let item_pricing = pricing::for_item(i);
154 let ctx = pricing::AccessContext {
155 is_creator,
156 has_purchased: purchased_item_ids.contains(&i.id),
157 subscription: subscribed_gates.get(&i.id).copied(),
158 };
159 let can_access = item_pricing.can_access(&ctx);
160 let is_free = item_pricing.is_free();
161 let item_tags = tags_map.get(&i.id).map(|v| v.as_slice()).unwrap_or(&[]);
162 let mut item = Item::from_db_list(i, item_tags, is_free, can_access);
163 if i.item_type == ItemType::Bundle {
164 item.bundle_item_count = bundle_counts.get(&i.id).copied().unwrap_or(0);
165 }
166 items.push(item);
167 }
168
169 let follower_count = db::follows::get_follower_count(
170 &state.db,
171 FollowTargetType::Project,
172 db_project.id.into(),
173 )
174 .await?;
175 let is_following = if let Some(ref viewer) = maybe_user {
176 db::follows::is_following(
177 &state.db,
178 viewer.id,
179 FollowTargetType::Project,
180 db_project.id.into(),
181 )
182 .await?
183 } else {
184 false
185 };
186
187 let db_tiers =
188 db::subscriptions::get_active_tiers_by_project(&state.db, db_project.id).await?;
189 let subscription_tiers: Vec<SubscriptionTier> =
190 db_tiers.iter().map(SubscriptionTier::from).collect();
191
192 let git_repos = if state.config.git_repos_path.is_some() {
193 let linked = db::git_repos::get_repos_by_project(&state.db, db_project.id)
194 .await
195 .unwrap_or_else(|e| {
196 tracing::warn!(project_id = %db_project.id, error = %e, "linked git repos lookup failed; omitting repo links");
197 Default::default()
198 });
199 linked
200 .into_iter()
201 .map(|r| {
202 let url = format!("/git/{}/{}", db_user.username, r.name);
203 (r.name, url)
204 })
205 .collect()
206 } else {
207 Vec::new()
208 };
209
210 let has_blog_posts = db::blog_posts::has_published_posts(&state.db, db_project.id).await?;
211
212 let community_url = if db_project.mt_community_id.is_some() {
213 state
214 .config
215 .mt_base_url
216 .as_ref()
217 .map(|base| format!("{}/p/{}", base, db_project.slug))
218 } else {
219 None
220 };
221
222 let is_owner = maybe_user
223 .as_ref()
224 .is_some_and(|u| u.id == db_project.user_id);
225
226 let db_sections = db::project_sections::list_by_project(&state.db, db_project.id).await?;
227 let cdn_base = state.config.cdn_base_url.as_deref().unwrap_or("https://cdn.makenot.work");
228 let sections: Vec<ProjectSection> = db_sections
229 .iter()
230 .map(|s| ProjectSection::from_db(s, db_project.user_id, cdn_base))
231 .collect();
232
233 // Ordered gallery → carousel frames (additive to the cover image). Alt is
234 // creator-optional; fall back to a title-based description (CarouselFrame::new
235 // debug-asserts non-empty alt, so build the struct directly).
236 let gallery = db::gallery_images::list_for_project(&state.db, db_project.id)
237 .await
238 .unwrap_or_else(|e| {
239 tracing::warn!(project_id = %db_project.id, error = %e, "gallery image lookup failed; rendering without carousel");
240 Default::default()
241 })
242 .into_iter()
243 .map(|g| crate::templates::CarouselFrame {
244 image: g.image_url,
245 alt: if g.alt.trim().is_empty() {
246 format!("{} gallery image", db_project.title)
247 } else {
248 g.alt
249 },
250 caption: None,
251 })
252 .collect();
253
254 Ok(ProjectTemplate {
255 csrf_token,
256 session_user: maybe_user,
257 project,
258 creator_username: db_user.username.to_string(),
259 items,
260 project_id: db_project.id.to_string(),
261 is_following,
262 follower_count,
263 subscription_tiers,
264 has_subscription,
265 host_url: state.config.host_url.clone(),
266 git_repos,
267 has_blog_posts,
268 community_url,
269 tips_enabled: db_user.tips_enabled && db_user.stripe_charges_enabled,
270 creator_id: db_user.id.to_string(),
271 tip_project_id: Some(db_project.id.to_string()),
272 is_owner,
273 sections,
274 gallery,
275 }
276 .into_response())
277 }
278