invisibleify completely removed trees only (fixes #431) (#535)

* invisibleify completely removed trees only (fixes #431)

* fix visibility state for shadowbanned users.
this also ends up moving some of the complexity out of the templates.

* comments: remove unused variable

* moderation state machine

* no seriously this really should check for v not being None

* fix shadowban state

* fix visibility state

* update stateful counters

* don't use bespoke function for show_descendants

* properly mock ModerationState for cron submissions

* fix approval discrepency

* remove treenukes for removed comments

* show shadowbans as removed
This commit is contained in:
justcool393 2023-04-03 02:30:46 -07:00 committed by GitHub
parent 77af24a5b1
commit 39ce6a4ee9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 167 additions and 62 deletions

View file

@ -1,5 +1,4 @@
import time
from typing import Literal, Optional
from typing import TYPE_CHECKING, Literal, Optional
from urllib.parse import parse_qs, urlencode, urlparse
from flask import g
@ -9,11 +8,14 @@ from sqlalchemy.orm import relationship
from files.classes.base import CreatedBase
from files.helpers.config.const import *
from files.helpers.config.environment import SCORE_HIDING_TIME_HOURS, SITE_FULL
from files.helpers.content import (body_displayed,
from files.helpers.content import (ModerationState, body_displayed,
execute_shadowbanned_fake_votes)
from files.helpers.lazy import lazy
from files.helpers.time import format_age
if TYPE_CHECKING:
from files.classes.user import User
CommentRenderContext = Literal['comments', 'volunteer']
class Comment(CreatedBase):
@ -402,7 +404,6 @@ class Comment(CreatedBase):
if v.id == self.author_id: return 1
return getattr(self, 'voted', 0)
def sticky_api_url(self, v) -> str | None:
'''
Returns the API URL used to sticky this comment.
@ -418,7 +419,25 @@ class Comment(CreatedBase):
return 'pin_comment'
return None
@lazy
def active_flags(self, v):
return len(self.flags(v))
@lazy
def show_descendants(self, v:"User | None") -> bool:
if self.moderation_state.is_visible_to(v, getattr(self, 'is_blocking', False)):
return True
return bool(self.descendant_count)
@lazy
def visibility_state(self, v:"User | None") -> tuple[bool, str]:
'''
Returns a tuple of whether this content is visible and a publicly
visible message to accompany it. The visibility state machine is
a slight mess but... this should at least unify the state checks.
'''
return self.moderation_state.visibility_state(v, getattr(self, 'is_blocking', False))
@property
def moderation_state(self) -> ModerationState:
return ModerationState.from_submittable(self)

View file

@ -8,10 +8,8 @@ from sqlalchemy.sql.sqltypes import Boolean, Integer, String, Text
from files.classes.cron.tasks import (RepeatableTask, ScheduledTaskType,
TaskRunContext)
from files.classes.submission import Submission
from files.helpers.config.const import (RENDER_DEPTH_LIMIT,
SUBMISSION_TITLE_LENGTH_MAXIMUM)
from files.helpers.config.environment import SITE_FULL
from files.helpers.content import body_displayed
from files.helpers.config.const import SUBMISSION_TITLE_LENGTH_MAXIMUM
from files.helpers.content import ModerationState, body_displayed
from files.helpers.lazy import lazy
from files.helpers.sanitize import filter_emojis_only
@ -172,3 +170,16 @@ class ScheduledSubmissionTask(RepeatableTask):
@property
def edit_url(self) -> str:
return f"/tasks/scheduled_posts/{self.id}/content"
@property
def moderation_state(self) -> ModerationState:
return ModerationState(
removed=False,
removed_by_name=None,
deleted=False, # we only want to show deleted UI color if disabled
reports_ignored=False,
filtered=False,
op_shadowbanned=False,
op_id=self.author_id_submission,
op_name_safe=self.author_name
)

View file

@ -11,7 +11,7 @@ from files.helpers.assetcache import assetcache_path
from files.helpers.config.const import *
from files.helpers.config.environment import (SCORE_HIDING_TIME_HOURS, SITE,
SITE_FULL, SITE_ID)
from files.helpers.content import body_displayed
from files.helpers.content import ModerationState, body_displayed
from files.helpers.lazy import lazy
from files.helpers.time import format_age, format_datetime
@ -360,3 +360,7 @@ class Submission(CreatedBase):
@property
def edit_url(self) -> str:
return f"/edit_post/{self.id}"
@property
def moderation_state(self) -> ModerationState:
return ModerationState.from_submittable(self)

View file

@ -2,7 +2,8 @@ from __future__ import annotations
import random
import urllib.parse
from typing import TYPE_CHECKING, Optional, Union
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Optional
from sqlalchemy.orm import Session
@ -10,7 +11,9 @@ from files.helpers.config.const import PERMS
if TYPE_CHECKING:
from files.classes import Comment, Submission, User
Submittable = Union[Submission, Comment]
Submittable = Comment | Submission
else:
Submittable = Any
def _replace_urls(url:str) -> str:
@ -86,18 +89,100 @@ def canonicalize_url2(url:str, *, httpsify:bool=False) -> urllib.parse.ParseResu
return url_parsed
def moderated_body(target:Submittable, v:Optional[User]) -> Optional[str]:
if v and (v.admin_level >= PERMS['POST_COMMENT_MODERATION'] \
or v.id == target.author_id):
@dataclass(frozen=True, kw_only=True, slots=True)
class ModerationState:
'''
The moderation state machine. This holds moderation state information,
including whether this was removed, deleted, filtered, whether OP was
shadowbanned, etc
'''
removed: bool
removed_by_name: str | None
deleted: bool
reports_ignored: bool
filtered: bool
op_shadowbanned: bool
op_id: int
op_name_safe: str
@classmethod
def from_submittable(cls, target: Submittable) -> "ModerationState":
return cls(
removed=bool(target.is_banned or target.filter_state == 'removed'),
removed_by_name=target.ban_reason, # type: ignore
deleted=bool(target.deleted_utc != 0),
reports_ignored=bool(target.filter_state == 'ignored'),
filtered=bool(target.filter_state == 'filtered'),
op_shadowbanned=bool(target.author.shadowbanned),
op_id=target.author_id, # type: ignore
op_name_safe=target.author_name
)
def moderated_body(self, v: User | None) -> str | None:
if v and (v.admin_level >= PERMS['POST_COMMENT_MODERATION'] \
or v.id == self.op_id):
return None
if self.deleted: return 'Deleted'
if self.appear_removed(v): return 'Removed'
if self.filtered: return 'Filtered'
return None
if target.deleted_utc: return 'Deleted by author'
if target.is_banned or target.filter_state == 'removed': return 'Removed'
if target.filter_state == 'filtered': return 'Filtered'
return None
def visibility_state(self, v: User | None, is_blocking: bool) -> tuple[bool, str]:
'''
Returns a tuple of whether this content is visible and a publicly
visible message to accompany it. The visibility state machine is
a slight mess but... this should at least unify the state checks.
'''
def can(v: User | None, perm_level: int) -> bool:
return v and v.admin_level >= perm_level
can_moderate: bool = can(v, PERMS['POST_COMMENT_MODERATION'])
can_shadowban: bool = can(v, PERMS['USER_SHADOWBAN'])
if v and v.id == self.op_id:
return True, "This shouldn't be here, please report it!"
if (self.removed and not can_moderate) or \
(self.op_shadowbanned and not can_shadowban):
msg: str = 'Removed'
if self.removed_by_name:
msg = f'Removed by @{self.removed_by_name}'
return False, msg
if self.filtered and not can_moderate:
return False, 'Filtered, please go kick a mod in the ass to fix this'
if self.deleted and not can_moderate:
return False, 'Deleted by author'
if is_blocking:
return False, f'You are blocking @{self.op_name_safe}'
return True, "This shouldn't be here, please report it!"
def is_visible_to(self, v: User | None, is_blocking: bool) -> bool:
return self.visibility_state(v, is_blocking)[0]
def replacement_message(self, v: User | None, is_blocking: bool) -> str:
return self.visibility_state(v, is_blocking)[1]
def appear_removed(self, v: User | None) -> bool:
if self.removed: return True
if not self.op_shadowbanned: return False
return (not v) or bool(v.admin_level < PERMS['USER_SHADOWBAN'])
@property
def publicly_visible(self) -> bool:
return all(
not state for state in
[self.deleted, self.removed, self.filtered, self.op_shadowbanned]
)
@property
def explicitly_moderated(self) -> bool:
'''
Whether this was removed or filtered and not as the result of a shadowban
'''
return self.removed or self.filtered
def body_displayed(target:Submittable, v:Optional[User], is_html:bool) -> str:
moderated:Optional[str] = moderated_body(target, v)
moderated:Optional[str] = target.moderation_state.moderated_body(v)
if moderated: return moderated
body = target.body_html if is_html else target.body

View file

@ -276,13 +276,13 @@ def update_filter_status(v):
return { 'result': f'Status of {new_status} is not permitted' }
if post_id:
p = g.db.get(Submission, post_id)
old_status = p.filter_state
target = g.db.get(Submission, post_id)
old_status = target.filter_state
rows_updated = g.db.query(Submission).where(Submission.id == post_id) \
.update({Submission.filter_state: new_status})
elif comment_id:
c = g.db.get(Comment, comment_id)
old_status = c.filter_state
target = g.db.get(Comment, comment_id)
old_status = target.filter_state
rows_updated = g.db.query(Comment).where(Comment.id == comment_id) \
.update({Comment.filter_state: new_status})
else:
@ -290,15 +290,15 @@ def update_filter_status(v):
if rows_updated == 1:
# If comment now visible, update state to reflect publication.
if (comment_id
if (isinstance(target, Comment)
and old_status in ['filtered', 'removed']
and new_status in ['normal', 'ignored']):
comment_on_publish(c)
comment_on_publish(target) # XXX: can cause discrepancies if removal state ≠ filter state
if (comment_id
if (isinstance(target, Comment)
and old_status in ['normal', 'ignored']
and new_status in ['filtered', 'removed']):
comment_on_unpublish(c)
comment_on_unpublish(target) # XXX: can cause discrepancies if removal state ≠ filter state
g.db.commit()
return { 'result': 'Update successful' }
@ -1302,7 +1302,6 @@ def unsticky_comment(cid, v):
@limiter.exempt
@admin_level_required(2)
def api_ban_comment(c_id, v):
comment = g.db.query(Comment).filter_by(id=c_id).one_or_none()
if not comment:
abort(404)
@ -1310,7 +1309,7 @@ def api_ban_comment(c_id, v):
comment.is_banned = True
comment.is_approved = None
comment.ban_reason = v.username
g.db.add(comment)
comment_on_unpublish(comment) # XXX: can cause discrepancies if removal state ≠ filter state
ma=ModAction(
kind="ban_comment",
user_id=v.id,
@ -1325,7 +1324,6 @@ def api_ban_comment(c_id, v):
@limiter.exempt
@admin_level_required(2)
def api_unban_comment(c_id, v):
comment = g.db.query(Comment).filter_by(id=c_id).one_or_none()
if not comment: abort(404)
@ -1340,6 +1338,7 @@ def api_unban_comment(c_id, v):
comment.is_banned = False
comment.ban_reason = None
comment.is_approved = v.id
comment_on_publish(comment) # XXX: can cause discrepancies if removal state ≠ filter state
g.db.add(comment)

View file

@ -331,17 +331,13 @@ def edit_comment(cid, v):
@limiter.limit("1/second;30/minute;200/hour;1000/day")
@auth_required
def delete_comment(cid, v):
c = get_comment(cid, v=v)
if not c.deleted_utc:
if c.author_id != v.id: abort(403)
c.deleted_utc = int(time.time())
g.db.add(c)
g.db.commit()
if c.deleted_utc: abort(409)
if c.author_id != v.id: abort(403)
c.deleted_utc = int(time.time())
# TODO: update stateful counters
g.db.add(c)
g.db.commit()
return {"message": "Comment deleted!"}
@ -349,16 +345,13 @@ def delete_comment(cid, v):
@limiter.limit("1/second;30/minute;200/hour;1000/day")
@auth_required
def undelete_comment(cid, v):
c = get_comment(cid, v=v)
if c.deleted_utc:
if c.author_id != v.id: abort(403)
c.deleted_utc = 0
g.db.add(c)
g.db.commit()
if not c.deleted_utc: abort(409)
if c.author_id != v.id: abort(403)
c.deleted_utc = 0
# TODO: update stateful counters
g.db.add(c)
g.db.commit()
return {"message": "Comment undeleted!"}
@ -366,7 +359,6 @@ def undelete_comment(cid, v):
@app.post("/pin_comment/<cid>")
@auth_required
def pin_comment(cid, v):
comment = get_comment(cid, v=v)
if not comment.is_pinned:

View file

@ -15,7 +15,6 @@ from files.__main__ import app, db_session, limiter
from files.classes import *
from files.helpers.alerts import *
from files.helpers.caching import invalidate_cache
from files.helpers.comments import comment_filter_moderated
from files.helpers.config.const import *
from files.helpers.content import canonicalize_url2
from files.helpers.contentsorting import sort_objects
@ -94,7 +93,6 @@ def post_id(pid, anything=None, v=None):
Comment.parent_submission == post.id,
Comment.level == 1,
).order_by(Comment.is_pinned.desc().nulls_last())
top_comments = comment_filter_moderated(top_comments, v)
top_comments = sort_objects(top_comments, sort, Comment)
pg_top_comment_ids = []
@ -108,7 +106,6 @@ def post_id(pid, anything=None, v=None):
def comment_tree_filter(q: Query) -> Query:
q = q.filter(Comment.top_comment_id.in_(pg_top_comment_ids))
q = comment_filter_moderated(q, v)
return q
comments, comment_tree = get_comment_trees_eager(comment_tree_filter, sort, v)
@ -160,7 +157,6 @@ def viewmore(v, pid, sort, offset):
# `NOT IN :ids` in top_comments.
top_comments = top_comments.filter(Comment.created_utc <= newest_created_utc)
top_comments = comment_filter_moderated(top_comments, v)
top_comments = sort_objects(top_comments, sort, Comment)
pg_top_comment_ids = []
@ -174,7 +170,6 @@ def viewmore(v, pid, sort, offset):
def comment_tree_filter(q: Query) -> Query:
q = q.filter(Comment.top_comment_id.in_(pg_top_comment_ids))
q = comment_filter_moderated(q, v)
return q
_, comment_tree = get_comment_trees_eager(comment_tree_filter, sort, v)

View file

@ -517,7 +517,7 @@ def messagereply(v):
g.db.add(c)
g.db.flush()
if user_id and user_id != v.id and user_id != 2:
if user_id and user_id != v.id and user_id != MODMAIL_ID:
notif = g.db.query(Notification).filter_by(comment_id=c.id, user_id=user_id).one_or_none()
if not notif:
notif = Notification(comment_id=c.id, user_id=user_id)

View file

@ -10,11 +10,10 @@
{%- set score = c.score_str(render_ctx) -%}
{%- set downs = c.downvotes_str(render_ctx) -%}
{%- set replies = c.replies(v) -%}
{%- set is_notification_page = request.path.startswith('/notifications') -%}
{% if (c.is_banned or c.deleted_utc or c.is_blocking) and not (v and v.admin_level >= 2) and not (v and v.id==c.author_id) %}
{% set replies = c.replies(v) %}
{% if not c.visibility_state(v)[0] %}
{% if c.show_descendants(v) %}
<div id="comment-{{c.id}}" class="comment">
<div class="comment-collapse-icon" onclick="collapse_comment('{{c.id}}', this.parentElement)"></div>
<div class="comment-collapse-bar-click" onclick="collapse_comment('{{c.id}}', this.parentElement)">
@ -23,7 +22,7 @@
<div class="comment-user-info">
{% if standalone and c.over_18 %}<span class="badge badge-danger">+18</span>{% endif %}
{% if c.is_banned %}removed by @{{c.ban_reason}}{% elif c.deleted_utc %}Deleted by author{% elif c.is_blocking %}You are blocking @{{c.author_name}}{% endif %}
{{c.visibility_state(v)[1]}}
</div>
<div class="comment-body">
@ -54,6 +53,7 @@
{% endif %}
{% endif %}
</div>
{% endif %}
</div>
{% else %}