Skip to main content

max / makenotwork

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