Skip to main content

max / makenotwork

8.0 KB · 236 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")]
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 pub(crate) async fn render_project_page(
46 state: &AppState,
47 db_project: &db::DbProject,
48 csrf_token: Option<String>,
49 maybe_user: Option<SessionUser>,
50 ) -> Result<Response> {
51 let db_user = db::users::get_user_by_id(&state.db, db_project.user_id)
52 .await?
53 .ok_or(AppError::NotFound)?;
54
55 // Project-level paywall gate
56 let project_pricing = pricing::for_project(db_project);
57 if !project_pricing.is_free() {
58 let project_ctx = pricing::build_project_access_context(
59 &state.db,
60 maybe_user.as_ref().map(|u| u.id),
61 db_project.id,
62 db_project.user_id,
63 )
64 .await?;
65 if !project_pricing.can_access(&project_ctx) {
66 tracing::warn!(
67 project_id = %db_project.id,
68 project_slug = %db_project.slug,
69 creator_user_id = %db_project.user_id,
70 viewer_user_id = ?maybe_user.as_ref().map(|u| u.id),
71 is_creator = project_ctx.is_creator,
72 has_purchased = project_ctx.has_purchased,
73 has_active_subscription = project_ctx.has_active_subscription,
74 pricing_kind = ?project_pricing.kind(),
75 "project paywall gate: showing paywall"
76 );
77 let db_tiers =
78 db::subscriptions::get_active_tiers_by_project(&state.db, db_project.id).await?;
79 let subscription_tiers: Vec<SubscriptionTier> =
80 db_tiers.iter().map(SubscriptionTier::from).collect();
81 let project = Project::from_db(db_project, 0);
82 return Ok(ProjectPaywallTemplate {
83 csrf_token,
84 session_user: maybe_user,
85 project,
86 creator_username: db_user.username.to_string(),
87 price_display: project_pricing.price_display(),
88 checkout_type: project_pricing.checkout_type(),
89 subscription_tiers,
90 host_url: state.config.host_url.clone(),
91 }
92 .into_response());
93 }
94 }
95
96 let db_items = db::items::get_public_items_by_project(&state.db, db_project.id).await?;
97
98 let is_creator = maybe_user
99 .as_ref()
100 .map(|u| u.id == db_project.user_id)
101 .unwrap_or(false);
102
103 let purchased_item_ids: std::collections::HashSet<ItemId> = if let Some(ref user) = maybe_user
104 {
105 db::transactions::get_user_purchased_item_ids(&state.db, user.id)
106 .await?
107 .into_iter()
108 .collect()
109 } else {
110 std::collections::HashSet::new()
111 };
112
113 let subscribed_item_ids: std::collections::HashSet<ItemId> = if let Some(ref user) = maybe_user
114 {
115 db::subscriptions::get_user_subscribed_item_ids(&state.db, user.id)
116 .await?
117 .into_iter()
118 .collect()
119 } else {
120 std::collections::HashSet::new()
121 };
122
123 let has_subscription = if let Some(ref user) = maybe_user {
124 db::subscriptions::has_active_subscription_to_project(&state.db, user.id, db_project.id)
125 .await?
126 } else {
127 false
128 };
129
130 let project = Project::from_db(db_project, db_items.len() as u32);
131
132 let item_ids: Vec<ItemId> = db_items.iter().map(|i| i.id).collect();
133 let tags_map = db::tags::get_tags_for_items(&state.db, &item_ids).await?;
134 let mut items: Vec<Item> = Vec::with_capacity(db_items.len());
135 for i in &db_items {
136 let item_pricing = pricing::for_item(i);
137 let ctx = pricing::AccessContext {
138 is_creator,
139 has_purchased: purchased_item_ids.contains(&i.id),
140 has_active_subscription: subscribed_item_ids.contains(&i.id),
141 };
142 let can_access = item_pricing.can_access(&ctx);
143 let is_free = item_pricing.is_free();
144 let item_tags = tags_map.get(&i.id).map(|v| v.as_slice()).unwrap_or(&[]);
145 let mut item = Item::from_db_list(i, item_tags, is_free, can_access);
146 if i.item_type == ItemType::Bundle {
147 item.bundle_item_count = db::bundles::get_bundle_item_count(&state.db, i.id).await?;
148 }
149 items.push(item);
150 }
151
152 let follower_count = db::follows::get_follower_count(
153 &state.db,
154 FollowTargetType::Project,
155 db_project.id.into(),
156 )
157 .await?;
158 let is_following = if let Some(ref viewer) = maybe_user {
159 db::follows::is_following(
160 &state.db,
161 viewer.id,
162 FollowTargetType::Project,
163 db_project.id.into(),
164 )
165 .await?
166 } else {
167 false
168 };
169
170 let db_tiers =
171 db::subscriptions::get_active_tiers_by_project(&state.db, db_project.id).await?;
172 let subscription_tiers: Vec<SubscriptionTier> =
173 db_tiers.iter().map(SubscriptionTier::from).collect();
174
175 let git_repos = if state.config.git_repos_path.is_some() {
176 let linked = db::git_repos::get_repos_by_project(&state.db, db_project.id)
177 .await
178 .unwrap_or_default();
179 linked
180 .into_iter()
181 .map(|r| {
182 let url = format!("/git/{}/{}", db_user.username, r.name);
183 (r.name, url)
184 })
185 .collect()
186 } else {
187 Vec::new()
188 };
189
190 let has_blog_posts = db::blog_posts::has_published_posts(&state.db, db_project.id).await?;
191
192 let community_url = if db_project.mt_community_id.is_some() {
193 state
194 .config
195 .mt_base_url
196 .as_ref()
197 .map(|base| format!("{}/p/{}", base, db_project.slug))
198 } else {
199 None
200 };
201
202 let is_owner = maybe_user
203 .as_ref()
204 .is_some_and(|u| u.id == db_project.user_id);
205
206 let db_sections = db::project_sections::list_by_project(&state.db, db_project.id).await?;
207 let cdn_base = state.config.cdn_base_url.as_deref().unwrap_or("https://cdn.makenot.work");
208 let sections: Vec<ProjectSection> = db_sections
209 .iter()
210 .map(|s| ProjectSection::from_db(s, db_project.user_id, cdn_base))
211 .collect();
212
213 Ok(ProjectTemplate {
214 csrf_token,
215 session_user: maybe_user,
216 project,
217 creator_username: db_user.username.to_string(),
218 items,
219 project_id: db_project.id.to_string(),
220 is_following,
221 follower_count,
222 subscription_tiers,
223 has_subscription,
224 host_url: state.config.host_url.clone(),
225 git_repos,
226 has_blog_posts,
227 community_url,
228 tips_enabled: db_user.tips_enabled && db_user.stripe_charges_enabled,
229 creator_id: db_user.id.to_string(),
230 tip_project_id: Some(db_project.id.to_string()),
231 is_owner,
232 sections,
233 }
234 .into_response())
235 }
236