Skip to main content

max / multithreaded

Phase 22: community user profiles, user summary API Add community-scoped profile pages (/p/{slug}/u/{username}) with activity history, replacing external MNW profile links. Add authenticated /api/user/{user_id}/summary endpoint for cross-service membership data. Bump to 0.2.4. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-15 21:45 UTC
Commit: 188d0b0305c5d8f9a29f9e0f0fb57c3cbd96e498
Parent: c7cb568
16 files changed, +502 insertions, -10 deletions
M Cargo.lock +4 -3
@@ -1282,16 +1282,17 @@ dependencies = [
1282 1282
1283 1283 [[package]]
1284 1284 name = "mt-core"
1285 - version = "0.2.2"
1285 + version = "0.2.3"
1286 1286 dependencies = [
1287 1287 "chrono",
1288 1288 ]
1289 1289
1290 1290 [[package]]
1291 1291 name = "mt-db"
1292 - version = "0.2.2"
1292 + version = "0.2.3"
1293 1293 dependencies = [
1294 1294 "chrono",
1295 + "serde",
1295 1296 "sqlx",
1296 1297 "tracing",
1297 1298 "uuid",
@@ -1299,7 +1300,7 @@ dependencies = [
1299 1300
1300 1301 [[package]]
1301 1302 name = "multithreaded"
1302 - version = "0.2.2"
1303 + version = "0.2.3"
1303 1304 dependencies = [
1304 1305 "ammonia",
1305 1306 "askama",
M Cargo.toml +1 -1
@@ -7,7 +7,7 @@ members = [
7 7 default-members = ["."]
8 8
9 9 [workspace.package]
10 - version = "0.2.3"
10 + version = "0.2.4"
11 11 edition = "2024"
12 12 license-file = "LICENSE"
13 13
@@ -8,3 +8,4 @@ sqlx = { workspace = true }
8 8 chrono = { workspace = true }
9 9 uuid = { workspace = true }
10 10 tracing = { workspace = true }
11 + serde = { workspace = true }
@@ -605,6 +605,134 @@ pub async fn get_user_by_username(
605 605 }
606 606
607 607 // ============================================================================
608 + // User profile queries
609 + // ============================================================================
610 +
611 + #[derive(sqlx::FromRow)]
612 + pub struct UserProfileRow {
613 + pub user_id: Uuid,
614 + pub username: String,
615 + pub display_name: Option<String>,
616 + pub avatar_url: Option<String>,
617 + pub role: String,
618 + pub joined_at: DateTime<Utc>,
619 + pub post_count: i64,
620 + }
621 +
622 + /// Fetch a user's profile within a specific community.
623 + /// Returns None if the user is not a member of the community.
624 + #[tracing::instrument(skip_all)]
625 + pub async fn get_user_profile_in_community(
626 + pool: &PgPool,
627 + community_slug: &str,
628 + username: &str,
629 + ) -> Result<Option<UserProfileRow>, sqlx::Error> {
630 + sqlx::query_as::<_, UserProfileRow>(
631 + "SELECT u.mnw_account_id AS user_id,
632 + u.username,
633 + u.display_name,
634 + u.avatar_url,
635 + m.role,
636 + m.joined_at,
637 + (SELECT COUNT(*) FROM posts p
638 + JOIN threads t ON t.id = p.thread_id
639 + JOIN categories c ON c.id = t.category_id
640 + WHERE p.author_id = u.mnw_account_id
641 + AND c.community_id = co.id
642 + AND p.deleted_at IS NULL
643 + AND t.deleted_at IS NULL) AS post_count
644 + FROM users u
645 + JOIN memberships m ON m.user_id = u.mnw_account_id
646 + JOIN communities co ON co.id = m.community_id
647 + WHERE co.slug = $1 AND u.username = $2",
648 + )
649 + .bind(community_slug)
650 + .bind(username)
651 + .fetch_optional(pool)
652 + .await
653 + }
654 +
655 + #[derive(sqlx::FromRow)]
656 + pub struct UserActivityRow {
657 + pub thread_id: Uuid,
658 + pub thread_title: String,
659 + pub category_name: String,
660 + pub category_slug: String,
661 + pub post_created_at: DateTime<Utc>,
662 + pub is_thread_author: bool,
663 + }
664 +
665 + /// Fetch a user's recent activity (posts) within a community.
666 + #[tracing::instrument(skip_all)]
667 + pub async fn get_user_activity_in_community(
668 + pool: &PgPool,
669 + community_id: Uuid,
670 + user_id: Uuid,
671 + limit: i64,
672 + ) -> Result<Vec<UserActivityRow>, sqlx::Error> {
673 + sqlx::query_as::<_, UserActivityRow>(
674 + "SELECT t.id AS thread_id,
675 + t.title AS thread_title,
676 + c.name AS category_name,
677 + c.slug AS category_slug,
678 + p.created_at AS post_created_at,
679 + (t.author_id = $2) AS is_thread_author
680 + FROM posts p
681 + JOIN threads t ON t.id = p.thread_id
682 + JOIN categories c ON c.id = t.category_id
683 + WHERE c.community_id = $1
684 + AND p.author_id = $2
685 + AND p.deleted_at IS NULL
686 + AND t.deleted_at IS NULL
687 + ORDER BY p.created_at DESC
688 + LIMIT $3",
689 + )
690 + .bind(community_id)
691 + .bind(user_id)
692 + .bind(limit)
693 + .fetch_all(pool)
694 + .await
695 + }
696 +
697 + #[derive(sqlx::FromRow, serde::Serialize)]
698 + pub struct UserMembershipSummary {
699 + pub community_name: String,
700 + pub community_slug: String,
701 + pub role: String,
702 + pub joined_at: DateTime<Utc>,
703 + pub post_count: i64,
704 + }
705 +
706 + /// Fetch all community memberships for a user with post counts.
707 + #[tracing::instrument(skip_all)]
708 + pub async fn get_user_membership_summary(
709 + pool: &PgPool,
710 + user_id: Uuid,
711 + ) -> Result<Vec<UserMembershipSummary>, sqlx::Error> {
712 + sqlx::query_as::<_, UserMembershipSummary>(
713 + "SELECT co.name AS community_name,
714 + co.slug AS community_slug,
715 + m.role,
716 + m.joined_at,
717 + (SELECT COUNT(*) FROM posts p
718 + JOIN threads t ON t.id = p.thread_id
719 + JOIN categories c ON c.id = t.category_id
720 + WHERE p.author_id = $1
721 + AND c.community_id = co.id
722 + AND p.deleted_at IS NULL
723 + AND t.deleted_at IS NULL) AS post_count
724 + FROM memberships m
725 + JOIN communities co ON co.id = m.community_id
726 + WHERE m.user_id = $1
727 + AND co.suspended_at IS NULL
728 + ORDER BY co.name",
729 + )
730 + .bind(user_id)
731 + .fetch_all(pool)
732 + .await
733 + }
734 +
735 + // ============================================================================
608 736 // Admin queries
609 737 // ============================================================================
610 738
@@ -17,7 +17,7 @@ use crate::AppState;
17 17 use super::{
18 18 can_delete, can_edit_post, check_community_access, check_write_access, is_mod_or_owner,
19 19 is_owner, render_markdown, CategoryQuery, CreateReplyForm, CreateThreadForm, EditPostForm,
20 - EditThreadForm, PageQuery,
20 + EditThreadForm, PageQuery, Json,
21 21 };
22 22
23 23 /// Forum directory — lists local communities.
@@ -439,6 +439,102 @@ pub(super) async fn new_thread(
439 439 })
440 440 }
441 441
442 + /// User profile within a community.
443 + #[tracing::instrument(skip_all)]
444 + pub(super) async fn user_profile(
445 + axum::extract::State(state): axum::extract::State<AppState>,
446 + Path((slug, username)): Path<(String, String)>,
447 + session: Session,
448 + MaybeUser(session_user): MaybeUser,
449 + ) -> Result<impl IntoResponse, Response> {
450 + let csrf_token = Some(csrf::get_or_create_token(&session).await);
451 + let community = mt_db::queries::get_community_by_slug(&state.db, &slug)
452 + .await
453 + .map_err(|e| {
454 + tracing::error!(error = ?e, "db error fetching community");
455 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
456 + })?
457 + .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
458 +
459 + check_community_access(&state.db, &community, session_user.as_ref().map(|u| u.user_id)).await?;
460 +
461 + let profile = mt_db::queries::get_user_profile_in_community(&state.db, &slug, &username)
462 + .await
463 + .map_err(|e| {
464 + tracing::error!(error = ?e, "db error fetching user profile");
465 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
466 + })?
467 + .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
468 +
469 + let activity = mt_db::queries::get_user_activity_in_community(
470 + &state.db, community.id, profile.user_id, 20,
471 + )
472 + .await
473 + .map_err(|e| {
474 + tracing::error!(error = ?e, "db error fetching user activity");
475 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
476 + })?;
477 +
478 + let activity_rows = activity
479 + .into_iter()
480 + .map(|a| ProfileActivityRow {
481 + thread_id: a.thread_id.to_string(),
482 + thread_title: a.thread_title,
483 + category_name: a.category_name,
484 + category_slug: a.category_slug,
485 + timestamp: mt_core::time_format::relative_timestamp(a.post_created_at),
486 + is_thread_author: a.is_thread_author,
487 + })
488 + .collect();
489 +
490 + let session_user = session_user.map(|u| TemplateSessionUser {
491 + is_platform_admin: state.config.platform_admin_id == Some(u.user_id),
492 + username: u.username,
493 + });
494 +
495 + Ok(UserProfileTemplate {
496 + csrf_token,
497 + session_user,
498 + mnw_base_url: state.config.mnw_base_url.clone(),
499 + community_name: community.name,
500 + community_slug: slug,
501 + display_name: profile.display_name.unwrap_or_else(|| profile.username.clone()),
502 + username: profile.username,
503 + avatar_url: profile.avatar_url,
504 + role: profile.role,
505 + joined: mt_core::time_format::relative_timestamp(profile.joined_at),
506 + post_count: profile.post_count,
507 + activity: activity_rows,
508 + })
509 + }
510 +
511 + /// API: user membership summary (for MNW dashboard).
512 + #[tracing::instrument(skip_all)]
513 + pub(super) async fn user_summary_api(
514 + axum::extract::State(state): axum::extract::State<AppState>,
515 + Path(user_id_str): Path<String>,
516 + MaybeUser(session_user): MaybeUser,
517 + ) -> Result<Json<serde_json::Value>, Response> {
518 + let user = session_user
519 + .ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?;
520 +
521 + let user_id = Uuid::parse_str(&user_id_str)
522 + .map_err(|_| StatusCode::NOT_FOUND.into_response())?;
523 +
524 + if user.user_id != user_id {
525 + return Err(StatusCode::FORBIDDEN.into_response());
526 + }
527 +
528 + let memberships = mt_db::queries::get_user_membership_summary(&state.db, user_id)
529 + .await
530 + .map_err(|e| {
531 + tracing::error!(error = ?e, "db error fetching membership summary");
532 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
533 + })?;
534 +
535 + Ok(Json(serde_json::json!({ "memberships": memberships })))
536 + }
537 +
442 538 // ============================================================================
443 539 // Write handlers
444 540 // ============================================================================
@@ -72,6 +72,7 @@ pub fn forum_routes(state: AppState) -> Router {
72 72 .route("/", get(forum::forum_directory))
73 73 .route("/p/{slug}", get(forum::project_forum))
74 74 .route("/p/{slug}/members", get(forum::community_members))
75 + .route("/p/{slug}/u/{username}", get(forum::user_profile))
75 76 .route("/p/{slug}/settings", get(settings::community_settings))
76 77 .route("/p/{slug}/settings/categories/{cat_id}/edit", get(settings::edit_category_form))
77 78 .route("/p/{slug}/moderation", get(moderation::moderation_page))
@@ -85,6 +86,7 @@ pub fn forum_routes(state: AppState) -> Router {
85 86 .route("/auth/login", get(auth::login))
86 87 .route("/auth/callback", get(auth::callback))
87 88 .route("/auth/logout", get(auth::logout))
89 + .route("/api/user/{user_id}/summary", get(forum::user_summary_api))
88 90 .route("/api/health", get(health));
89 91
90 92 read_routes
@@ -53,6 +53,7 @@ impl_into_response!(
53 53 CommunitySettingsTemplate,
54 54 EditCategoryTemplate,
55 55 MembersTemplate,
56 + UserProfileTemplate,
56 57 ModerationTemplate,
57 58 ModLogTemplate,
58 59 AdminDashboardTemplate,
@@ -254,6 +254,34 @@ pub struct MembersTemplate {
254 254 pub members: Vec<MemberListRow>,
255 255 }
256 256
257 + /// Activity row for user profile page.
258 + pub struct ProfileActivityRow {
259 + pub thread_id: String,
260 + pub thread_title: String,
261 + pub category_name: String,
262 + pub category_slug: String,
263 + pub timestamp: String,
264 + pub is_thread_author: bool,
265 + }
266 +
267 + /// User profile within a community.
268 + #[derive(Template)]
269 + #[template(path = "pages/user_profile.html")]
270 + pub struct UserProfileTemplate {
271 + pub csrf_token: CsrfTokenOption,
272 + pub session_user: Option<TemplateSessionUser>,
273 + pub mnw_base_url: String,
274 + pub community_name: String,
275 + pub community_slug: String,
276 + pub username: String,
277 + pub display_name: String,
278 + pub avatar_url: Option<String>,
279 + pub role: String,
280 + pub joined: String,
281 + pub post_count: i64,
282 + pub activity: Vec<ProfileActivityRow>,
283 + }
284 +
257 285 /// 404 error page.
258 286 #[derive(Template)]
259 287 #[template(path = "pages/error_404.html")]
@@ -23,7 +23,7 @@
23 23 </script>
24 24 {% block head %}{% endblock %}
25 25 </head>
26 - <body{% block body_attrs %}{% endblock %}>
26 + <body data-mnw-url="{{ mnw_base_url }}"{% block body_attrs %}{% endblock %}>
27 27 <a href="#main-content" class="skip-to-main">Skip to main content</a>
28 28 {% block header %}{% endblock %}
29 29 <main id="main-content">
@@ -119,6 +119,23 @@
119 119 toggle.checked = false;
120 120 }
121 121 });
122 +
123 + // Confirm before navigating to MNW (external) links
124 + (function() {
125 + var mnwUrl = document.body.dataset.mnwUrl;
126 + if (!mnwUrl) return;
127 + document.addEventListener('click', function(e) {
128 + var link = e.target.closest('a[href]');
129 + if (!link) return;
130 + var href = link.getAttribute('href');
131 + if (href && href.indexOf(mnwUrl) === 0) {
132 + e.preventDefault();
133 + if (confirm('You are about to leave Multithreaded for Makenot.work. Continue?')) {
134 + window.location.href = href;
135 + }
136 + }
137 + });
138 + })();
122 139 </script>
123 140 <script>
124 141 document.addEventListener('submit', function(e) {
@@ -45,7 +45,7 @@
45 45 {% if t.locked %}<span class="badge badge-locked">[locked]</span> {% endif %}
46 46 <a href="/p/{{ community_slug }}/{{ category_slug }}/{{ t.id }}">{{ t.title }}</a>
47 47 </td>
48 - <td class="col-author"><a href="{{ mnw_base_url }}/u/{{ t.author_username }}">{{ t.author_name }}</a></td>
48 + <td class="col-author"><a href="/p/{{ community_slug }}/u/{{ t.author_username }}">{{ t.author_name }}</a></td>
49 49 <td class="col-replies">{{ t.reply_count }}</td>
50 50 <td class="col-activity">{{ t.last_activity }}</td>
51 51 </tr>
@@ -32,7 +32,7 @@
32 32 <tbody>
33 33 {% for m in members %}
34 34 <tr>
35 - <td><a href="{{ mnw_base_url }}/u/{{ m.username }}">{{ m.display_name }}</a></td>
35 + <td><a href="/p/{{ community_slug }}/u/{{ m.username }}">{{ m.display_name }}</a></td>
36 36 <td class="col-role"><span class="badge badge-role-{{ m.role }}">{{ m.role }}</span></td>
37 37 <td class="col-joined">{{ m.joined }}</td>
38 38 </tr>
@@ -49,7 +49,7 @@
49 49 {% for post in posts %}
50 50 <div class="post-item{% if post.is_op %} op{% endif %}{% if post.is_deleted %} post-deleted{% endif %}">
51 51 <div class="post-header">
52 - <a href="{{ mnw_base_url }}/u/{{ post.author_username }}" class="post-author">{{ post.author_name }}</a>
52 + <a href="/p/{{ community_slug }}/u/{{ post.author_username }}" class="post-author">{{ post.author_name }}</a>
53 53 <span>
54 54 <span class="post-timestamp">{{ post.timestamp }}</span>
55 55 {% if post.is_edited %}<span class="post-edited">(edited)</span>{% endif %}
@@ -0,0 +1,66 @@
1 + {% extends "base.html" %}
2 +
3 + {% block title %}{{ display_name }} — {{ community_name }} — Multithreaded{% endblock %}
4 +
5 + {% block head %}<meta name="description" content="{{ display_name }}'s profile in {{ community_name }}.">{% endblock %}
6 +
7 + {% block header %}{% include "partials/site_header.html" %}{% endblock %}
8 +
9 + {% block content %}
10 + <div class="container">
11 + <div class="breadcrumb">
12 + <a href="/">Forums</a>
13 + <span class="sep">/</span>
14 + <a href="/p/{{ community_slug }}">{{ community_name }}</a>
15 + <span class="sep">/</span>
16 + <a href="/p/{{ community_slug }}/members">Members</a>
17 + <span class="sep">/</span>
18 + {{ display_name }}
19 + </div>
20 +
21 + <div class="profile-header" style="display: flex; align-items: center; gap: 1.25rem; margin-bottom: 1.5rem;">
22 + {% if let Some(url) = avatar_url %}
23 + <img src="{{ url }}" alt="{{ username }}" style="width: 64px; height: 64px; border-radius: 50%; object-fit: cover;">
24 + {% else %}
25 + <div style="width: 64px; height: 64px; border-radius: 50%; background: var(--border-color); display: flex; align-items: center; justify-content: center; font-size: 1.5rem; font-weight: bold; color: var(--text-muted); flex-shrink: 0;">{{ username.chars().next().unwrap_or('?').to_uppercase().next().unwrap_or('?') }}</div>
26 + {% endif %}
27 + <div>
28 + <h2 style="margin: 0;">{{ display_name }}</h2>
29 + <div style="color: var(--text-muted); font-size: 0.9rem;">
30 + @{{ username }}
31 + <span class="badge badge-role-{{ role }}">{{ role }}</span>
32 + </div>
33 + <div style="color: var(--text-muted); font-size: 0.85rem; margin-top: 0.25rem;">
34 + Joined {{ joined }} · {{ post_count }} post{% if post_count != 1 %}s{% endif %}
35 + </div>
36 + </div>
37 + </div>
38 +
39 + <h3>Recent Activity</h3>
40 + {% if activity.is_empty() %}
41 + <div class="empty-state">No activity yet.</div>
42 + {% else %}
43 + <table class="data-table">
44 + <thead>
45 + <tr>
46 + <th>Thread</th>
47 + <th class="col-role">Type</th>
48 + <th class="col-activity">When</th>
49 + </tr>
50 + </thead>
51 + <tbody>
52 + {% for a in activity %}
53 + <tr>
54 + <td>
55 + <a href="/p/{{ community_slug }}/{{ a.category_slug }}/{{ a.thread_id }}">{{ a.thread_title }}</a>
56 + <span style="color: var(--text-muted); font-size: 0.85rem;"> in {{ a.category_name }}</span>
57 + </td>
58 + <td class="col-role">{% if a.is_thread_author %}started{% else %}replied{% endif %}</td>
59 + <td class="col-activity">{{ a.timestamp }}</td>
60 + </tr>
61 + {% endfor %}
62 + </tbody>
63 + </table>
64 + {% endif %}
65 + </div>
66 + {% endblock %}
@@ -6,4 +6,5 @@ mod csrf;
6 6 mod moderation;
7 7 mod pagination;
8 8 mod permissions;
9 + mod profiles;
9 10 mod rate_limit;
@@ -0,0 +1,151 @@
1 + use crate::harness::TestHarness;
2 +
3 + #[tokio::test]
4 + async fn profile_page_shows_user_info() {
5 + let mut h = TestHarness::new().await;
6 + let user_id = h.login_as("profileuser").await;
7 + let comm_id = h.create_community("TestCommunity", "test-comm").await;
8 + h.create_category(comm_id, "General", "general").await;
9 + h.add_membership(user_id, comm_id, "member").await;
10 +
11 + let resp = h.client.get("/p/test-comm/u/profileuser").await;
12 + assert_eq!(resp.status.as_u16(), 200, "profile page should load");
13 + assert!(resp.text.contains("profileuser"), "should show username");
14 + assert!(resp.text.contains("member"), "should show role badge");
15 + assert!(resp.text.contains("Joined"), "should show join date");
16 + }
17 +
18 + #[tokio::test]
19 + async fn profile_page_shows_activity() {
20 + let mut h = TestHarness::new().await;
21 + let user_id = h.login_as("activeuser").await;
22 + let comm_id = h.create_community("ActivityComm", "activity-comm").await;
23 + let cat_id = h.create_category(comm_id, "General", "general").await;
24 + h.add_membership(user_id, comm_id, "member").await;
25 +
26 + // Create a thread (user is thread author)
27 + let thread_id = h
28 + .create_thread_with_post(cat_id, user_id, "My Thread", "Hello world")
29 + .await;
30 +
31 + // Create a reply in another thread
32 + let thread_id_2 = h
33 + .create_thread_with_post(cat_id, user_id, "Another Thread", "Second post")
34 + .await;
35 + mt_db::mutations::create_post(
36 + &h.db,
37 + thread_id_2,
38 + user_id,
39 + "A reply",
40 + "<p>A reply</p>",
41 + )
42 + .await
43 + .unwrap();
44 +
45 + let resp = h.client.get("/p/activity-comm/u/activeuser").await;
46 + assert_eq!(resp.status.as_u16(), 200);
47 + assert!(resp.text.contains("My Thread"), "should list thread activity");
48 + assert!(resp.text.contains("Another Thread"), "should list reply activity");
49 +
50 + // Verify thread_id link is present
51 + assert!(
52 + resp.text.contains(&thread_id.to_string()),
53 + "should link to thread"
54 + );
55 + }
56 +
57 + #[tokio::test]
58 + async fn profile_nonmember_returns_404() {
59 + let mut h = TestHarness::new().await;
60 + h.login_as("outsider").await;
61 + let _comm_id = h.create_community("ClosedComm", "closed-comm").await;
62 +
63 + // User exists but is not a member
64 + let resp = h.client.get("/p/closed-comm/u/outsider").await;
65 + assert_eq!(resp.status.as_u16(), 404, "non-member should get 404");
66 + }
67 +
68 + #[tokio::test]
69 + async fn profile_nonexistent_user_404() {
70 + let mut h = TestHarness::new().await;
71 + h.login_as("someuser").await;
72 + let _comm_id = h.create_community("SomeComm", "some-comm").await;
73 +
74 + let resp = h.client.get("/p/some-comm/u/nobodyhere").await;
75 + assert_eq!(resp.status.as_u16(), 404, "nonexistent user should get 404");
76 + }
77 +
78 + #[tokio::test]
79 + async fn profile_suspended_community_blocked() {
80 + let mut h = TestHarness::new().await;
81 + let user_id = h.login_as("suspendedfan").await;
82 + let comm_id = h.create_community("SuspendedComm", "suspended-comm").await;
83 + h.add_membership(user_id, comm_id, "member").await;
84 +
85 + // Suspend the community
86 + sqlx::query("UPDATE communities SET suspended_at = now() WHERE id = $1")
87 + .bind(comm_id)
88 + .execute(&h.db)
89 + .await
90 + .unwrap();
91 +
92 + let resp = h.client.get("/p/suspended-comm/u/suspendedfan").await;
93 + assert_eq!(
94 + resp.status.as_u16(),
95 + 403,
96 + "suspended community should return 403"
97 + );
98 + }
99 +
100 + #[tokio::test]
101 + async fn api_summary_requires_auth() {
102 + let mut h = TestHarness::new().await;
103 + // Don't log in — make unauthenticated request
104 + h.client.get("/").await; // establish session
105 +
106 + let user_id = uuid::Uuid::new_v4();
107 + let resp = h
108 + .client
109 + .get(&format!("/api/user/{}/summary", user_id))
110 + .await;
111 + assert_eq!(resp.status.as_u16(), 401, "unauthenticated should get 401");
112 + }
113 +
114 + #[tokio::test]
115 + async fn api_summary_returns_memberships() {
116 + let mut h = TestHarness::new().await;
117 + let user_id = h.login_as("summaryuser").await;
118 + let comm_id = h.create_community("SummaryComm", "summary-comm").await;
119 + h.add_membership(user_id, comm_id, "member").await;
120 +
121 + let resp = h
122 + .client
123 + .get(&format!("/api/user/{}/summary", user_id))
124 + .await;
125 + assert_eq!(resp.status.as_u16(), 200, "should return 200");
126 +
127 + let json: serde_json::Value = resp.json();
128 + let memberships = json["memberships"].as_array().unwrap();
129 + assert_eq!(memberships.len(), 1, "should have one membership");
130 + assert_eq!(memberships[0]["community_name"], "SummaryComm");
131 + assert_eq!(memberships[0]["community_slug"], "summary-comm");
132 + assert_eq!(memberships[0]["role"], "member");
133 + }
134 +
135 + #[tokio::test]
136 + async fn api_summary_only_own_data() {
137 + let mut h = TestHarness::new().await;
138 + let _user_id = h.login_as("snoop").await;
139 +
140 + // Try to access another user's summary
141 + let other_id = uuid::Uuid::new_v4();
142 + let resp = h
143 + .client
144 + .get(&format!("/api/user/{}/summary", other_id))
145 + .await;
146 + assert_eq!(
147 + resp.status.as_u16(),
148 + 403,
149 + "accessing other user's data should return 403"
150 + );
151 + }
M todo.md +1 -1
@@ -1,6 +1,6 @@
1 1 # Multithreaded — Todo
2 2
3 - Done: Phases 0-11. 106 tests (65 integration + 25 unit lib + 16 unit mt-core). v0.2.2. Routes split into directory module (`routes/`). Graceful shutdown + reqwest timeouts. Unused deps removed. First formal audit: B+ (2026-03-14). All 10 audit findings resolved (1 HIGH + 4 MEDIUM + 5 SMALL). Rate limiting (tower-governor). Expired ban cleanup (opportunistic). Test coverage gaps closed. Ammonia HTML sanitizer (defense-in-depth). Audit grade: A. Initial git commit done. UI aligned with MNW: header nav (Library, Discover, Feed, Profile, Dashboard link to MNW), footer ("Powered by Makenot.work"), nav link styling (IBM Plex Mono, opacity transitions), dead CSS removed. Deployed to hetzner (forums.makenot.work) alongside MNW (2026-03-15). Cross-compiled via cargo-zigbuild, reqwest uses rustls-tls. OAuth app registered in MNW production DB. Caddy reverse proxy + Cloudflare Origin CA + Authenticated Origin Pulls (mTLS). PoM monitoring configured. Public URL verified (HTTP 200 through Cloudflare).
3 + Done: Phases 0-11. 106 tests (65 integration + 25 unit lib + 16 unit mt-core). v0.2.3. Routes split into directory module (`routes/`). Graceful shutdown + reqwest timeouts. Unused deps removed. First formal audit: B+ (2026-03-14). All 10 audit findings resolved (1 HIGH + 4 MEDIUM + 5 SMALL). Rate limiting (tower-governor). Expired ban cleanup (opportunistic). Test coverage gaps closed. Ammonia HTML sanitizer (defense-in-depth). Audit grade: A. Initial git commit done. UI aligned with MNW: header nav (Library, Discover, Feed, Profile, Dashboard link to MNW), footer ("Powered by Makenot.work"), nav link styling (IBM Plex Mono, opacity transitions), dead CSS removed. Deployed to hetzner (forums.makenot.work) alongside MNW (2026-03-15). Cross-compiled via cargo-zigbuild, reqwest uses rustls-tls. OAuth app registered in MNW production DB. Caddy reverse proxy + Cloudflare Origin CA + Authenticated Origin Pulls (mTLS). PoM monitoring configured. Public URL verified (HTTP 200 through Cloudflare).
4 4
5 5 Completed work archived in [todo_done.md](todo_done.md).
6 6