Skip to main content

max / makenotwork

16.9 KB · 517 lines History Blame Raw
1 //! Read handlers, forum directory, community pages, category listings, user profiles.
2
3 use axum::{
4 Json,
5 extract::{Path, Query},
6 http::StatusCode,
7 response::{IntoResponse, Response},
8 };
9 use tower_sessions::Session;
10
11 use crate::AppState;
12 use crate::auth::MaybeUser;
13 use crate::csrf;
14 use crate::templates::{
15 CategoryRow, CategoryTemplate, CommunityDirectoryRow, CommunityTemplate,
16 ForumDirectoryTemplate, MemberListRow, MembersTemplate, NewThreadTemplate, Pagination,
17 ProfileActivityRow, TagBadge, ThreadRow, UserProfileTemplate,
18 };
19
20 use std::collections::HashMap;
21
22 use mt_core::types::{SortColumn, SortOrder};
23
24 use super::super::{
25 CategoryQuery, ForumDirectoryQuery, PageQuery, check_community_access, db_error, get_community,
26 get_role, is_mod_or_owner, is_owner, parse_uuid, template_user,
27 };
28
29 /// Forum directory, lists local communities (paginated).
30 ///
31 /// `?filter=archived` switches to the archived-only listing. The default view
32 /// excludes archived communities; they remain reachable by direct URL.
33 #[tracing::instrument(skip_all)]
34 pub(in crate::routes) async fn forum_directory(
35 axum::extract::State(state): axum::extract::State<AppState>,
36 Query(query): Query<ForumDirectoryQuery>,
37 session: Session,
38 MaybeUser(session_user): MaybeUser,
39 ) -> Result<impl IntoResponse, Response> {
40 let csrf_token = Some(csrf::get_or_create_token(&session).await?);
41 let viewing_archived = query.filter.as_deref() == Some("archived");
42
43 let per_page: i64 = 25;
44 let total = if viewing_archived {
45 mt_db::queries::count_archived_communities(&state.db).await
46 } else {
47 mt_db::queries::count_communities(&state.db).await
48 }
49 .inspect_err(
50 |e| tracing::error!(error = ?e, viewing_archived, "db error counting communities for directory"),
51 )
52 .unwrap_or(0);
53 let mut pagination = Pagination::new(query.page.unwrap_or(1).max(1), total, per_page);
54 // Keep the archived filter across page links (else page 2+ reverts to the
55 // default, non-archived listing).
56 if viewing_archived {
57 pagination = pagination.with_query_suffix("&filter=archived");
58 }
59 let offset = pagination.offset(per_page);
60
61 let rows = if viewing_archived {
62 mt_db::queries::list_archived_communities(&state.db, per_page, offset).await
63 } else {
64 mt_db::queries::list_communities(&state.db, per_page, offset).await
65 };
66 let communities = rows
67 .inspect_err(
68 |e| tracing::error!(error = ?e, viewing_archived, "db error listing communities for directory"),
69 )
70 .unwrap_or_default()
71 .into_iter()
72 .map(|c| CommunityDirectoryRow {
73 slug: c.slug,
74 name: c.name,
75 description: c.description,
76 category_count: c.category_count as u32,
77 thread_count: c.thread_count as u32,
78 })
79 .collect();
80
81 let session_user = session_user
82 .as_ref()
83 .map(|u| template_user(u, state.config.platform_admin_id));
84
85 Ok(ForumDirectoryTemplate {
86 csrf_token,
87 session_user,
88 mnw_base_url: state.config.mnw_base_url.clone(),
89 communities,
90 pagination,
91 viewing_archived,
92 })
93 }
94
95 /// Project forum, categories within a project.
96 #[tracing::instrument(skip_all)]
97 pub(in crate::routes) async fn project_forum(
98 axum::extract::State(state): axum::extract::State<AppState>,
99 Path(slug): Path<String>,
100 session: Session,
101 MaybeUser(session_user): MaybeUser,
102 ) -> Result<impl IntoResponse, Response> {
103 let csrf_token = Some(csrf::get_or_create_token(&session).await?);
104 let community = get_community(&state.db, &slug).await?;
105
106 check_community_access(
107 &state.db,
108 &community,
109 session_user.as_ref().map(|u| u.user_id),
110 )
111 .await?;
112
113 let db_categories = mt_db::queries::list_categories_with_counts(&state.db, &slug)
114 .await
115 .map_err(db_error)?;
116
117 let role = if let Some(ref user) = session_user {
118 get_role(&state.db, user.user_id, community.id).await?
119 } else {
120 None
121 };
122
123 let categories = db_categories
124 .into_iter()
125 .map(|c| CategoryRow {
126 name: c.name,
127 slug: c.slug,
128 description: c.description,
129 thread_count: c.thread_count as u32,
130 })
131 .collect();
132
133 let owner = is_owner(role);
134 let mod_or_owner = is_mod_or_owner(role);
135 let session_user = session_user
136 .as_ref()
137 .map(|u| template_user(u, state.config.platform_admin_id));
138
139 Ok(CommunityTemplate {
140 csrf_token,
141 session_user,
142 mnw_base_url: state.config.mnw_base_url.clone(),
143 community_name: community.name,
144 community_slug: community.slug,
145 community_description: community.description,
146 categories,
147 is_owner: owner,
148 is_mod_or_owner: mod_or_owner,
149 })
150 }
151
152 #[tracing::instrument(skip_all)]
153 pub(in crate::routes) async fn community_members(
154 axum::extract::State(state): axum::extract::State<AppState>,
155 Path(slug): Path<String>,
156 Query(page_query): Query<PageQuery>,
157 session: Session,
158 MaybeUser(session_user): MaybeUser,
159 ) -> Result<impl IntoResponse, Response> {
160 let csrf_token = Some(csrf::get_or_create_token(&session).await?);
161 let community = get_community(&state.db, &slug).await?;
162
163 check_community_access(
164 &state.db,
165 &community,
166 session_user.as_ref().map(|u| u.user_id),
167 )
168 .await?;
169
170 let per_page: i64 = 100;
171 let total = mt_db::queries::count_community_members(&state.db, community.id)
172 .await
173 .map_err(db_error)?;
174 let pagination = Pagination::new(page_query.page.unwrap_or(1).max(1), total, per_page);
175 let offset = pagination.offset(per_page);
176
177 let db_members =
178 mt_db::queries::list_community_members(&state.db, community.id, per_page, offset)
179 .await
180 .map_err(db_error)?;
181
182 let members = db_members
183 .into_iter()
184 .map(|m| MemberListRow {
185 display_name: m.display_name.unwrap_or_else(|| m.username.clone()),
186 username: m.username,
187 role: m.role.to_string(),
188 joined: mt_core::time_format::relative_timestamp(m.joined_at),
189 })
190 .collect();
191
192 let session_user = session_user
193 .as_ref()
194 .map(|u| template_user(u, state.config.platform_admin_id));
195
196 Ok(MembersTemplate {
197 csrf_token,
198 session_user,
199 mnw_base_url: state.config.mnw_base_url.clone(),
200 community_name: community.name,
201 community_slug: slug,
202 members,
203 pagination,
204 })
205 }
206
207 #[tracing::instrument(skip_all)]
208 pub(in crate::routes) async fn category(
209 axum::extract::State(state): axum::extract::State<AppState>,
210 Path((slug, category_slug)): Path<(String, String)>,
211 Query(query): Query<CategoryQuery>,
212 session: Session,
213 MaybeUser(session_user): MaybeUser,
214 ) -> Result<impl IntoResponse, Response> {
215 let csrf_token = Some(csrf::get_or_create_token(&session).await?);
216 let per_page: i64 = 25;
217
218 // Parse sort column, only allow known values, default to "activity"
219 let sort = SortColumn::from_query(query.sort.as_deref());
220 let order = SortOrder::from_query(query.order.as_deref());
221
222 let tag_filter = query.tag.as_deref().filter(|t| !t.is_empty());
223
224 // Community, category, and the thread count are all keyed off the slugs and
225 // independent of each other, run them concurrently rather than as three
226 // serial round-trips (matches thread.rs's batched fetch).
227 let (community, cat, total) = tokio::try_join!(
228 async { get_community(&state.db, &slug).await },
229 async {
230 mt_db::queries::get_category_by_slugs(&state.db, &slug, &category_slug)
231 .await
232 .map_err(db_error)?
233 .ok_or_else(crate::error_page::not_found)
234 },
235 async {
236 mt_db::queries::count_threads_in_category_filtered(
237 &state.db,
238 &slug,
239 &category_slug,
240 tag_filter,
241 )
242 .await
243 .map_err(db_error)
244 },
245 )?;
246
247 check_community_access(
248 &state.db,
249 &community,
250 session_user.as_ref().map(|u| u.user_id),
251 )
252 .await?;
253
254 // Carry the sort/order (and optional tag) across page links so page 2+ keeps
255 // the current view. Feeding this into the shared pagination partial via
256 // query_suffix means category.html no longer hand-rolls its own pagination
257 // markup (which had drifted from partials/pagination.html). sort/order are
258 // enum-derived and tag is a validated slug, so the suffix is URL-safe.
259 let mut page_suffix = format!("&sort={}&order={}", sort.as_str(), order.as_str());
260 if let Some(tag) = tag_filter {
261 use std::fmt::Write as _;
262 let _ = write!(page_suffix, "&tag={tag}");
263 }
264 let pagination = Pagination::new(query.page.unwrap_or(1).max(1), total, per_page)
265 .with_query_suffix(&page_suffix);
266 let offset = pagination.offset(per_page);
267
268 let db_threads = mt_db::queries::list_threads_in_category_sorted(
269 &state.db,
270 &slug,
271 &category_slug,
272 sort,
273 order,
274 per_page,
275 offset,
276 tag_filter,
277 )
278 .await
279 .map_err(db_error)?;
280
281 // Per-thread tags, the user's mention status, and the community's tag list
282 // are all independent given the thread ids (and community id), fetch them
283 // concurrently instead of three serial round-trips.
284 let thread_ids: Vec<uuid::Uuid> = db_threads.iter().map(|t| t.id).collect();
285 let user_id = session_user.as_ref().map(|u| u.user_id);
286 let (all_thread_tags, mention_thread_ids, db_tags) = tokio::try_join!(
287 async {
288 mt_db::queries::list_tags_for_threads(&state.db, &thread_ids)
289 .await
290 .map_err(db_error)
291 },
292 async {
293 // Mention status for the logged-in user; a query error degrades to
294 // "no mentions" rather than failing the page (as before).
295 let set: std::collections::HashSet<String> = match user_id {
296 Some(uid) => {
297 mt_db::queries::get_threads_with_mentions_for_user(&state.db, uid, &thread_ids)
298 .await
299 .unwrap_or_default()
300 .into_iter()
301 .map(|id| id.to_string())
302 .collect()
303 }
304 None => std::collections::HashSet::new(),
305 };
306 Ok::<_, Response>(set)
307 },
308 async {
309 mt_db::queries::list_tags_for_community(&state.db, community.id)
310 .await
311 .map_err(db_error)
312 },
313 )?;
314
315 let mut tags_by_thread: HashMap<String, Vec<TagBadge>> = HashMap::new();
316 for tt in all_thread_tags {
317 tags_by_thread
318 .entry(tt.thread_id.to_string())
319 .or_default()
320 .push(TagBadge {
321 id: String::new(),
322 name: tt.tag_name,
323 slug: tt.tag_slug,
324 });
325 }
326
327 let threads = db_threads
328 .into_iter()
329 .map(|t| {
330 let tid = t.id.to_string();
331 let tags = tags_by_thread.remove(&tid).unwrap_or_default();
332 let has_mention = mention_thread_ids.contains(&tid);
333 ThreadRow {
334 id: tid,
335 title: t.title,
336 author_name: t.author_name,
337 author_username: t.author_username,
338 reply_count: t.reply_count.max(0) as u32,
339 last_activity: mt_core::time_format::relative_timestamp(t.last_activity_at),
340 pinned: t.pinned,
341 locked: t.locked,
342 has_mention,
343 tags,
344 }
345 })
346 .collect();
347
348 let available_tags = db_tags
349 .into_iter()
350 .map(|t| TagBadge {
351 id: t.id.to_string(),
352 name: t.name,
353 slug: t.slug,
354 })
355 .collect();
356
357 let session_user = session_user
358 .as_ref()
359 .map(|u| template_user(u, state.config.platform_admin_id));
360
361 Ok(CategoryTemplate {
362 csrf_token,
363 session_user,
364 mnw_base_url: state.config.mnw_base_url.clone(),
365 community_name: community.name,
366 community_slug: slug,
367 category_name: cat.name,
368 category_slug,
369 threads,
370 pagination,
371 sort_column: sort.as_str().to_string(),
372 sort_order: order.as_str().to_string(),
373 available_tags,
374 active_tag: tag_filter.map(std::string::ToString::to_string),
375 })
376 }
377
378 #[tracing::instrument(skip_all)]
379 pub(in crate::routes) async fn new_thread(
380 axum::extract::State(state): axum::extract::State<AppState>,
381 Path((slug, category_slug)): Path<(String, String)>,
382 session: Session,
383 MaybeUser(session_user): MaybeUser,
384 ) -> Result<impl IntoResponse, Response> {
385 let csrf_token = Some(csrf::get_or_create_token(&session).await?);
386 let community = get_community(&state.db, &slug).await?;
387
388 // Check suspension + ban for logged-in users (form display only; POST enforces fully)
389 check_community_access(
390 &state.db,
391 &community,
392 session_user.as_ref().map(|u| u.user_id),
393 )
394 .await?;
395
396 let cat = mt_db::queries::get_category_by_slugs(&state.db, &slug, &category_slug)
397 .await
398 .map_err(db_error)?
399 .ok_or_else(crate::error_page::not_found)?;
400
401 let db_tags = mt_db::queries::list_tags_for_community(&state.db, community.id)
402 .await
403 .map_err(db_error)?;
404 let available_tags = db_tags
405 .into_iter()
406 .map(|t| TagBadge {
407 id: t.id.to_string(),
408 name: t.name,
409 slug: t.slug,
410 })
411 .collect();
412
413 let session_user = session_user
414 .as_ref()
415 .map(|u| template_user(u, state.config.platform_admin_id));
416
417 Ok(NewThreadTemplate {
418 csrf_token,
419 session_user,
420 mnw_base_url: state.config.mnw_base_url.clone(),
421 community_name: community.name,
422 community_slug: slug,
423 category_name: cat.name,
424 category_slug,
425 available_tags,
426 })
427 }
428
429 /// User profile within a community.
430 #[tracing::instrument(skip_all)]
431 pub(in crate::routes) async fn user_profile(
432 axum::extract::State(state): axum::extract::State<AppState>,
433 Path((slug, username)): Path<(String, String)>,
434 session: Session,
435 MaybeUser(session_user): MaybeUser,
436 ) -> Result<impl IntoResponse, Response> {
437 let csrf_token = Some(csrf::get_or_create_token(&session).await?);
438 let community = get_community(&state.db, &slug).await?;
439
440 check_community_access(
441 &state.db,
442 &community,
443 session_user.as_ref().map(|u| u.user_id),
444 )
445 .await?;
446
447 let profile = mt_db::queries::get_user_profile_in_community(&state.db, &slug, &username)
448 .await
449 .map_err(db_error)?
450 .ok_or_else(crate::error_page::not_found)?;
451
452 let activity = mt_db::queries::get_user_activity_in_community(
453 &state.db,
454 community.id,
455 profile.user_id,
456 20,
457 )
458 .await
459 .map_err(db_error)?;
460
461 let activity_rows = activity
462 .into_iter()
463 .map(|a| ProfileActivityRow {
464 thread_id: a.thread_id.to_string(),
465 thread_title: a.thread_title,
466 category_name: a.category_name,
467 category_slug: a.category_slug,
468 timestamp: mt_core::time_format::relative_timestamp(a.post_created_at),
469 is_thread_author: a.is_thread_author,
470 })
471 .collect();
472
473 let session_user = session_user
474 .as_ref()
475 .map(|u| template_user(u, state.config.platform_admin_id));
476
477 Ok(UserProfileTemplate {
478 csrf_token,
479 session_user,
480 mnw_base_url: state.config.mnw_base_url.clone(),
481 community_name: community.name,
482 community_slug: slug,
483 display_name: profile
484 .display_name
485 .unwrap_or_else(|| profile.username.clone()),
486 username: profile.username,
487 avatar_url: profile.avatar_url,
488 role: profile.role.to_string(),
489 joined: mt_core::time_format::relative_timestamp(profile.joined_at),
490 post_count: profile.post_count,
491 endorsement_count: profile.endorsement_count,
492 activity: activity_rows,
493 })
494 }
495
496 /// API: user membership summary (for MNW dashboard).
497 #[tracing::instrument(skip_all)]
498 pub(in crate::routes) async fn user_summary_api(
499 axum::extract::State(state): axum::extract::State<AppState>,
500 Path(user_id_str): Path<String>,
501 MaybeUser(session_user): MaybeUser,
502 ) -> Result<Json<serde_json::Value>, Response> {
503 let user = session_user.ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?;
504
505 let user_id = parse_uuid(&user_id_str)?;
506
507 if user.user_id != user_id {
508 return Err(StatusCode::FORBIDDEN.into_response());
509 }
510
511 let memberships = mt_db::queries::get_user_membership_summary(&state.db, user_id)
512 .await
513 .map_err(db_error)?;
514
515 Ok(Json(serde_json::json!({ "memberships": memberships })))
516 }
517