max / multithreaded
16 files changed,
+502 insertions,
-10 deletions
| @@ -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", |
| @@ -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 | + | } |
| @@ -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 |