Skip to main content

max / makenotwork

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