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 @@
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 @@
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 @@
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
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
@@ -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) {
@@ -8,3 +8,4 @@
8 8 chrono = { workspace = true }
9 9 uuid = { workspace = true }
10 10 tracing = { workspace = true }
11 + serde = { workspace = true }
@@ -17,7 +17,7 @@
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 @@
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 @@
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 @@
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 @@
53 53 CommunitySettingsTemplate,
54 54 EditCategoryTemplate,
55 55 MembersTemplate,
56 + UserProfileTemplate,
56 57 ModerationTemplate,
57 58 ModLogTemplate,
58 59 AdminDashboardTemplate,
@@ -254,6 +254,34 @@
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")]
@@ -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>