max / multithreaded
- Co-Authored-By
- Claude Opus 4.6 <noreply@anthropic.com>
16 files changed,
+502 insertions,
-10 deletions
| @@ -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", |
| @@ -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 |
| @@ -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> |