Skip to main content

max / makenotwork

multithreaded: add the deleted-threads surface so a deleted thread can come back Finishes the gap left open by 97fb193a. Restore existed but a deleted thread could not be reached to use it: every thread loader filters deleted_at IS NOT NULL, so the thread page 404s and the post-level restore control lives on the page that no longer renders. The delete was effectively permanent even though every row survived. GET /p/{slug}/moderation/deleted lists a community's soft-deleted threads, mod/owner only, capped and truncation-flagged like the bans and flags reads beside it. POST .../moderation/threads/{id}/restore brings one back. Restore is thread-level because a thread reaches deleted_at two ways and the reversal has to cover both. mod_remove_post_cascade sets it as a consequence of removing the opening post; delete_thread_handler sets it directly and leaves every post alone. restore_thread_cascade un-deletes the thread and brings the opening post back only when that post was removed, so the first case does not come back headless and the second does not "restore" a post that was fine all along. Logged as restore_thread, plus restore_post when the opening post came back, mirroring the removal's delete_thread pair. Scoping goes through get_deleted_thread_in_community rather than CommunityScope, since every scoped thread loader filters deleted rows out and would 404 the rows this acts on. The community id is part of the lookup, so there is no unscoped by-id variant to reach for; a cross-community restore attempt 404s, and there is a test for it.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-27 14:54 UTC
Signed with PGP, not checked
Commit: 307a3fe1c68f68a09ecc2c08bbfcdabb91c3ed4f
Parent: 327b35b
13 files changed, +715 insertions, -1 deletion
@@ -312,6 +312,14 @@
312 312 )
313 313 .route("/p/{slug}/moderation", get(moderation::moderation_page))
314 314 .route("/p/{slug}/moderation/log", get(moderation::mod_log_page))
315 + .route(
316 + "/p/{slug}/moderation/deleted",
317 + get(moderation::deleted_threads_page),
318 + )
319 + .route(
320 + "/p/{slug}/moderation/threads/{thread_id}/restore",
321 + post(moderation::restore_thread_handler),
322 + )
315 323 .route("/p/{slug}/{category}", get(forum::category))
316 324 .route("/p/{slug}/{category}/new", get(forum::new_thread))
317 325 .route("/p/{slug}/{category}/{thread_id}", get(forum::thread))
@@ -12,7 +12,8 @@
12 12 use crate::auth::RequireUser;
13 13 use crate::csrf;
14 14 use crate::templates::{
15 - BanListRow, FlagViewRow, ModLogRow, ModLogTemplate, ModerationTemplate, Pagination,
15 + BanListRow, DeletedThreadViewRow, DeletedThreadsTemplate, FlagViewRow, ModLogRow,
16 + ModLogTemplate, ModerationTemplate, Pagination,
16 17 };
17 18
18 19 use mt_core::types::{BanType, ModAction};
@@ -535,6 +536,123 @@
535 536 )))
536 537 }
537 538
539 + /// The surface a soft-deleted thread can be restored from.
540 + ///
541 + /// Every thread loader filters `deleted_at IS NOT NULL`, so a deleted thread is
542 + /// unreachable by URL: its own page 404s and it is gone from every listing.
543 + /// Without this page the delete is effectively permanent even though the rows
544 + /// are all still there, and the post-level restore control cannot help, since it
545 + /// lives on the thread page that no longer renders.
546 + #[tracing::instrument(skip_all)]
547 + pub(super) async fn deleted_threads_page(
548 + axum::extract::State(state): axum::extract::State<AppState>,
549 + Path(slug): Path<String>,
550 + session: Session,
551 + RequireUser(user): RequireUser,
552 + ) -> Result<impl IntoResponse, Response> {
553 + let csrf_token = Some(csrf::get_or_create_token(&session).await);
554 + let (community, _role) = require_mod_or_owner(&state, &slug, &user).await?;
555 +
556 + // Same cap and truncation signal as the bans/flags reads on the moderation
557 + // page, for the same reason.
558 + const DELETED_LIST_CAP: usize = 200;
559 +
560 + let mut db_threads =
561 + mt_db::queries::list_deleted_threads(&state.db, community.id, DELETED_LIST_CAP as i64 + 1)
562 + .await
563 + .map_err(db_error)?;
564 + let threads_truncated = db_threads.len() > DELETED_LIST_CAP;
565 + db_threads.truncate(DELETED_LIST_CAP);
566 +
567 + let threads = db_threads
568 + .into_iter()
569 + .map(|t| DeletedThreadViewRow {
570 + thread_id: t.id.to_string(),
571 + title: t.title,
572 + category_slug: t.category_slug,
573 + author_username: t.author_username,
574 + deleted: mt_core::time_format::relative_timestamp(t.deleted_at),
575 + op_removed: t.op_removed,
576 + })
577 + .collect();
578 +
579 + Ok(DeletedThreadsTemplate {
580 + csrf_token,
581 + session_user: Some(template_user(&user, state.config.platform_admin_id)),
582 + mnw_base_url: state.config.mnw_base_url.clone(),
583 + community_name: community.name,
584 + community_slug: slug,
585 + threads,
586 + threads_truncated,
587 + })
588 + }
589 +
590 + /// Restore a soft-deleted thread, and its opening post when the removal of that
591 + /// post is what deleted the thread.
592 + ///
593 + /// Scoped by looking the thread up within the community rather than through
594 + /// `CommunityScope`, because every scoped thread loader filters deleted threads
595 + /// out and would 404 the very rows this acts on. The community-id check in the
596 + /// query is what keeps a mod of one community from restoring another's thread.
597 + #[tracing::instrument(skip_all)]
598 + pub(super) async fn restore_thread_handler(
599 + axum::extract::State(state): axum::extract::State<AppState>,
600 + Path((slug, thread_id_str)): Path<(String, String)>,
601 + RequireUser(user): RequireUser,
602 + ) -> Result<Redirect, Response> {
603 + let (community, _role) = require_mod_or_owner(&state, &slug, &user).await?;
604 + let thread_id = parse_uuid(&thread_id_str)?;
605 +
606 + let target =
607 + mt_db::queries::get_deleted_thread_in_community(&state.db, thread_id, community.id)
608 + .await
609 + .map_err(db_error)?
610 + .ok_or_else(|| (StatusCode::NOT_FOUND, "Not found").into_response())?;
611 +
612 + let mut tx = begin_tx(&state.db).await?;
613 + let restore = mt_db::mutations::restore_thread_cascade(&mut tx, thread_id)
614 + .await
615 + .map_err(db_error)?;
616 +
617 + if !restore.thread_restored {
618 + commit_tx(tx).await?;
619 + return Ok(Redirect::to(&format!(
620 + "/p/{slug}/moderation/deleted?toast=Thread+was+not+deleted"
621 + )));
622 + }
623 +
624 + audit(
625 + &mut tx,
626 + Some(community.id),
627 + ModActor::User(user.user_id),
628 + ModAction::RestoreThread,
629 + Some(target.author_id),
630 + Some(thread_id),
631 + None,
632 + )
633 + .await?;
634 +
635 + // Mirrors the removal, which logged RemovePost alongside DeleteThread.
636 + if restore.op_restored {
637 + audit(
638 + &mut tx,
639 + Some(community.id),
640 + ModActor::User(user.user_id),
641 + ModAction::RestorePost,
642 + Some(target.author_id),
643 + Some(thread_id),
644 + None,
645 + )
646 + .await?;
647 + }
648 + commit_tx(tx).await?;
649 +
650 + Ok(Redirect::to(&format!(
651 + "/p/{slug}/{}/{thread_id}?toast=Thread+restored",
652 + target.category_slug
653 + )))
654 + }
655 +
538 656 #[tracing::instrument(skip_all)]
539 657 pub(super) async fn mod_log_page(
540 658 axum::extract::State(state): axum::extract::State<AppState>,
@@ -54,6 +54,7 @@
54 54 UserProfileTemplate,
55 55 ModerationTemplate,
56 56 ModLogTemplate,
57 + DeletedThreadsTemplate,
57 58 AdminDashboardTemplate,
58 59 TrackedThreadsTemplate,
59 60 TrackingInfoTemplate,
@@ -594,6 +594,32 @@
594 594 pub is_owner: bool,
595 595 }
596 596
597 + /// Soft-deleted thread awaiting restore or nothing.
598 + pub struct DeletedThreadViewRow {
599 + pub thread_id: String,
600 + pub title: String,
601 + pub category_slug: String,
602 + pub author_username: String,
603 + pub deleted: String,
604 + /// Restoring also brings the opening post back, worth saying on the button
605 + /// so the mod knows the scope of what they are undoing.
606 + pub op_removed: bool,
607 + }
608 +
609 + /// Deleted-threads page (mod/owner only).
610 + #[derive(Template)]
611 + #[template(path = "pages/deleted_threads.html")]
612 + pub struct DeletedThreadsTemplate {
613 + pub csrf_token: CsrfTokenOption,
614 + pub session_user: Option<TemplateSessionUser>,
615 + pub mnw_base_url: std::sync::Arc<str>,
616 + pub community_name: String,
617 + pub community_slug: String,
618 + pub threads: Vec<DeletedThreadViewRow>,
619 + /// True when more deleted threads exist than the page renders (capped read).
620 + pub threads_truncated: bool,
621 + }
622 +
597 623 /// Mod log page (mod/owner only).
598 624 #[derive(Template)]
599 625 #[template(path = "pages/mod_log.html")]
@@ -17,6 +17,7 @@
17 17 </div>
18 18 <div class="page-header">
19 19 <h1>Moderation</h1>
20 + <a href="/p/{{ community_slug }}/moderation/deleted" class="btn-secondary">deleted threads</a>
20 21 <a href="/p/{{ community_slug }}/moderation/log" class="btn-secondary">mod log</a>
21 22 </div>
22 23
@@ -663,3 +663,244 @@
663 663 "thread must not be deleted via a mismatched community slug"
664 664 );
665 665 }
666 +
667 + // Deleted-threads surface
668 +
669 + /// The gap this closes: removing an OP cascades the thread to `deleted_at`, and
670 + /// every thread loader filters on that, so the thread page 404s and the
671 + /// post-level restore control (which lives on that page) is unreachable. Without
672 + /// this surface the delete is permanent even though every row survives.
673 + #[tokio::test]
674 + async fn deleted_thread_can_be_restored_after_op_removal_cascade() {
675 + let mut h = TestHarness::new().await;
676 + let author_id = h.login_as("cascadeauthor").await;
677 + let comm_id = h.create_community("Test", "test").await;
678 + let cat_id = h.create_category(comm_id, "General", "general").await;
679 + h.add_membership(author_id, comm_id, "member").await;
680 +
681 + let thread_id = h
682 + .create_thread_with_post(cat_id, author_id, "Cascade Restore", "Opening content")
683 + .await;
684 + let op_id = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
685 + .await
686 + .unwrap()[0]
687 + .id;
688 +
689 + let mod_id = h.login_as("cascaderestoremod").await;
690 + h.add_membership(mod_id, comm_id, "moderator").await;
691 + h.client.get(&format!("/p/test/general/{thread_id}")).await;
692 + h.client
693 + .post_form(
694 + &format!("/p/test/general/{thread_id}/posts/{op_id}/remove"),
695 + "",
696 + )
697 + .await;
698 +
699 + // The thread page is gone, so the post-level restore control cannot be reached.
700 + let resp = h.client.get(&format!("/p/test/general/{thread_id}")).await;
701 + assert_eq!(
702 + resp.status,
703 + axum::http::StatusCode::NOT_FOUND,
704 + "cascade-deleted thread must 404"
705 + );
706 +
707 + // The deleted-threads page lists it.
708 + let resp = h.client.get("/p/test/moderation/deleted").await;
709 + assert!(resp.status.is_success());
710 + assert!(
711 + resp.text.contains("Cascade Restore"),
712 + "deleted thread must be listed for restore"
713 + );
714 +
715 + let resp = h
716 + .client
717 + .post_form(
718 + &format!("/p/test/moderation/threads/{thread_id}/restore"),
719 + "",
720 + )
721 + .await;
722 + assert!(
723 + resp.status.is_redirection(),
724 + "Expected redirect, got {}",
725 + resp.status
726 + );
727 +
728 + let (thread_deleted, op_removed): (bool, bool) = sqlx::query_as(
729 + "SELECT (SELECT deleted_at IS NOT NULL FROM threads WHERE id = $1),
730 + (SELECT removed_at IS NOT NULL FROM posts WHERE id = $2)",
731 + )
732 + .bind(thread_id)
733 + .bind(op_id)
734 + .fetch_one(&h.db)
735 + .await
736 + .unwrap();
737 + assert!(!thread_deleted, "thread must be restored");
738 + assert!(
739 + !op_removed,
740 + "the opening post must come back with the thread, not leave it headless"
741 + );
742 +
743 + let resp = h.client.get(&format!("/p/test/general/{thread_id}")).await;
744 + assert!(resp.status.is_success(), "restored thread must load again");
745 + }
746 +
747 + /// The other delete path: a mod deletes the thread directly and the opening post
748 + /// is never removed. Restore must not "restore" a post that was fine all along.
749 + #[tokio::test]
750 + async fn directly_deleted_thread_restores_without_touching_the_op() {
751 + let mut h = TestHarness::new().await;
752 + let author_id = h.login_as("directdelauthor").await;
753 + let comm_id = h.create_community("Test", "test").await;
754 + let cat_id = h.create_category(comm_id, "General", "general").await;
755 + h.add_membership(author_id, comm_id, "member").await;
756 +
757 + let thread_id = h
758 + .create_thread_with_post(cat_id, author_id, "Direct Delete", "Opening content")
759 + .await;
760 + let op_id = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
761 + .await
762 + .unwrap()[0]
763 + .id;
764 +
765 + let mod_id = h.login_as("directdelmod").await;
766 + h.add_membership(mod_id, comm_id, "moderator").await;
767 + h.client.get(&format!("/p/test/general/{thread_id}")).await;
768 + h.client
769 + .post_form(&format!("/p/test/general/{thread_id}/delete"), "")
770 + .await;
771 +
772 + let deleted: bool =
773 + sqlx::query_scalar("SELECT deleted_at IS NOT NULL FROM threads WHERE id = $1")
774 + .bind(thread_id)
775 + .fetch_one(&h.db)
776 + .await
777 + .unwrap();
778 + assert!(deleted, "thread should be soft-deleted");
779 +
780 + h.client
781 + .post_form(
782 + &format!("/p/test/moderation/threads/{thread_id}/restore"),
783 + "",
784 + )
785 + .await;
786 +
787 + let (thread_deleted, op_removed): (bool, bool) = sqlx::query_as(
788 + "SELECT (SELECT deleted_at IS NOT NULL FROM threads WHERE id = $1),
789 + (SELECT removed_at IS NOT NULL FROM posts WHERE id = $2)",
790 + )
791 + .bind(thread_id)
792 + .bind(op_id)
793 + .fetch_one(&h.db)
794 + .await
795 + .unwrap();
796 + assert!(!thread_deleted, "thread must be restored");
797 + assert!(!op_removed, "the OP was never removed and must stay live");
798 +
799 + let rows: i64 = sqlx::query_scalar(
800 + "SELECT COUNT(*) FROM mod_log WHERE target_id = $1 AND action = 'restore_post'",
801 + )
802 + .bind(thread_id)
803 + .fetch_one(&h.db)
804 + .await
805 + .unwrap();
806 + assert_eq!(
807 + rows, 0,
808 + "no post was restored, so no restore_post row should be logged"
809 + );
810 + }
811 +
812 + #[tokio::test]
813 + async fn member_cannot_see_or_restore_deleted_threads() {
814 + let mut h = TestHarness::new().await;
815 + let author_id = h.login_as("nodelauthor").await;
816 + let comm_id = h.create_community("Test", "test").await;
817 + let cat_id = h.create_category(comm_id, "General", "general").await;
818 + h.add_membership(author_id, comm_id, "member").await;
819 +
820 + let thread_id = h
821 + .create_thread_with_post(cat_id, author_id, "Hidden", "Opening content")
822 + .await;
823 + sqlx::query("UPDATE threads SET deleted_at = now() WHERE id = $1")
824 + .bind(thread_id)
825 + .execute(&h.db)
826 + .await
827 + .unwrap();
828 +
829 + let member_id = h.login_as("nodelmember").await;
830 + h.add_membership(member_id, comm_id, "member").await;
831 +
832 + let resp = h.client.get("/p/test/moderation/deleted").await;
833 + assert_eq!(
834 + resp.status,
835 + axum::http::StatusCode::FORBIDDEN,
836 + "a member must not see the deleted-threads list"
837 + );
838 +
839 + let resp = h
840 + .client
841 + .post_form(
842 + &format!("/p/test/moderation/threads/{thread_id}/restore"),
843 + "",
844 + )
845 + .await;
846 + assert_eq!(
847 + resp.status,
848 + axum::http::StatusCode::FORBIDDEN,
849 + "a member must not restore a thread"
850 + );
851 +
852 + let deleted: bool =
853 + sqlx::query_scalar("SELECT deleted_at IS NOT NULL FROM threads WHERE id = $1")
854 + .bind(thread_id)
855 + .fetch_one(&h.db)
856 + .await
857 + .unwrap();
858 + assert!(deleted, "thread must stay deleted");
859 + }
860 +
861 + /// The community-id check in `get_deleted_thread_in_community` is what stops a
862 + /// mod of one community restoring another's thread; `CommunityScope` cannot do
863 + /// it here because every scoped thread loader filters deleted rows out.
864 + #[tokio::test]
865 + async fn mod_cannot_restore_a_thread_in_another_community() {
866 + let mut h = TestHarness::new().await;
867 + let author_id = h.login_as("xcommauthor").await;
868 + let comm_a = h.create_community("Alpha", "alpha").await;
869 + let cat_a = h.create_category(comm_a, "General", "general").await;
870 + h.add_membership(author_id, comm_a, "member").await;
871 +
872 + let thread_id = h
873 + .create_thread_with_post(cat_a, author_id, "Alpha Thread", "Opening content")
874 + .await;
875 + sqlx::query("UPDATE threads SET deleted_at = now() WHERE id = $1")
876 + .bind(thread_id)
877 + .execute(&h.db)
878 + .await
879 + .unwrap();
880 +
881 + let comm_b = h.create_community("Beta", "beta").await;
882 + h.create_category(comm_b, "General", "general").await;
883 + let mod_b = h.login_as("betamod").await;
884 + h.add_membership(mod_b, comm_b, "moderator").await;
885 +
886 + let resp = h
887 + .client
888 + .post_form(
889 + &format!("/p/beta/moderation/threads/{thread_id}/restore"),
890 + "",
891 + )
892 + .await;
893 + assert_eq!(
894 + resp.status,
895 + axum::http::StatusCode::NOT_FOUND,
896 + "beta's mod must not reach alpha's thread"
897 + );
898 +
899 + let deleted: bool =
900 + sqlx::query_scalar("SELECT deleted_at IS NOT NULL FROM threads WHERE id = $1")
901 + .bind(thread_id)
902 + .fetch_one(&h.db)
903 + .await
904 + .unwrap();
905 + assert!(deleted, "alpha's thread must stay deleted");
906 + }