Skip to main content

max / makenotwork

9.0 KB · 266 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,
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 let screen = crate::quasi::project_blog::screen(
60 &project.title,
61 db_project.slug.as_ref(),
62 db_user.username.as_ref(),
63 &posts,
64 );
65 Ok(axum::response::Html(crate::quasi::project_blog::document(
66 maybe_user.as_ref(),
67 csrf_token.as_deref(),
68 &screen,
69 )))
70 }
71
72 /// Public blog post reader page.
73 #[tracing::instrument(skip_all, name = "blog_pages::blog_post_page")]
74 async fn blog_post_page(
75 State(db): State<PgPool>,
76 State(config): State<Config>,
77 State(integrations): State<Integrations>,
78 session: Session,
79 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
80 Path((slug, post_slug)): Path<(String, String)>,
81 ) -> Result<impl IntoResponse> {
82 let csrf_token = get_csrf_token(&session).await;
83
84 let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
85 let post_slug = Slug::new(&post_slug).map_err(|_| AppError::NotFound)?;
86 let db_project = db::projects::get_public_project_by_slug(&db, &slug)
87 .await?
88 .ok_or(AppError::NotFound)?;
89
90 let db_user = db::users::get_user_by_id(&db, db_project.user_id)
91 .await?
92 .ok_or(AppError::NotFound)?;
93
94 let db_post = db::blog_posts::get_blog_post_by_slug(&db, db_project.id, &post_slug)
95 .await?
96 .ok_or(AppError::NotFound)?;
97
98 // Only published posts are visible publicly (unless owner)
99 let is_owner = maybe_user
100 .as_ref()
101 .is_some_and(|u| u.id == db_project.user_id);
102 if db_post.published_at.is_none() && !is_owner {
103 return Err(AppError::NotFound);
104 }
105
106 let avatar_initials =
107 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
108
109 let title_json = json_escape(&db_post.title);
110 let project_title_json = json_escape(&db_project.title);
111
112 let project_slug_str = db_project.slug.to_string();
113 let (discussion_url, discussion_count) = fetch_discussion_info(
114 &integrations,
115 &config,
116 db_post.mt_thread_id,
117 &project_slug_str,
118 "blog",
119 )
120 .await;
121
122 Ok(BlogPostTemplate {
123 csrf_token,
124 session_user: maybe_user,
125 title: db_post.title,
126 title_json,
127 body_html: db_post.body_html,
128 published_at: db_post
129 .published_at
130 .map(|d| d.format("%b %d, %Y").to_string())
131 .unwrap_or_default(),
132 creator_username: db_user.username.to_string(),
133 creator_display_name: db_user.display_name.clone(),
134 creator_avatar_initials: avatar_initials,
135 project_title: db_project.title,
136 project_title_json,
137 project_slug: project_slug_str,
138 post_slug: post_slug.to_string(),
139 host_url: config.host_url.clone(),
140 project_cover_image_url: db_project.cover_image_url,
141 discussion_url,
142 discussion_count,
143 })
144 }
145
146 // /changelog alias routes
147
148 /// Platform changelog index (alias for the "changelog" project blog).
149 #[tracing::instrument(skip_all, name = "blog_pages::changelog_index")]
150 async fn changelog_index(
151 State(db): State<PgPool>,
152 session: Session,
153 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
154 ) -> Result<impl IntoResponse> {
155 let csrf_token = get_csrf_token(&session).await;
156
157 // `from_trusted` is safe here because CHANGELOG_PROJECT_SLUG is a
158 // compile-time constant (`&'static str`), not user input. The audit
159 // flagged this site historically; the constant origin is the
160 // justification.
161 let slug = Slug::from_trusted(constants::CHANGELOG_PROJECT_SLUG.to_owned());
162 let db_project = db::projects::get_public_project_by_slug(&db, &slug)
163 .await?
164 .ok_or(AppError::NotFound)?;
165
166 let db_user = db::users::get_user_by_id(&db, db_project.user_id)
167 .await?
168 .ok_or(AppError::NotFound)?;
169
170 let db_posts = db::blog_posts::get_published_blog_posts_by_project(&db, db_project.id).await?;
171
172 let project = Project::from_db(&db_project, 0);
173 let posts: Vec<BlogPostSummary> = db_posts.iter().map(BlogPostSummary::from).collect();
174
175 // The changelog is a project blog under a reserved slug, so it is the same
176 // screen. One description rather than two that drift.
177 let screen = crate::quasi::project_blog::screen(
178 &project.title,
179 db_project.slug.as_ref(),
180 db_user.username.as_ref(),
181 &posts,
182 );
183 Ok(axum::response::Html(crate::quasi::project_blog::document(
184 maybe_user.as_ref(),
185 csrf_token.as_deref(),
186 &screen,
187 )))
188 }
189
190 /// Platform changelog post (alias for a "changelog" project blog post).
191 #[tracing::instrument(skip_all, name = "blog_pages::changelog_post")]
192 async fn changelog_post(
193 State(db): State<PgPool>,
194 State(config): State<Config>,
195 State(integrations): State<Integrations>,
196 session: Session,
197 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
198 Path(post_slug): Path<String>,
199 ) -> Result<impl IntoResponse> {
200 let csrf_token = get_csrf_token(&session).await;
201
202 // `from_trusted` is safe here because CHANGELOG_PROJECT_SLUG is a
203 // compile-time constant (`&'static str`), not user input. The audit
204 // flagged this site historically; the constant origin is the
205 // justification.
206 let slug = Slug::from_trusted(constants::CHANGELOG_PROJECT_SLUG.to_owned());
207 let post_slug = Slug::new(&post_slug).map_err(|_| AppError::NotFound)?;
208 let db_project = db::projects::get_public_project_by_slug(&db, &slug)
209 .await?
210 .ok_or(AppError::NotFound)?;
211
212 let db_user = db::users::get_user_by_id(&db, db_project.user_id)
213 .await?
214 .ok_or(AppError::NotFound)?;
215
216 let db_post = db::blog_posts::get_blog_post_by_slug(&db, db_project.id, &post_slug)
217 .await?
218 .ok_or(AppError::NotFound)?;
219
220 let is_owner = maybe_user
221 .as_ref()
222 .is_some_and(|u| u.id == db_project.user_id);
223 if db_post.published_at.is_none() && !is_owner {
224 return Err(AppError::NotFound);
225 }
226
227 let avatar_initials =
228 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
229
230 let title_json = json_escape(&db_post.title);
231 let project_title_json = json_escape(&db_project.title);
232
233 let project_slug_str = db_project.slug.to_string();
234 let (discussion_url, discussion_count) = fetch_discussion_info(
235 &integrations,
236 &config,
237 db_post.mt_thread_id,
238 &project_slug_str,
239 "blog",
240 )
241 .await;
242
243 Ok(BlogPostTemplate {
244 csrf_token,
245 session_user: maybe_user,
246 title: db_post.title,
247 title_json,
248 body_html: db_post.body_html,
249 published_at: db_post
250 .published_at
251 .map(|d| d.format("%b %d, %Y").to_string())
252 .unwrap_or_default(),
253 creator_username: db_user.username.to_string(),
254 creator_display_name: db_user.display_name.clone(),
255 creator_avatar_initials: avatar_initials,
256 project_title: db_project.title,
257 project_title_json,
258 project_slug: project_slug_str,
259 post_slug: post_slug.to_string(),
260 host_url: config.host_url.clone(),
261 project_cover_image_url: db_project.cover_image_url,
262 discussion_url,
263 discussion_count,
264 })
265 }
266