Skip to main content

max / makenotwork

multithreaded: add restore as its own mod action Closes problem a5670731. Auto-hide could hide a post and nothing could ever bring it back: no code path cleared posts.removed_at. A mod reading a bad-faith brigade could only dismiss the flags, which resolves the flag rows and leaves removed_at set, so the post stayed dark while the mod queue went empty and implied it had been dealt with. Restore is its own action rather than a side effect of dismissing a flag. The hide and the un-hide are separate decisions, and a log recording auto_hide_post with nothing after it cannot be told apart from one where the mod agreed with the hide. Dismiss deliberately still leaves a hidden post hidden. restore_post_cascade mirrors mod_remove_post_cascade arm for arm, including the OP identity rule, so restoring an OP whose thread was soft-deleted with it brings the thread back on the same transaction, logged as restore_thread against the removal's delete_thread. It does not touch posts.deleted_at: the author's soft-delete is a different actor's decision and a moderator undoing a moderator action must not undo an author's. Restore resolves the post's outstanding flags. Without that it undoes itself, since auto_hide_if_threshold_met counts unresolved flags and a post restored while still over the threshold re-hides on the next flag with no way for the mod to break the loop. Known gap, not addressed here: a mod-removed OP cascades its thread to deleted_at and every thread loader filters on that, so it has no surface to be restored from. Auto-hidden OPs never cascade and stay reachable, which is the case the problem was filed about. A deleted-threads surface would need its own design.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-27 14:19 UTC
Signed with PGP, not checked
Commit: 97fb193aea78ec02836c5c3799e8d7907a1aee3c
Parent: 7e0bef0
10 files changed, +431 insertions, -3 deletions
@@ -160,6 +160,10 @@
160 160 "/p/{slug}/{category}/{thread_id}/posts/{post_id}/remove",
161 161 post(moderation::mod_remove_post_handler),
162 162 )
163 + .route(
164 + "/p/{slug}/{category}/{thread_id}/posts/{post_id}/restore",
165 + post(moderation::mod_restore_post_handler),
166 + )
163 167 .route(
164 168 "/p/{slug}/{category}/{thread_id}/posts/{post_id}/flag",
165 169 post(flagging::flag_post_handler),
@@ -174,6 +174,81 @@
174 174 )))
175 175 }
176 176
177 + /// Reverse a removal, whether a moderator made it or the flag threshold did.
178 + ///
179 + /// Its own mod-log action rather than a side effect of dismissing a flag: the
180 + /// hide and the un-hide are separate decisions, and a log that records
181 + /// `auto_hide_post` with nothing after it cannot be told apart from one where
182 + /// the mod agreed with the hide. Dismissing a flag deliberately still leaves a
183 + /// hidden post hidden.
184 + ///
185 + /// Outstanding flags on the post are resolved as `dismissed` on the same
186 + /// transaction. Without that the restore undoes itself: `auto_hide_if_threshold_met`
187 + /// counts unresolved flags, so a post restored while still over the threshold
188 + /// re-hides on the very next flag, and the mod has no way to break the loop.
189 + #[tracing::instrument(skip_all)]
190 + pub(super) async fn mod_restore_post_handler(
191 + axum::extract::State(state): axum::extract::State<AppState>,
192 + Path((slug, category_slug, thread_id_str, post_id_str)): Path<(String, String, String, String)>,
193 + RequireUser(user): RequireUser,
194 + ) -> Result<Redirect, Response> {
195 + let scope = CommunityScope::<PostForEdit>::resolve(&state.db, &slug, &post_id_str).await?;
196 + scope.require_mod_write(&state.db, user.user_id).await?;
197 + let post_data = scope.resource;
198 + let post_id = post_data.id;
199 + let thread_id = parse_uuid(&thread_id_str)?;
200 +
201 + let mut tx = begin_tx(&state.db).await?;
202 + let restore = mt_db::mutations::restore_post_cascade(&mut tx, post_id)
203 + .await
204 + .map_err(db_error)?;
205 +
206 + // Nothing to reverse: the post was already live. Say so rather than writing
207 + // a log row for an action that did not happen.
208 + if !restore.post_restored {
209 + commit_tx(tx).await?;
210 + return Ok(Redirect::to(&format!(
211 + "/p/{slug}/{category_slug}/{thread_id_str}?toast=Post+was+not+removed"
212 + )));
213 + }
214 +
215 + mt_db::mutations::resolve_all_flags_for_post(&mut *tx, post_id, user.user_id, "dismissed")
216 + .await
217 + .map_err(db_error)?;
218 +
219 + audit(
220 + &mut tx,
221 + Some(post_data.community_id),
222 + ModActor::User(user.user_id),
223 + ModAction::RestorePost,
224 + Some(post_data.author_id),
225 + Some(post_id),
226 + None,
227 + )
228 + .await?;
229 +
230 + // The removal logged `DeleteThread` when it cascaded; log the inverse on the
231 + // same tx so the pair reads as one reversal rather than a thread that came
232 + // back unexplained.
233 + if restore.thread_restored {
234 + audit(
235 + &mut tx,
236 + Some(post_data.community_id),
237 + ModActor::User(user.user_id),
238 + ModAction::RestoreThread,
239 + Some(post_data.author_id),
240 + Some(thread_id),
241 + None,
242 + )
243 + .await?;
244 + }
245 + commit_tx(tx).await?;
246 +
247 + Ok(Redirect::to(&format!(
248 + "/p/{slug}/{category_slug}/{thread_id_str}?toast=Post+restored"
249 + )))
250 + }
251 +
177 252 // Community moderation routes
178 253
179 254 #[tracing::instrument(skip_all)]
@@ -73,6 +73,7 @@
73 73 pub body_html: String,
74 74 pub is_op: bool,
75 75 pub is_removed: bool,
76 + pub can_restore: bool,
76 77 pub can_add_footnote: bool,
77 78 pub can_remove: bool,
78 79 pub can_flag: bool,
@@ -2,7 +2,7 @@
2 2
3 3 {% block title %}{{ thread_title }} — Multithreaded{% endblock %}
4 4
5 - {% block head %}<meta name="description" content="{{ thread_title }} — discussion in {{ category_name }}, {{ community_name }}.">{% endblock %}
5 + {% block head %}<meta name="description" content="{{ thread_title }}, discussion in {{ category_name }}, {{ community_name }}.">{% endblock %}
6 6
7 7 {% block header %}{% include "partials/site_header.html" %}{% endblock %}
8 8
@@ -100,6 +100,14 @@
100 100 {% endif %}
101 101 </span>
102 102 {% endif %}
103 + {% if post.can_restore %}
104 + <span class="post-actions">
105 + <form method="post" action="/p/{{ community_slug }}/{{ category_slug }}/{{ thread_id }}/posts/{{ post.id }}/restore" class="form-inline" data-confirm="Restore this post? It becomes visible again and any outstanding flags on it are dismissed.">
106 + {% include "partials/csrf_input.html" %}
107 + <button type="submit" class="post-action-link">restore</button>
108 + </form>
109 + </span>
110 + {% endif %}
103 111 </span>
104 112 </div>
105 113 <div class="post-body">
@@ -498,7 +498,6 @@
498 498 .await
499 499 .unwrap();
500 500
501 - // Login as mod
502 501 let mod_id = h.login_as("dismissmod").await;
503 502 h.add_membership(mod_id, comm_id, "moderator").await;
504 503
@@ -568,7 +567,6 @@
568 567 .await
569 568 .unwrap();
570 569
571 - // Login as mod
572 570 let mod_id = h.login_as("removemod").await;
573 571 h.add_membership(mod_id, comm_id, "moderator").await;
574 572
@@ -684,3 +682,238 @@
684 682 );
685 683 }
686 684 }
685 +
686 + // Restore, the reversal of both removal paths
687 +
688 + /// The gap this closes: before restore existed, a mod who read a bad-faith
689 + /// brigade and dismissed the flags left the post hidden forever, with an empty
690 + /// queue implying it had been dealt with.
691 + #[tokio::test]
692 + async fn restore_unhides_auto_hidden_post() {
693 + let mut h = TestHarness::new().await;
694 + let author_id = h.login_as("restoreauthor").await;
695 + let comm_id = h.create_community("Test", "test").await;
696 + let cat_id = h.create_category(comm_id, "General", "general").await;
697 + h.add_membership(author_id, comm_id, "member").await;
698 +
699 + sqlx::query("UPDATE communities SET auto_hide_threshold = 2 WHERE id = $1")
700 + .bind(comm_id)
701 + .execute(&h.db)
702 + .await
703 + .unwrap();
704 +
705 + let thread_id = h
706 + .create_thread_with_post(cat_id, author_id, "Brigaded", "Perfectly fine post")
707 + .await;
708 + let post_id = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
709 + .await
710 + .unwrap()[0]
711 + .id;
712 +
713 + let thread_url = format!("/p/test/general/{thread_id}");
714 + let flag_url = format!("/p/test/general/{thread_id}/posts/{post_id}/flag");
715 + for name in ["brigade1", "brigade2"] {
716 + let flagger = h.login_as(name).await;
717 + h.add_membership(flagger, comm_id, "member").await;
718 + h.client.get(&thread_url).await;
719 + h.client.post_form(&flag_url, "reason=spam").await;
720 + }
721 +
722 + let removed: bool =
723 + sqlx::query_scalar("SELECT removed_at IS NOT NULL FROM posts WHERE id = $1")
724 + .bind(post_id)
725 + .fetch_one(&h.db)
726 + .await
727 + .unwrap();
728 + assert!(removed, "threshold reached, post should be auto-hidden");
729 +
730 + let mod_id = h.login_as("restoremod").await;
731 + h.add_membership(mod_id, comm_id, "moderator").await;
732 + h.client.get(&thread_url).await;
733 + let restore_url = format!("/p/test/general/{thread_id}/posts/{post_id}/restore");
734 + let resp = h.client.post_form(&restore_url, "").await;
735 + assert!(
736 + resp.status.is_redirection(),
737 + "Expected redirect, got {}",
738 + resp.status
739 + );
740 +
741 + let still_removed: bool =
742 + sqlx::query_scalar("SELECT removed_at IS NOT NULL FROM posts WHERE id = $1")
743 + .bind(post_id)
744 + .fetch_one(&h.db)
745 + .await
746 + .unwrap();
747 + assert!(!still_removed, "restore must clear removed_at");
748 +
749 + let actor: Option<uuid::Uuid> = sqlx::query_scalar(
750 + "SELECT actor_id FROM mod_log WHERE target_id = $1 AND action = 'restore_post'",
751 + )
752 + .bind(post_id)
753 + .fetch_one(&h.db)
754 + .await
755 + .unwrap();
756 + assert_eq!(
757 + actor,
758 + Some(mod_id),
759 + "restore must log against the acting moderator, not System"
760 + );
761 + }
762 +
763 + /// Restoring while the post is still over the threshold would re-hide it on the
764 + /// very next flag, so restore resolves the outstanding flags. This is the test
765 + /// that pins that decision: one fresh flag after a restore must not re-hide.
766 + #[tokio::test]
767 + async fn restore_resolves_flags_so_the_post_does_not_immediately_rehide() {
768 + let mut h = TestHarness::new().await;
769 + let author_id = h.login_as("rehideauthor").await;
770 + let comm_id = h.create_community("Test", "test").await;
771 + let cat_id = h.create_category(comm_id, "General", "general").await;
772 + h.add_membership(author_id, comm_id, "member").await;
773 +
774 + sqlx::query("UPDATE communities SET auto_hide_threshold = 2 WHERE id = $1")
775 + .bind(comm_id)
776 + .execute(&h.db)
777 + .await
778 + .unwrap();
779 +
780 + let thread_id = h
781 + .create_thread_with_post(cat_id, author_id, "Rehide", "Fine post")
782 + .await;
783 + let post_id = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
784 + .await
785 + .unwrap()[0]
786 + .id;
787 +
788 + let thread_url = format!("/p/test/general/{thread_id}");
789 + let flag_url = format!("/p/test/general/{thread_id}/posts/{post_id}/flag");
790 + for name in ["rehide1", "rehide2"] {
791 + let flagger = h.login_as(name).await;
792 + h.add_membership(flagger, comm_id, "member").await;
793 + h.client.get(&thread_url).await;
794 + h.client.post_form(&flag_url, "reason=spam").await;
795 + }
796 +
797 + let mod_id = h.login_as("rehidemod").await;
798 + h.add_membership(mod_id, comm_id, "moderator").await;
799 + h.client.get(&thread_url).await;
800 + h.client
801 + .post_form(
802 + &format!("/p/test/general/{thread_id}/posts/{post_id}/restore"),
803 + "",
804 + )
805 + .await;
806 +
807 + let pending: i64 = sqlx::query_scalar(
808 + "SELECT COUNT(*) FROM post_flags WHERE post_id = $1 AND resolved_at IS NULL",
809 + )
810 + .bind(post_id)
811 + .fetch_one(&h.db)
812 + .await
813 + .unwrap();
814 + assert_eq!(pending, 0, "restore must resolve the outstanding flags");
815 +
816 + let flagger = h.login_as("rehide3").await;
817 + h.add_membership(flagger, comm_id, "member").await;
818 + h.client.get(&thread_url).await;
819 + h.client.post_form(&flag_url, "reason=spam").await;
820 +
821 + let removed: bool =
822 + sqlx::query_scalar("SELECT removed_at IS NOT NULL FROM posts WHERE id = $1")
823 + .bind(post_id)
824 + .fetch_one(&h.db)
825 + .await
826 + .unwrap();
827 + assert!(
828 + !removed,
829 + "one flag after a restore is below threshold; the post must stay live"
830 + );
831 + }
832 +
833 + #[tokio::test]
834 + async fn member_cannot_restore_post() {
835 + let mut h = TestHarness::new().await;
836 + let author_id = h.login_as("norestoreauthor").await;
837 + let comm_id = h.create_community("Test", "test").await;
838 + let cat_id = h.create_category(comm_id, "General", "general").await;
839 + h.add_membership(author_id, comm_id, "member").await;
840 +
841 + let thread_id = h
842 + .create_thread_with_post(cat_id, author_id, "NoRestore", "Content")
843 + .await;
844 + let post_id = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
845 + .await
846 + .unwrap()[0]
847 + .id;
848 +
849 + sqlx::query("UPDATE posts SET removed_at = now() WHERE id = $1")
850 + .bind(post_id)
851 + .execute(&h.db)
852 + .await
853 + .unwrap();
854 +
855 + let member_id = h.login_as("plainmember").await;
856 + h.add_membership(member_id, comm_id, "member").await;
857 + let thread_url = format!("/p/test/general/{thread_id}");
858 + h.client.get(&thread_url).await;
859 + let resp = h
860 + .client
861 + .post_form(
862 + &format!("/p/test/general/{thread_id}/posts/{post_id}/restore"),
863 + "",
864 + )
865 + .await;
866 + assert_eq!(
867 + resp.status,
868 + axum::http::StatusCode::FORBIDDEN,
869 + "a member must not be able to restore"
870 + );
871 +
872 + let removed: bool =
873 + sqlx::query_scalar("SELECT removed_at IS NOT NULL FROM posts WHERE id = $1")
874 + .bind(post_id)
875 + .fetch_one(&h.db)
876 + .await
877 + .unwrap();
878 + assert!(removed, "post must stay removed");
879 + }
880 +
881 + /// Restoring a live post is a no-op, not a log entry. A mod-log row for an
882 + /// action that changed nothing is worse than no row: it reads as a reversal
883 + /// that happened.
884 + #[tokio::test]
885 + async fn restoring_a_live_post_writes_no_log_row() {
886 + let mut h = TestHarness::new().await;
887 + let author_id = h.login_as("liveauthor").await;
888 + let comm_id = h.create_community("Test", "test").await;
889 + let cat_id = h.create_category(comm_id, "General", "general").await;
890 + h.add_membership(author_id, comm_id, "member").await;
891 +
892 + let thread_id = h
893 + .create_thread_with_post(cat_id, author_id, "Live", "Never removed")
894 + .await;
895 + let post_id = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
896 + .await
897 + .unwrap()[0]
898 + .id;
899 +
900 + let mod_id = h.login_as("nooprestoremod").await;
901 + h.add_membership(mod_id, comm_id, "moderator").await;
902 + let thread_url = format!("/p/test/general/{thread_id}");
903 + h.client.get(&thread_url).await;
904 + h.client
905 + .post_form(
906 + &format!("/p/test/general/{thread_id}/posts/{post_id}/restore"),
907 + "",
908 + )
909 + .await;
910 +
911 + let rows: i64 = sqlx::query_scalar(
912 + "SELECT COUNT(*) FROM mod_log WHERE target_id = $1 AND action = 'restore_post'",
913 + )
914 + .bind(post_id)
915 + .fetch_one(&h.db)
916 + .await
917 + .unwrap();
918 + assert_eq!(rows, 0, "no-op restore must not write a mod-log row");
919 + }