Skip to main content

max / makenotwork

8.7 KB · 258 lines History Blame Raw
1 //! Public blog pages.
2
3 use axum::{
4 extract::{Path, State},
5 response::IntoResponse,
6 routing::get,
7 };
8 use sqlx::PgPool;
9 use tower_sessions::Session;
10
11 use crate::{
12 AppState, Integrations,
13 auth::MaybeUserUnverified,
14 config::Config,
15 constants,
16 csrf::CsrfRouter,
17 db::{self, Slug},
18 error::{AppError, Result},
19 helpers::{fetch_discussion_info, get_csrf_token, get_initials},
20 templates::{BlogPostTemplate, ProjectBlogTemplate},
21 types::{BlogPostSummary, Project, json_escape},
22 };
23
24 /// Register blog page routes. All GET-only; returned as a `CsrfRouter` so the
25 /// page-tree aggregator only ever composes posture-declared routers.
26 pub(super) fn blog_routes() -> CsrfRouter<AppState> {
27 CsrfRouter::new()
28 .route_get("/p/{slug}/blog", get(project_blog_page))
29 .route_get("/p/{slug}/blog/{post_slug}", get(blog_post_page))
30 .route_get("/changelog", get(changelog_index))
31 .route_get("/changelog/{post_slug}", get(changelog_post))
32 }
33
34 /// Public blog index page for a project.
35 #[tracing::instrument(skip_all, name = "blog_pages::project_blog_page")]
36 async fn project_blog_page(
37 State(db): State<PgPool>,
38 session: Session,
39 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
40 Path(slug): Path<String>,
41 ) -> Result<impl IntoResponse> {
42 let csrf_token = get_csrf_token(&session).await;
43
44 let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
45 let db_project = db::projects::get_public_project_by_slug(&db, &slug)
46 .await?
47 .ok_or(AppError::NotFound)?;
48
49 let db_user = db::users::get_user_by_id(&db, db_project.user_id)
50 .await?
51 .ok_or(AppError::NotFound)?;
52
53 let db_posts = db::blog_posts::get_published_blog_posts_by_project(&db, db_project.id).await?;
54
55 let project = Project::from_db(&db_project, 0);
56
57 let posts: Vec<BlogPostSummary> = db_posts.iter().map(BlogPostSummary::from).collect();
58
59 Ok(ProjectBlogTemplate {
60 csrf_token,
61 session_user: maybe_user,
62 project,
63 creator_username: db_user.username.to_string(),
64 project_slug: db_project.slug.to_string(),
65 posts,
66 })
67 }
68
69 /// Public blog post reader page.
70 #[tracing::instrument(skip_all, name = "blog_pages::blog_post_page")]
71 async fn blog_post_page(
72 State(db): State<PgPool>,
73 State(config): State<Config>,
74 State(integrations): State<Integrations>,
75 session: Session,
76 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
77 Path((slug, post_slug)): Path<(String, String)>,
78 ) -> Result<impl IntoResponse> {
79 let csrf_token = get_csrf_token(&session).await;
80
81 let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
82 let post_slug = Slug::new(&post_slug).map_err(|_| AppError::NotFound)?;
83 let db_project = db::projects::get_public_project_by_slug(&db, &slug)
84 .await?
85 .ok_or(AppError::NotFound)?;
86
87 let db_user = db::users::get_user_by_id(&db, db_project.user_id)
88 .await?
89 .ok_or(AppError::NotFound)?;
90
91 let db_post = db::blog_posts::get_blog_post_by_slug(&db, db_project.id, &post_slug)
92 .await?
93 .ok_or(AppError::NotFound)?;
94
95 // Only published posts are visible publicly (unless owner)
96 let is_owner = maybe_user
97 .as_ref()
98 .is_some_and(|u| u.id == db_project.user_id);
99 if db_post.published_at.is_none() && !is_owner {
100 return Err(AppError::NotFound);
101 }
102
103 let avatar_initials =
104 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
105
106 let title_json = json_escape(&db_post.title);
107 let project_title_json = json_escape(&db_project.title);
108
109 let project_slug_str = db_project.slug.to_string();
110 let (discussion_url, discussion_count) = fetch_discussion_info(
111 &integrations,
112 &config,
113 db_post.mt_thread_id,
114 &project_slug_str,
115 "blog",
116 )
117 .await;
118
119 Ok(BlogPostTemplate {
120 csrf_token,
121 session_user: maybe_user,
122 title: db_post.title,
123 title_json,
124 body_html: db_post.body_html,
125 published_at: db_post
126 .published_at
127 .map(|d| d.format("%b %d, %Y").to_string())
128 .unwrap_or_default(),
129 creator_username: db_user.username.to_string(),
130 creator_display_name: db_user.display_name.clone(),
131 creator_avatar_initials: avatar_initials,
132 project_title: db_project.title,
133 project_title_json,
134 project_slug: project_slug_str,
135 post_slug: post_slug.to_string(),
136 host_url: config.host_url.clone(),
137 project_cover_image_url: db_project.cover_image_url,
138 discussion_url,
139 discussion_count,
140 })
141 }
142
143 // /changelog alias routes
144
145 /// Platform changelog index (alias for the "changelog" project blog).
146 #[tracing::instrument(skip_all, name = "blog_pages::changelog_index")]
147 async fn changelog_index(
148 State(db): State<PgPool>,
149 session: Session,
150 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
151 ) -> Result<impl IntoResponse> {
152 let csrf_token = get_csrf_token(&session).await;
153
154 // `from_trusted` is safe here because CHANGELOG_PROJECT_SLUG is a
155 // compile-time constant (`&'static str`), not user input. The audit
156 // flagged this site historically; the constant origin is the
157 // justification.
158 let slug = Slug::from_trusted(constants::CHANGELOG_PROJECT_SLUG.to_owned());
159 let db_project = db::projects::get_public_project_by_slug(&db, &slug)
160 .await?
161 .ok_or(AppError::NotFound)?;
162
163 let db_user = db::users::get_user_by_id(&db, db_project.user_id)
164 .await?
165 .ok_or(AppError::NotFound)?;
166
167 let db_posts = db::blog_posts::get_published_blog_posts_by_project(&db, db_project.id).await?;
168
169 let project = Project::from_db(&db_project, 0);
170 let posts: Vec<BlogPostSummary> = db_posts.iter().map(BlogPostSummary::from).collect();
171
172 Ok(ProjectBlogTemplate {
173 csrf_token,
174 session_user: maybe_user,
175 project,
176 creator_username: db_user.username.to_string(),
177 project_slug: db_project.slug.to_string(),
178 posts,
179 })
180 }
181
182 /// Platform changelog post (alias for a "changelog" project blog post).
183 #[tracing::instrument(skip_all, name = "blog_pages::changelog_post")]
184 async fn changelog_post(
185 State(db): State<PgPool>,
186 State(config): State<Config>,
187 State(integrations): State<Integrations>,
188 session: Session,
189 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
190 Path(post_slug): Path<String>,
191 ) -> Result<impl IntoResponse> {
192 let csrf_token = get_csrf_token(&session).await;
193
194 // `from_trusted` is safe here because CHANGELOG_PROJECT_SLUG is a
195 // compile-time constant (`&'static str`), not user input. The audit
196 // flagged this site historically; the constant origin is the
197 // justification.
198 let slug = Slug::from_trusted(constants::CHANGELOG_PROJECT_SLUG.to_owned());
199 let post_slug = Slug::new(&post_slug).map_err(|_| AppError::NotFound)?;
200 let db_project = db::projects::get_public_project_by_slug(&db, &slug)
201 .await?
202 .ok_or(AppError::NotFound)?;
203
204 let db_user = db::users::get_user_by_id(&db, db_project.user_id)
205 .await?
206 .ok_or(AppError::NotFound)?;
207
208 let db_post = db::blog_posts::get_blog_post_by_slug(&db, db_project.id, &post_slug)
209 .await?
210 .ok_or(AppError::NotFound)?;
211
212 let is_owner = maybe_user
213 .as_ref()
214 .is_some_and(|u| u.id == db_project.user_id);
215 if db_post.published_at.is_none() && !is_owner {
216 return Err(AppError::NotFound);
217 }
218
219 let avatar_initials =
220 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
221
222 let title_json = json_escape(&db_post.title);
223 let project_title_json = json_escape(&db_project.title);
224
225 let project_slug_str = db_project.slug.to_string();
226 let (discussion_url, discussion_count) = fetch_discussion_info(
227 &integrations,
228 &config,
229 db_post.mt_thread_id,
230 &project_slug_str,
231 "blog",
232 )
233 .await;
234
235 Ok(BlogPostTemplate {
236 csrf_token,
237 session_user: maybe_user,
238 title: db_post.title,
239 title_json,
240 body_html: db_post.body_html,
241 published_at: db_post
242 .published_at
243 .map(|d| d.format("%b %d, %Y").to_string())
244 .unwrap_or_default(),
245 creator_username: db_user.username.to_string(),
246 creator_display_name: db_user.display_name.clone(),
247 creator_avatar_initials: avatar_initials,
248 project_title: db_project.title,
249 project_title_json,
250 project_slug: project_slug_str,
251 post_slug: post_slug.to_string(),
252 host_url: config.host_url.clone(),
253 project_cover_image_url: db_project.cover_image_url,
254 discussion_url,
255 discussion_count,
256 })
257 }
258