max / makenotwork
18 files changed,
+178 insertions,
-82 deletions
| @@ -12,3 +12,13 @@ RUST_LOG=info | |||
| 12 | 12 | ||
| 13 | 13 | # Platform admin (UUID of the MNW account that can access /_admin) | |
| 14 | 14 | # PLATFORM_ADMIN_ID=00000000-0000-0000-0000-000000000000 | |
| 15 | + | ||
| 16 | + | # S3 storage (optional — required for image uploads) | |
| 17 | + | # S3_ENDPOINT=https://s3.us-east-1.amazonaws.com | |
| 18 | + | # S3_BUCKET=mt-uploads | |
| 19 | + | # S3_ACCESS_KEY=your-access-key | |
| 20 | + | # S3_SECRET_KEY=your-secret-key | |
| 21 | + | # S3_REGION=us-east-1 | |
| 22 | + | ||
| 23 | + | # Internal API shared secret (HMAC-SHA256 auth for MNW → MT calls) | |
| 24 | + | # INTERNAL_SHARED_SECRET=your-shared-secret |
| @@ -2074,6 +2074,8 @@ name = "mt-core" | |||
| 2074 | 2074 | version = "0.3.3" | |
| 2075 | 2075 | dependencies = [ | |
| 2076 | 2076 | "chrono", | |
| 2077 | + | "serde", | |
| 2078 | + | "sqlx", | |
| 2077 | 2079 | ] | |
| 2078 | 2080 | ||
| 2079 | 2081 | [[package]] |
| @@ -5,3 +5,5 @@ edition.workspace = true | |||
| 5 | 5 | ||
| 6 | 6 | [dependencies] | |
| 7 | 7 | chrono = { workspace = true } | |
| 8 | + | serde = { workspace = true } | |
| 9 | + | sqlx = { workspace = true } |
| @@ -1,5 +1,6 @@ | |||
| 1 | 1 | //! Typed enums replacing raw string values across the codebase. | |
| 2 | 2 | ||
| 3 | + | use serde::Serialize; | |
| 3 | 4 | use std::fmt; | |
| 4 | 5 | ||
| 5 | 6 | // ============================================================================ | |
| @@ -7,13 +8,20 @@ use std::fmt; | |||
| 7 | 8 | // ============================================================================ | |
| 8 | 9 | ||
| 9 | 10 | /// Role a user holds within a community. | |
| 10 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 11 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)] | |
| 12 | + | #[sqlx(type_name = "TEXT", rename_all = "lowercase")] | |
| 11 | 13 | pub enum CommunityRole { | |
| 12 | 14 | Owner, | |
| 13 | 15 | Moderator, | |
| 14 | 16 | Member, | |
| 15 | 17 | } | |
| 16 | 18 | ||
| 19 | + | impl Serialize for CommunityRole { | |
| 20 | + | fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { | |
| 21 | + | serializer.serialize_str(self.as_str()) | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 17 | 25 | impl CommunityRole { | |
| 18 | 26 | /// Parse from the string stored in the database. Returns `None` for unrecognised values. | |
| 19 | 27 | pub fn from_db(s: &str) -> Option<Self> { | |
| @@ -54,7 +62,8 @@ impl fmt::Display for CommunityRole { | |||
| 54 | 62 | // ============================================================================ | |
| 55 | 63 | ||
| 56 | 64 | /// Type of community restriction. | |
| 57 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 65 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)] | |
| 66 | + | #[sqlx(type_name = "TEXT", rename_all = "lowercase")] | |
| 58 | 67 | pub enum BanType { | |
| 59 | 68 | Ban, | |
| 60 | 69 | Mute, | |
| @@ -80,7 +89,8 @@ impl fmt::Display for BanType { | |||
| 80 | 89 | // ============================================================================ | |
| 81 | 90 | ||
| 82 | 91 | /// Action recorded in the moderation log. | |
| 83 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 92 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)] | |
| 93 | + | #[sqlx(type_name = "TEXT", rename_all = "snake_case")] | |
| 84 | 94 | pub enum ModAction { | |
| 85 | 95 | PinThread, | |
| 86 | 96 | UnpinThread, |
| @@ -671,7 +671,7 @@ pub async fn delete_tag(pool: &PgPool, tag_id: Uuid) -> Result<(), sqlx::Error> | |||
| 671 | 671 | Ok(()) | |
| 672 | 672 | } | |
| 673 | 673 | ||
| 674 | - | /// Set the tags for a thread (delete existing + insert new, in a transaction). | |
| 674 | + | /// Set the tags for a thread (delete existing + batch insert new, in a transaction). | |
| 675 | 675 | #[tracing::instrument(skip_all)] | |
| 676 | 676 | pub async fn set_thread_tags( | |
| 677 | 677 | pool: &PgPool, | |
| @@ -683,12 +683,21 @@ pub async fn set_thread_tags( | |||
| 683 | 683 | .bind(thread_id) | |
| 684 | 684 | .execute(&mut *tx) | |
| 685 | 685 | .await?; | |
| 686 | - | for tag_id in tag_ids { | |
| 687 | - | sqlx::query("INSERT INTO thread_tags (thread_id, tag_id) VALUES ($1, $2)") | |
| 688 | - | .bind(thread_id) | |
| 689 | - | .bind(tag_id) | |
| 690 | - | .execute(&mut *tx) | |
| 691 | - | .await?; | |
| 686 | + | if !tag_ids.is_empty() { | |
| 687 | + | // Build multi-row INSERT: VALUES ($1, $2), ($1, $3), ... | |
| 688 | + | let mut sql = String::from("INSERT INTO thread_tags (thread_id, tag_id) VALUES "); | |
| 689 | + | for (i, _) in tag_ids.iter().enumerate() { | |
| 690 | + | if i > 0 { | |
| 691 | + | sql.push_str(", "); | |
| 692 | + | } | |
| 693 | + | // $1 = thread_id, $2.. = tag_ids | |
| 694 | + | sql.push_str(&format!("($1, ${})", i + 2)); | |
| 695 | + | } | |
| 696 | + | let mut query = sqlx::query(&sql).bind(thread_id); | |
| 697 | + | for tag_id in tag_ids { | |
| 698 | + | query = query.bind(tag_id); | |
| 699 | + | } | |
| 700 | + | query.execute(&mut *tx).await?; | |
| 692 | 701 | } | |
| 693 | 702 | tx.commit().await?; | |
| 694 | 703 | Ok(()) | |
| @@ -792,22 +801,30 @@ pub async fn insert_link_preview( | |||
| 792 | 801 | // Mention mutations | |
| 793 | 802 | // ============================================================================ | |
| 794 | 803 | ||
| 795 | - | /// Insert mention rows for a post. Ignores duplicates. | |
| 804 | + | /// Insert mention rows for a post (batch insert). Ignores duplicates. | |
| 796 | 805 | #[tracing::instrument(skip_all)] | |
| 797 | 806 | pub async fn insert_mentions( | |
| 798 | 807 | pool: &PgPool, | |
| 799 | 808 | post_id: Uuid, | |
| 800 | 809 | user_ids: &[Uuid], | |
| 801 | 810 | ) -> Result<(), sqlx::Error> { | |
| 811 | + | if user_ids.is_empty() { | |
| 812 | + | return Ok(()); | |
| 813 | + | } | |
| 814 | + | // Build multi-row INSERT: VALUES ($1, $2), ($1, $3), ... ON CONFLICT DO NOTHING | |
| 815 | + | let mut sql = String::from("INSERT INTO post_mentions (post_id, mentioned_user_id) VALUES "); | |
| 816 | + | for (i, _) in user_ids.iter().enumerate() { | |
| 817 | + | if i > 0 { | |
| 818 | + | sql.push_str(", "); | |
| 819 | + | } | |
| 820 | + | sql.push_str(&format!("($1, ${})", i + 2)); | |
| 821 | + | } | |
| 822 | + | sql.push_str(" ON CONFLICT DO NOTHING"); | |
| 823 | + | let mut query = sqlx::query(&sql).bind(post_id); | |
| 802 | 824 | for user_id in user_ids { | |
| 803 | - | sqlx::query( | |
| 804 | - | "INSERT INTO post_mentions (post_id, mentioned_user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", | |
| 805 | - | ) | |
| 806 | - | .bind(post_id) | |
| 807 | - | .bind(user_id) | |
| 808 | - | .execute(pool) | |
| 809 | - | .await?; | |
| 825 | + | query = query.bind(user_id); | |
| 810 | 826 | } | |
| 827 | + | query.execute(pool).await?; | |
| 811 | 828 | Ok(()) | |
| 812 | 829 | } | |
| 813 | 830 | ||
| @@ -816,18 +833,21 @@ pub async fn insert_mentions( | |||
| 816 | 833 | // ============================================================================ | |
| 817 | 834 | ||
| 818 | 835 | /// Toggle endorsement: insert if missing, delete if exists. Returns true if now endorsed. | |
| 836 | + | /// Uses a transaction to prevent race conditions between concurrent toggle requests. | |
| 819 | 837 | #[tracing::instrument(skip_all)] | |
| 820 | 838 | pub async fn toggle_endorsement( | |
| 821 | 839 | pool: &PgPool, | |
| 822 | 840 | post_id: Uuid, | |
| 823 | 841 | endorser_id: Uuid, | |
| 824 | 842 | ) -> Result<bool, sqlx::Error> { | |
| 843 | + | let mut tx = pool.begin().await?; | |
| 844 | + | ||
| 825 | 845 | let result = sqlx::query( | |
| 826 | 846 | "INSERT INTO post_endorsements (post_id, endorser_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", | |
| 827 | 847 | ) | |
| 828 | 848 | .bind(post_id) | |
| 829 | 849 | .bind(endorser_id) | |
| 830 | - | .execute(pool) | |
| 850 | + | .execute(&mut *tx) | |
| 831 | 851 | .await?; | |
| 832 | 852 | ||
| 833 | 853 | if result.rows_affected() == 0 { | |
| @@ -835,10 +855,12 @@ pub async fn toggle_endorsement( | |||
| 835 | 855 | sqlx::query("DELETE FROM post_endorsements WHERE post_id = $1 AND endorser_id = $2") | |
| 836 | 856 | .bind(post_id) | |
| 837 | 857 | .bind(endorser_id) | |
| 838 | - | .execute(pool) | |
| 858 | + | .execute(&mut *tx) | |
| 839 | 859 | .await?; | |
| 860 | + | tx.commit().await?; | |
| 840 | 861 | Ok(false) | |
| 841 | 862 | } else { | |
| 863 | + | tx.commit().await?; | |
| 842 | 864 | Ok(true) | |
| 843 | 865 | } | |
| 844 | 866 | } |
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | //! Database read queries — projection structs and SQL. | |
| 2 | 2 | ||
| 3 | 3 | use chrono::{DateTime, Utc}; | |
| 4 | + | use mt_core::types::{BanType, CommunityRole, ModAction}; | |
| 4 | 5 | use sqlx::PgPool; | |
| 5 | 6 | use uuid::Uuid; | |
| 6 | 7 | ||
| @@ -464,8 +465,8 @@ pub async fn get_user_role( | |||
| 464 | 465 | pool: &PgPool, | |
| 465 | 466 | user_id: Uuid, | |
| 466 | 467 | community_id: Uuid, | |
| 467 | - | ) -> Result<Option<String>, sqlx::Error> { | |
| 468 | - | let row: Option<(String,)> = sqlx::query_as( | |
| 468 | + | ) -> Result<Option<CommunityRole>, sqlx::Error> { | |
| 469 | + | let row: Option<(CommunityRole,)> = sqlx::query_as( | |
| 469 | 470 | "SELECT role FROM memberships WHERE user_id = $1 AND community_id = $2", | |
| 470 | 471 | ) | |
| 471 | 472 | .bind(user_id) | |
| @@ -504,7 +505,7 @@ pub async fn list_categories_for_settings( | |||
| 504 | 505 | pub struct MemberRow { | |
| 505 | 506 | pub username: String, | |
| 506 | 507 | pub display_name: Option<String>, | |
| 507 | - | pub role: String, | |
| 508 | + | pub role: CommunityRole, | |
| 508 | 509 | pub joined_at: DateTime<Utc>, | |
| 509 | 510 | } | |
| 510 | 511 | ||
| @@ -512,6 +513,8 @@ pub struct MemberRow { | |||
| 512 | 513 | pub async fn list_community_members( | |
| 513 | 514 | pool: &PgPool, | |
| 514 | 515 | community_id: Uuid, | |
| 516 | + | limit: i64, | |
| 517 | + | offset: i64, | |
| 515 | 518 | ) -> Result<Vec<MemberRow>, sqlx::Error> { | |
| 516 | 519 | sqlx::query_as::<_, MemberRow>( | |
| 517 | 520 | "SELECT u.username, | |
| @@ -528,13 +531,28 @@ pub async fn list_community_members( | |||
| 528 | 531 | WHEN 'member' THEN 2 | |
| 529 | 532 | ELSE 3 | |
| 530 | 533 | END, | |
| 531 | - | m.joined_at", | |
| 534 | + | m.joined_at | |
| 535 | + | LIMIT $2 OFFSET $3", | |
| 532 | 536 | ) | |
| 533 | 537 | .bind(community_id) | |
| 538 | + | .bind(limit) | |
| 539 | + | .bind(offset) | |
| 534 | 540 | .fetch_all(pool) | |
| 535 | 541 | .await | |
| 536 | 542 | } | |
| 537 | 543 | ||
| 544 | + | /// Count members in a community. | |
| 545 | + | #[tracing::instrument(skip_all)] | |
| 546 | + | pub async fn count_community_members( | |
| 547 | + | pool: &PgPool, | |
| 548 | + | community_id: Uuid, | |
| 549 | + | ) -> Result<i64, sqlx::Error> { | |
| 550 | + | sqlx::query_scalar("SELECT COUNT(*) FROM memberships WHERE community_id = $1") | |
| 551 | + | .bind(community_id) | |
| 552 | + | .fetch_one(pool) | |
| 553 | + | .await | |
| 554 | + | } | |
| 555 | + | ||
| 538 | 556 | #[tracing::instrument(skip_all)] | |
| 539 | 557 | pub async fn get_category_by_id( | |
| 540 | 558 | pool: &PgPool, | |
| @@ -612,13 +630,12 @@ pub async fn is_user_suspended( | |||
| 612 | 630 | pool: &PgPool, | |
| 613 | 631 | user_id: Uuid, | |
| 614 | 632 | ) -> Result<bool, sqlx::Error> { | |
| 615 | - | let count: i64 = sqlx::query_scalar( | |
| 616 | - | "SELECT COUNT(*) FROM users WHERE mnw_account_id = $1 AND suspended_at IS NOT NULL", | |
| 633 | + | sqlx::query_scalar( | |
| 634 | + | "SELECT EXISTS(SELECT 1 FROM users WHERE mnw_account_id = $1 AND suspended_at IS NOT NULL)", | |
| 617 | 635 | ) | |
| 618 | 636 | .bind(user_id) | |
| 619 | 637 | .fetch_one(pool) | |
| 620 | - | .await?; | |
| 621 | - | Ok(count > 0) | |
| 638 | + | .await | |
| 622 | 639 | } | |
| 623 | 640 | ||
| 624 | 641 | /// Check if user has an active ban in a community. | |
| @@ -628,16 +645,15 @@ pub async fn is_user_banned( | |||
| 628 | 645 | community_id: Uuid, | |
| 629 | 646 | user_id: Uuid, | |
| 630 | 647 | ) -> Result<bool, sqlx::Error> { | |
| 631 | - | let count: i64 = sqlx::query_scalar( | |
| 632 | - | "SELECT COUNT(*) FROM community_bans | |
| 648 | + | sqlx::query_scalar( | |
| 649 | + | "SELECT EXISTS(SELECT 1 FROM community_bans | |
| 633 | 650 | WHERE community_id = $1 AND user_id = $2 AND ban_type = 'ban' | |
| 634 | - | AND (expires_at IS NULL OR expires_at > now())", | |
| 651 | + | AND (expires_at IS NULL OR expires_at > now()))", | |
| 635 | 652 | ) | |
| 636 | 653 | .bind(community_id) | |
| 637 | 654 | .bind(user_id) | |
| 638 | 655 | .fetch_one(pool) | |
| 639 | - | .await?; | |
| 640 | - | Ok(count > 0) | |
| 656 | + | .await | |
| 641 | 657 | } | |
| 642 | 658 | ||
| 643 | 659 | /// Check if user has an active mute in a community. | |
| @@ -647,16 +663,15 @@ pub async fn is_user_muted( | |||
| 647 | 663 | community_id: Uuid, | |
| 648 | 664 | user_id: Uuid, | |
| 649 | 665 | ) -> Result<bool, sqlx::Error> { | |
| 650 | - | let count: i64 = sqlx::query_scalar( | |
| 651 | - | "SELECT COUNT(*) FROM community_bans | |
| 666 | + | sqlx::query_scalar( | |
| 667 | + | "SELECT EXISTS(SELECT 1 FROM community_bans | |
| 652 | 668 | WHERE community_id = $1 AND user_id = $2 AND ban_type = 'mute' | |
| 653 | - | AND (expires_at IS NULL OR expires_at > now())", | |
| 669 | + | AND (expires_at IS NULL OR expires_at > now()))", | |
| 654 | 670 | ) | |
| 655 | 671 | .bind(community_id) | |
| 656 | 672 | .bind(user_id) | |
| 657 | 673 | .fetch_one(pool) | |
| 658 | - | .await?; | |
| 659 | - | Ok(count > 0) | |
| 674 | + | .await | |
| 660 | 675 | } | |
| 661 | 676 | ||
| 662 | 677 | #[derive(sqlx::FromRow)] | |
| @@ -665,7 +680,7 @@ pub struct CommunityBanRow { | |||
| 665 | 680 | pub user_id: Uuid, | |
| 666 | 681 | pub username: String, | |
| 667 | 682 | pub display_name: Option<String>, | |
| 668 | - | pub ban_type: String, | |
| 683 | + | pub ban_type: BanType, | |
| 669 | 684 | pub reason: Option<String>, | |
| 670 | 685 | pub expires_at: Option<DateTime<Utc>>, | |
| 671 | 686 | pub created_at: DateTime<Utc>, | |
| @@ -699,7 +714,7 @@ pub async fn list_community_bans( | |||
| 699 | 714 | pub struct ModLogEntry { | |
| 700 | 715 | pub id: Uuid, | |
| 701 | 716 | pub actor_username: String, | |
| 702 | - | pub action: String, | |
| 717 | + | pub action: ModAction, | |
| 703 | 718 | pub target_username: Option<String>, | |
| 704 | 719 | pub reason: Option<String>, | |
| 705 | 720 | pub created_at: DateTime<Utc>, | |
| @@ -773,7 +788,7 @@ pub struct UserProfileRow { | |||
| 773 | 788 | pub username: String, | |
| 774 | 789 | pub display_name: Option<String>, | |
| 775 | 790 | pub avatar_url: Option<String>, | |
| 776 | - | pub role: String, | |
| 791 | + | pub role: CommunityRole, | |
| 777 | 792 | pub joined_at: DateTime<Utc>, | |
| 778 | 793 | pub post_count: i64, | |
| 779 | 794 | pub endorsement_count: i64, | |
| @@ -866,7 +881,7 @@ pub async fn get_user_activity_in_community( | |||
| 866 | 881 | pub struct UserMembershipSummary { | |
| 867 | 882 | pub community_name: String, | |
| 868 | 883 | pub community_slug: String, | |
| 869 | - | pub role: String, | |
| 884 | + | pub role: CommunityRole, | |
| 870 | 885 | pub joined_at: DateTime<Utc>, | |
| 871 | 886 | pub post_count: i64, | |
| 872 | 887 | } | |
| @@ -913,14 +928,14 @@ pub struct AdminCommunityRow { | |||
| 913 | 928 | pub suspension_reason: Option<String>, | |
| 914 | 929 | } | |
| 915 | 930 | ||
| 916 | - | /// List all communities for the admin dashboard. | |
| 931 | + | /// List all communities for the admin dashboard (capped at 500). | |
| 917 | 932 | #[tracing::instrument(skip_all)] | |
| 918 | 933 | pub async fn list_all_communities( | |
| 919 | 934 | pool: &PgPool, | |
| 920 | 935 | ) -> Result<Vec<AdminCommunityRow>, sqlx::Error> { | |
| 921 | 936 | sqlx::query_as::<_, AdminCommunityRow>( | |
| 922 | 937 | "SELECT id, name, slug, suspended_at, suspension_reason | |
| 923 | - | FROM communities ORDER BY name", | |
| 938 | + | FROM communities ORDER BY name LIMIT 500", | |
| 924 | 939 | ) | |
| 925 | 940 | .fetch_all(pool) | |
| 926 | 941 | .await | |
| @@ -1057,14 +1072,13 @@ pub async fn is_thread_tracked( | |||
| 1057 | 1072 | user_id: Uuid, | |
| 1058 | 1073 | thread_id: Uuid, | |
| 1059 | 1074 | ) -> Result<bool, sqlx::Error> { | |
| 1060 | - | let count: i64 = sqlx::query_scalar( | |
| 1061 | - | "SELECT COUNT(*) FROM tracked_threads WHERE user_id = $1 AND thread_id = $2", | |
| 1075 | + | sqlx::query_scalar( | |
| 1076 | + | "SELECT EXISTS(SELECT 1 FROM tracked_threads WHERE user_id = $1 AND thread_id = $2)", | |
| 1062 | 1077 | ) | |
| 1063 | 1078 | .bind(user_id) | |
| 1064 | 1079 | .bind(thread_id) | |
| 1065 | 1080 | .fetch_one(pool) | |
| 1066 | - | .await?; | |
| 1067 | - | Ok(count > 0) | |
| 1081 | + | .await | |
| 1068 | 1082 | } | |
| 1069 | 1083 | ||
| 1070 | 1084 | #[derive(sqlx::FromRow)] | |
| @@ -1296,14 +1310,13 @@ pub async fn has_user_flagged_post( | |||
| 1296 | 1310 | post_id: Uuid, | |
| 1297 | 1311 | flagger_id: Uuid, | |
| 1298 | 1312 | ) -> Result<bool, sqlx::Error> { | |
| 1299 | - | let count: i64 = sqlx::query_scalar( | |
| 1300 | - | "SELECT COUNT(*) FROM post_flags WHERE post_id = $1 AND flagger_id = $2", | |
| 1313 | + | sqlx::query_scalar( | |
| 1314 | + | "SELECT EXISTS(SELECT 1 FROM post_flags WHERE post_id = $1 AND flagger_id = $2)", | |
| 1301 | 1315 | ) | |
| 1302 | 1316 | .bind(post_id) | |
| 1303 | 1317 | .bind(flagger_id) | |
| 1304 | 1318 | .fetch_one(pool) | |
| 1305 | - | .await?; | |
| 1306 | - | Ok(count > 0) | |
| 1319 | + | .await | |
| 1307 | 1320 | } | |
| 1308 | 1321 | ||
| 1309 | 1322 | ||
| @@ -1466,13 +1479,12 @@ pub async fn thread_exists( | |||
| 1466 | 1479 | pool: &PgPool, | |
| 1467 | 1480 | thread_id: Uuid, | |
| 1468 | 1481 | ) -> Result<bool, sqlx::Error> { | |
| 1469 | - | let count: i64 = sqlx::query_scalar( | |
| 1470 | - | "SELECT COUNT(*) FROM threads WHERE id = $1", | |
| 1482 | + | sqlx::query_scalar( | |
| 1483 | + | "SELECT EXISTS(SELECT 1 FROM threads WHERE id = $1)", | |
| 1471 | 1484 | ) | |
| 1472 | 1485 | .bind(thread_id) | |
| 1473 | 1486 | .fetch_one(pool) | |
| 1474 | - | .await?; | |
| 1475 | - | Ok(count > 0) | |
| 1487 | + | .await | |
| 1476 | 1488 | } | |
| 1477 | 1489 | ||
| 1478 | 1490 | /// Count images uploaded by a user in the last N seconds (rate limiting). |
| @@ -0,0 +1,20 @@ | |||
| 1 | + | -- Performance indexes for hot query paths identified in Run 15 audit. | |
| 2 | + | ||
| 3 | + | -- Suspension check (called on every authenticated write request) | |
| 4 | + | CREATE INDEX IF NOT EXISTS idx_users_suspended | |
| 5 | + | ON users(suspended_at) WHERE suspended_at IS NOT NULL; | |
| 6 | + | ||
| 7 | + | -- Per-user rate limiting (called on every post/footnote creation) | |
| 8 | + | CREATE INDEX IF NOT EXISTS idx_posts_author_created | |
| 9 | + | ON posts(author_id, created_at); | |
| 10 | + | ||
| 11 | + | CREATE INDEX IF NOT EXISTS idx_post_footnotes_author_created | |
| 12 | + | ON post_footnotes(author_id, created_at); | |
| 13 | + | ||
| 14 | + | -- Image upload rate limiting | |
| 15 | + | CREATE INDEX IF NOT EXISTS idx_images_uploader_created | |
| 16 | + | ON images(uploader_id, created_at); | |
| 17 | + | ||
| 18 | + | -- Ban/mute type filtering (existing index lacks ban_type column) | |
| 19 | + | CREATE INDEX IF NOT EXISTS idx_community_bans_type_lookup | |
| 20 | + | ON community_bans(community_id, user_id, ban_type); |
| @@ -64,11 +64,15 @@ impl SessionUser { | |||
| 64 | 64 | } | |
| 65 | 65 | ||
| 66 | 66 | async fn save_to_session(&self, session: &Session) { | |
| 67 | - | let _ = session.insert(SESSION_USER_ID, self.user_id).await; | |
| 68 | - | let _ = session.insert(SESSION_USERNAME, &self.username).await; | |
| 69 | - | let _ = session | |
| 70 | - | .insert(SESSION_DISPLAY_NAME, &self.display_name) | |
| 71 | - | .await; | |
| 67 | + | if let Err(e) = session.insert(SESSION_USER_ID, self.user_id).await { | |
| 68 | + | tracing::error!(error = %e, "failed to save user_id to session"); | |
| 69 | + | } | |
| 70 | + | if let Err(e) = session.insert(SESSION_USERNAME, &self.username).await { | |
| 71 | + | tracing::error!(error = %e, "failed to save username to session"); | |
| 72 | + | } | |
| 73 | + | if let Err(e) = session.insert(SESSION_DISPLAY_NAME, &self.display_name).await { | |
| 74 | + | tracing::error!(error = %e, "failed to save display_name to session"); | |
| 75 | + | } | |
| 72 | 76 | } | |
| 73 | 77 | } | |
| 74 | 78 | ||
| @@ -149,8 +153,12 @@ pub async fn login( | |||
| 149 | 153 | let challenge = pkce_challenge(&verifier); | |
| 150 | 154 | let oauth_state = generate_state_nonce(); | |
| 151 | 155 | ||
| 152 | - | let _ = session.insert(SESSION_PKCE_VERIFIER, &verifier).await; | |
| 153 | - | let _ = session.insert(SESSION_OAUTH_STATE, &oauth_state).await; | |
| 156 | + | if let Err(e) = session.insert(SESSION_PKCE_VERIFIER, &verifier).await { | |
| 157 | + | tracing::error!(error = %e, "failed to save PKCE verifier to session"); | |
| 158 | + | } | |
| 159 | + | if let Err(e) = session.insert(SESSION_OAUTH_STATE, &oauth_state).await { | |
| 160 | + | tracing::error!(error = %e, "failed to save OAuth state to session"); | |
| 161 | + | } | |
| 154 | 162 | ||
| 155 | 163 | let url = format!( | |
| 156 | 164 | "{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256", | |
| @@ -189,8 +197,12 @@ pub async fn callback( | |||
| 189 | 197 | }; | |
| 190 | 198 | ||
| 191 | 199 | // Clean up OAuth session data | |
| 192 | - | let _ = session.remove::<String>(SESSION_OAUTH_STATE).await; | |
| 193 | - | let _ = session.remove::<String>(SESSION_PKCE_VERIFIER).await; | |
| 200 | + | if let Err(e) = session.remove::<String>(SESSION_OAUTH_STATE).await { | |
| 201 | + | tracing::warn!(error = %e, "failed to remove OAuth state from session"); | |
| 202 | + | } | |
| 203 | + | if let Err(e) = session.remove::<String>(SESSION_PKCE_VERIFIER).await { | |
| 204 | + | tracing::warn!(error = %e, "failed to remove PKCE verifier from session"); | |
| 205 | + | } | |
| 194 | 206 | ||
| 195 | 207 | // Exchange code for token (retry up to 2 attempts on network/5xx errors) | |
| 196 | 208 | let token_url = format!("{}/oauth/token", state.config.mnw_base_url); | |
| @@ -374,6 +386,8 @@ pub async fn callback( | |||
| 374 | 386 | /// `POST /auth/logout` — flush session, redirect home. | |
| 375 | 387 | #[tracing::instrument(skip_all)] | |
| 376 | 388 | pub async fn logout(session: Session) -> impl IntoResponse { | |
| 377 | - | let _ = session.flush().await; | |
| 389 | + | if let Err(e) = session.flush().await { | |
| 390 | + | tracing::warn!(error = %e, "failed to flush session on logout"); | |
| 391 | + | } | |
| 378 | 392 | Redirect::to("/") | |
| 379 | 393 | } |
| @@ -128,7 +128,7 @@ pub(in crate::routes) async fn community_members( | |||
| 128 | 128 | ||
| 129 | 129 | check_community_access(&state.db, &community, session_user.as_ref().map(|u| u.user_id)).await?; | |
| 130 | 130 | ||
| 131 | - | let db_members = mt_db::queries::list_community_members(&state.db, community.id) | |
| 131 | + | let db_members = mt_db::queries::list_community_members(&state.db, community.id, 500, 0) | |
| 132 | 132 | .await | |
| 133 | 133 | .map_err(|e| { | |
| 134 | 134 | tracing::error!(error = ?e, "db error listing members"); | |
| @@ -140,7 +140,7 @@ pub(in crate::routes) async fn community_members( | |||
| 140 | 140 | .map(|m| MemberListRow { | |
| 141 | 141 | display_name: m.display_name.unwrap_or_else(|| m.username.clone()), | |
| 142 | 142 | username: m.username, | |
| 143 | - | role: m.role, | |
| 143 | + | role: m.role.to_string(), | |
| 144 | 144 | joined: mt_core::time_format::relative_timestamp(m.joined_at), | |
| 145 | 145 | }) | |
| 146 | 146 | .collect(); | |
| @@ -389,7 +389,7 @@ pub(in crate::routes) async fn user_profile( | |||
| 389 | 389 | display_name: profile.display_name.unwrap_or_else(|| profile.username.clone()), | |
| 390 | 390 | username: profile.username, | |
| 391 | 391 | avatar_url: profile.avatar_url, | |
| 392 | - | role: profile.role, | |
| 392 | + | role: profile.role.to_string(), | |
| 393 | 393 | joined: mt_core::time_format::relative_timestamp(profile.joined_at), | |
| 394 | 394 | post_count: profile.post_count, | |
| 395 | 395 | endorsement_count: profile.endorsement_count, |
| @@ -89,13 +89,12 @@ pub(crate) async fn get_role( | |||
| 89 | 89 | user_id: Uuid, | |
| 90 | 90 | community_id: Uuid, | |
| 91 | 91 | ) -> Result<Option<CommunityRole>, Response> { | |
| 92 | - | let role_str = mt_db::queries::get_user_role(db, user_id, community_id) | |
| 92 | + | mt_db::queries::get_user_role(db, user_id, community_id) | |
| 93 | 93 | .await | |
| 94 | 94 | .map_err(|e| { | |
| 95 | 95 | tracing::error!(error = ?e, "db error fetching role"); | |
| 96 | 96 | (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() | |
| 97 | - | })?; | |
| 98 | - | Ok(role_str.and_then(|s| CommunityRole::from_db(&s))) | |
| 97 | + | }) | |
| 99 | 98 | } | |
| 100 | 99 | ||
| 101 | 100 | /// Look up a user by username, returning 422 if not found. |
| @@ -176,7 +176,7 @@ pub(super) async fn moderation_page( | |||
| 176 | 176 | .map(|b| BanListRow { | |
| 177 | 177 | username: b.username, | |
| 178 | 178 | display_name: b.display_name, | |
| 179 | - | ban_type: b.ban_type, | |
| 179 | + | ban_type: b.ban_type.to_string(), | |
| 180 | 180 | reason: b.reason, | |
| 181 | 181 | expires: b.expires_at.map(mt_core::time_format::relative_timestamp), | |
| 182 | 182 | created: mt_core::time_format::relative_timestamp(b.created_at), | |
| @@ -427,7 +427,7 @@ pub(super) async fn mod_log_page( | |||
| 427 | 427 | .into_iter() | |
| 428 | 428 | .map(|e| ModLogRow { | |
| 429 | 429 | actor: e.actor_username, | |
| 430 | - | action: e.action, | |
| 430 | + | action: e.action.to_string(), | |
| 431 | 431 | target: e.target_username, | |
| 432 | 432 | reason: e.reason, | |
| 433 | 433 | timestamp: mt_core::time_format::relative_timestamp(e.created_at), |
| @@ -89,7 +89,7 @@ pub(super) async fn upload_image_handler( | |||
| 89 | 89 | // Upload to S3 | |
| 90 | 90 | s3.upload(&s3_key, validated_ct, data).await.map_err(|e| { | |
| 91 | 91 | tracing::error!(error = %e, "S3 upload failed"); | |
| 92 | - | (StatusCode::INTERNAL_SERVER_ERROR, "Upload failed.").into_response() | |
| 92 | + | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 93 | 93 | })?; | |
| 94 | 94 | ||
| 95 | 95 | // Record in DB |
| @@ -250,6 +250,7 @@ impl TestHarness { | |||
| 250 | 250 | author_id, | |
| 251 | 251 | body, | |
| 252 | 252 | &format!("<p>{}</p>", body), | |
| 253 | + | false, | |
| 253 | 254 | ) | |
| 254 | 255 | .await | |
| 255 | 256 | .expect("Failed to create post"); |
| @@ -169,6 +169,7 @@ async fn test_membership_summary_post_count() { | |||
| 169 | 169 | user_id, | |
| 170 | 170 | "Second post", | |
| 171 | 171 | "<p>Second post</p>", | |
| 172 | + | true, | |
| 172 | 173 | ) | |
| 173 | 174 | .await | |
| 174 | 175 | .unwrap(); | |
| @@ -179,6 +180,7 @@ async fn test_membership_summary_post_count() { | |||
| 179 | 180 | user_id, | |
| 180 | 181 | "Third post", | |
| 181 | 182 | "<p>Third post</p>", | |
| 183 | + | true, | |
| 182 | 184 | ) | |
| 183 | 185 | .await | |
| 184 | 186 | .unwrap(); | |
| @@ -190,7 +192,7 @@ async fn test_membership_summary_post_count() { | |||
| 190 | 192 | assert_eq!(summaries.len(), 1, "Should have one membership"); | |
| 191 | 193 | assert_eq!(summaries[0].community_name, "Writers Guild"); | |
| 192 | 194 | assert_eq!(summaries[0].community_slug, "writers"); | |
| 193 | - | assert_eq!(summaries[0].role, "member"); | |
| 195 | + | assert_eq!(summaries[0].role, mt_core::types::CommunityRole::Member); | |
| 194 | 196 | assert_eq!( | |
| 195 | 197 | summaries[0].post_count, 3, | |
| 196 | 198 | "Should count all 3 posts (initial + 2 replies)" |
| @@ -400,7 +400,7 @@ async fn create_post_bumps_thread_activity() { | |||
| 400 | 400 | .unwrap(); | |
| 401 | 401 | ||
| 402 | 402 | // Create post should bump last_activity_at | |
| 403 | - | mt_db::mutations::create_post(&h.db, thread_id, author, "test", "<p>test</p>") | |
| 403 | + | mt_db::mutations::create_post(&h.db, thread_id, author, "test", "<p>test</p>", true) | |
| 404 | 404 | .await | |
| 405 | 405 | .unwrap(); | |
| 406 | 406 | ||
| @@ -439,7 +439,7 @@ async fn toggle_endorsement_db_roundtrip() { | |||
| 439 | 439 | .await | |
| 440 | 440 | .unwrap(); | |
| 441 | 441 | let post_id = mt_db::mutations::create_post( | |
| 442 | - | &h.db, thread_id, author, "content", "<p>content</p>", | |
| 442 | + | &h.db, thread_id, author, "content", "<p>content</p>", true, | |
| 443 | 443 | ) | |
| 444 | 444 | .await | |
| 445 | 445 | .unwrap(); | |
| @@ -488,7 +488,7 @@ async fn insert_flag_idempotent_per_user() { | |||
| 488 | 488 | .await | |
| 489 | 489 | .unwrap(); | |
| 490 | 490 | let post_id = mt_db::mutations::create_post( | |
| 491 | - | &h.db, thread_id, author, "content", "<p>content</p>", | |
| 491 | + | &h.db, thread_id, author, "content", "<p>content</p>", true, | |
| 492 | 492 | ) | |
| 493 | 493 | .await | |
| 494 | 494 | .unwrap(); | |
| @@ -631,7 +631,7 @@ async fn insert_link_preview_dedup() { | |||
| 631 | 631 | .await | |
| 632 | 632 | .unwrap(); | |
| 633 | 633 | let post_id = mt_db::mutations::create_post( | |
| 634 | - | &h.db, thread_id, author, "content", "<p>content</p>", | |
| 634 | + | &h.db, thread_id, author, "content", "<p>content</p>", true, | |
| 635 | 635 | ) | |
| 636 | 636 | .await | |
| 637 | 637 | .unwrap(); | |
| @@ -684,7 +684,7 @@ async fn insert_mentions_dedup() { | |||
| 684 | 684 | .await | |
| 685 | 685 | .unwrap(); | |
| 686 | 686 | let post_id = mt_db::mutations::create_post( | |
| 687 | - | &h.db, thread_id, author, "content", "<p>content</p>", | |
| 687 | + | &h.db, thread_id, author, "content", "<p>content</p>", true, | |
| 688 | 688 | ) | |
| 689 | 689 | .await | |
| 690 | 690 | .unwrap(); |
| @@ -122,6 +122,7 @@ async fn sort_by_replies_works() { | |||
| 122 | 122 | user_id, | |
| 123 | 123 | &format!("reply {i}"), | |
| 124 | 124 | &format!("<p>reply {i}</p>"), | |
| 125 | + | true, | |
| 125 | 126 | ) | |
| 126 | 127 | .await | |
| 127 | 128 | .unwrap(); |
| @@ -38,6 +38,7 @@ async fn profile_page_shows_activity() { | |||
| 38 | 38 | user_id, | |
| 39 | 39 | "A reply", | |
| 40 | 40 | "<p>A reply</p>", | |
| 41 | + | true, | |
| 41 | 42 | ) | |
| 42 | 43 | .await | |
| 43 | 44 | .unwrap(); |
| @@ -110,7 +110,7 @@ async fn unread_count_tracking() { | |||
| 110 | 110 | .execute(&h.db) | |
| 111 | 111 | .await | |
| 112 | 112 | .unwrap(); | |
| 113 | - | mt_db::mutations::create_post(&h.db, thread_id, other_id, "New reply", "<p>New reply</p>") | |
| 113 | + | mt_db::mutations::create_post(&h.db, thread_id, other_id, "New reply", "<p>New reply</p>", true) | |
| 114 | 114 | .await | |
| 115 | 115 | .unwrap(); | |
| 116 | 116 |