per-user keyword filters

This commit is contained in:
rimu 2024-01-11 20:39:22 +13:00
parent 29a75c045a
commit c6216f2588
14 changed files with 419 additions and 93 deletions

View file

@ -17,7 +17,7 @@ from app.models import User, Community, CommunityMember, CommunityJoinRequest, C
from app.community import bp from app.community import bp
from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \ from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \
shorten_string, markdown_to_text, domain_from_url, validate_image, gibberish, community_membership, ap_datetime, \ shorten_string, markdown_to_text, domain_from_url, validate_image, gibberish, community_membership, ap_datetime, \
request_etag_matches, return_304, instance_banned, can_create, can_upvote, can_downvote request_etag_matches, return_304, instance_banned, can_create, can_upvote, can_downvote, user_filters_posts
from feedgen.feed import FeedGenerator from feedgen.feed import FeedGenerator
from datetime import timezone, timedelta from datetime import timezone, timedelta
@ -118,7 +118,9 @@ def show_community(community: Community):
if current_user.is_anonymous or current_user.ignore_bots: if current_user.is_anonymous or current_user.ignore_bots:
posts = community.posts.filter(Post.from_bot == False) posts = community.posts.filter(Post.from_bot == False)
content_filters = {}
else: else:
content_filters = user_filters_posts(current_user.id)
posts = community.posts posts = community.posts
if sort == '' or sort == 'hot': if sort == '' or sort == 'hot':
posts = posts.order_by(desc(Post.ranking)) posts = posts.order_by(desc(Post.ranking))
@ -141,7 +143,8 @@ def show_community(community: Community):
og_image=og_image, POST_TYPE_IMAGE=POST_TYPE_IMAGE, POST_TYPE_LINK=POST_TYPE_LINK, SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, og_image=og_image, POST_TYPE_IMAGE=POST_TYPE_IMAGE, POST_TYPE_LINK=POST_TYPE_LINK, SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING,
SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER, etag=f"{community.id}_{hash(community.last_active)}", SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER, etag=f"{community.id}_{hash(community.last_active)}",
next_url=next_url, prev_url=prev_url, low_bandwidth=request.cookies.get('low_bandwidth', '0') == '1', next_url=next_url, prev_url=prev_url, low_bandwidth=request.cookies.get('low_bandwidth', '0') == '1',
rss_feed=f"https://{current_app.config['SERVER_NAME']}/community/{community.link()}/feed", rss_feed_name=f"{community.title} posts on PieFed") rss_feed=f"https://{current_app.config['SERVER_NAME']}/community/{community.link()}/feed", rss_feed_name=f"{community.title} posts on PieFed",
content_filters=content_filters)
# RSS feed of the community # RSS feed of the community

View file

@ -14,7 +14,7 @@ from flask_babel import _, get_locale
from sqlalchemy import select, desc, text from sqlalchemy import select, desc, text
from sqlalchemy_searchable import search from sqlalchemy_searchable import search
from app.utils import render_template, get_setting, gibberish, request_etag_matches, return_304, blocked_domains, \ from app.utils import render_template, get_setting, gibberish, request_etag_matches, return_304, blocked_domains, \
ap_datetime, ip_address, retrieve_block_list, shorten_string, markdown_to_text ap_datetime, ip_address, retrieve_block_list, shorten_string, markdown_to_text, user_filters_home
from app.models import Community, CommunityMember, Post, Site, User, utcnow, Domain, Topic from app.models import Community, CommunityMember, Post, Site, User, utcnow, Domain, Topic
@ -38,12 +38,14 @@ def index():
flash(_('Create an account to tailor this feed to your interests.')) flash(_('Create an account to tailor this feed to your interests.'))
posts = Post.query.filter(Post.from_bot == False, Post.nsfw == False, Post.nsfl == False) posts = Post.query.filter(Post.from_bot == False, Post.nsfw == False, Post.nsfl == False)
posts = posts.join(Community, Community.id == Post.community_id).filter(Community.show_home == True) posts = posts.join(Community, Community.id == Post.community_id).filter(Community.show_home == True)
content_filters = {}
else: else:
posts = Post.query.join(CommunityMember, Post.community_id == CommunityMember.community_id).filter(CommunityMember.is_banned == False) posts = Post.query.join(CommunityMember, Post.community_id == CommunityMember.community_id).filter(CommunityMember.is_banned == False)
posts = posts.join(User, CommunityMember.user_id == User.id).filter(User.id == current_user.id) posts = posts.join(User, CommunityMember.user_id == User.id).filter(User.id == current_user.id)
domains_ids = blocked_domains(current_user.id) domains_ids = blocked_domains(current_user.id)
if domains_ids: if domains_ids:
posts = posts.filter(or_(Post.domain_id.not_in(domains_ids), Post.domain_id == None)) posts = posts.filter(or_(Post.domain_id.not_in(domains_ids), Post.domain_id == None))
content_filters = user_filters_home(current_user.id)
posts = posts.order_by(desc(Post.ranking)).paginate(page=page, per_page=100, error_out=False) posts = posts.order_by(desc(Post.ranking)).paginate(page=page, per_page=100, error_out=False)
@ -57,7 +59,8 @@ def index():
SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER, SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER,
etag=f"home_{hash(str(g.site.last_active))}", next_url=next_url, prev_url=prev_url, etag=f"home_{hash(str(g.site.last_active))}", next_url=next_url, prev_url=prev_url,
rss_feed=f"https://{current_app.config['SERVER_NAME']}/feed", rss_feed_name=f"Posts on " + g.site.name, rss_feed=f"https://{current_app.config['SERVER_NAME']}/feed", rss_feed_name=f"Posts on " + g.site.name,
title=f"{g.site.name} - {g.site.description}", description=shorten_string(markdown_to_text(g.site.sidebar), 150)) title=f"{g.site.name} - {g.site.description}", description=shorten_string(markdown_to_text(g.site.sidebar), 150),
content_filters=content_filters)
@bp.route('/new', methods=['HEAD', 'GET', 'POST']) @bp.route('/new', methods=['HEAD', 'GET', 'POST'])
@ -73,6 +76,7 @@ def new_posts():
if current_user.is_anonymous: if current_user.is_anonymous:
posts = Post.query.filter(Post.from_bot == False, Post.nsfw == False, Post.nsfl == False) posts = Post.query.filter(Post.from_bot == False, Post.nsfw == False, Post.nsfl == False)
content_filters = {}
else: else:
posts = Post.query.join(CommunityMember, Post.community_id == CommunityMember.community_id).filter( posts = Post.query.join(CommunityMember, Post.community_id == CommunityMember.community_id).filter(
CommunityMember.is_banned == False) CommunityMember.is_banned == False)
@ -80,6 +84,7 @@ def new_posts():
domains_ids = blocked_domains(current_user.id) domains_ids = blocked_domains(current_user.id)
if domains_ids: if domains_ids:
posts = posts.filter(or_(Post.domain_id.not_in(domains_ids), Post.domain_id == None)) posts = posts.filter(or_(Post.domain_id.not_in(domains_ids), Post.domain_id == None))
content_filters = user_filters_home(current_user.id)
posts = posts.order_by(desc(Post.posted_at)).paginate(page=page, per_page=100, error_out=False) posts = posts.order_by(desc(Post.posted_at)).paginate(page=page, per_page=100, error_out=False)
@ -93,7 +98,8 @@ def new_posts():
SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER, SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER,
etag=f"home_{hash(str(g.site.last_active))}", next_url=next_url, prev_url=prev_url, etag=f"home_{hash(str(g.site.last_active))}", next_url=next_url, prev_url=prev_url,
rss_feed=f"https://{current_app.config['SERVER_NAME']}/feed", rss_feed=f"https://{current_app.config['SERVER_NAME']}/feed",
rss_feed_name=f"Posts on " + g.site.name, low_bandwidth=request.cookies.get('low_bandwidth', '0') == '1') rss_feed_name=f"Posts on " + g.site.name, low_bandwidth=request.cookies.get('low_bandwidth', '0') == '1',
content_filters=content_filters)
@bp.route('/top', methods=['HEAD', 'GET', 'POST']) @bp.route('/top', methods=['HEAD', 'GET', 'POST'])
@ -109,6 +115,7 @@ def top_posts():
if current_user.is_anonymous: if current_user.is_anonymous:
posts = Post.query.filter(Post.from_bot == False, Post.nsfw == False, Post.nsfl == False) posts = Post.query.filter(Post.from_bot == False, Post.nsfw == False, Post.nsfl == False)
content_filters = {}
else: else:
posts = Post.query.join(CommunityMember, Post.community_id == CommunityMember.community_id).filter( posts = Post.query.join(CommunityMember, Post.community_id == CommunityMember.community_id).filter(
CommunityMember.is_banned == False) CommunityMember.is_banned == False)
@ -116,6 +123,7 @@ def top_posts():
domains_ids = blocked_domains(current_user.id) domains_ids = blocked_domains(current_user.id)
if domains_ids: if domains_ids:
posts = posts.filter(or_(Post.domain_id.not_in(domains_ids), Post.domain_id == None)) posts = posts.filter(or_(Post.domain_id.not_in(domains_ids), Post.domain_id == None))
content_filters = user_filters_home(current_user.id)
posts = posts.filter(Post.posted_at > utcnow() - timedelta(days=1)).order_by(desc(Post.score)).paginate(page=page, per_page=100, error_out=False) posts = posts.filter(Post.posted_at > utcnow() - timedelta(days=1)).order_by(desc(Post.score)).paginate(page=page, per_page=100, error_out=False)
@ -129,7 +137,8 @@ def top_posts():
SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER, SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER,
etag=f"home_{hash(str(g.site.last_active))}", next_url=next_url, prev_url=prev_url, etag=f"home_{hash(str(g.site.last_active))}", next_url=next_url, prev_url=prev_url,
rss_feed=f"https://{current_app.config['SERVER_NAME']}/feed", rss_feed=f"https://{current_app.config['SERVER_NAME']}/feed",
rss_feed_name=f"Posts on " + g.site.name, low_bandwidth=request.cookies.get('low_bandwidth', '0') == '1') rss_feed_name=f"Posts on " + g.site.name, low_bandwidth=request.cookies.get('low_bandwidth', '0') == '1',
content_filters=content_filters)
@bp.route('/communities', methods=['GET']) @bp.route('/communities', methods=['GET'])

View file

@ -665,6 +665,14 @@ class Post(db.Model):
else: else:
return f"https://{current_app.config['SERVER_NAME']}/post/{self.id}" return f"https://{current_app.config['SERVER_NAME']}/post/{self.id}"
def blocked_by_content_filter(self, content_filters):
lowercase_title = self.title.lower()
for name, keywords in content_filters.items() if content_filters else {}:
for keyword in keywords:
if keyword in lowercase_title:
return name
return False
def flush_cache(self): def flush_cache(self):
cache.delete(f'/post/{self.id}_False') cache.delete(f'/post/{self.id}_False')
cache.delete(f'/post/{self.id}_True') cache.delete(f'/post/{self.id}_True')
@ -750,6 +758,14 @@ class PostReply(db.Model):
reply = PostReply.query.filter_by(parent_id=self.id).first() reply = PostReply.query.filter_by(parent_id=self.id).first()
return reply is not None return reply is not None
def blocked_by_content_filter(self, content_filters):
lowercase_body = self.body.lower()
for name, keywords in content_filters.items() if content_filters else {}:
for keyword in keywords:
if keyword in lowercase_body:
return name
return False
class Domain(db.Model): class Domain(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
@ -864,17 +880,18 @@ class ActivityPubLog(db.Model):
class Filter(db.Model): class Filter(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(50)) title = db.Column(db.String(50))
filter_home = db.Column(db.Boolean, default=True)
filter_posts = db.Column(db.Boolean, default=True) filter_posts = db.Column(db.Boolean, default=True)
filter_replies = db.Column(db.Boolean, default=False) filter_replies = db.Column(db.Boolean, default=False)
hide_type = db.Column(db.Integer, default=0) # 0 = hide with warning, 1 = hide completely hide_type = db.Column(db.Integer, default=0) # 0 = hide with warning, 1 = hide completely
user_id = db.Column(db.Integer, db.ForeignKey('user.id')) user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
expire_after = db.Column(db.Date)
keywords = db.Column(db.String(500))
def keywords_string(self):
class FilterKeyword(db.Model): if self.keywords is None or self.keywords == '':
id = db.Column(db.Integer, primary_key=True) return ''
keyword = db.Column(db.String(100)) return ', '.join(self.keywords.split('\n'))
filter_id = db.Column(db.Integer, db.ForeignKey('filter.id'))
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
class Role(db.Model): class Role(db.Model):

View file

@ -553,6 +553,17 @@ fieldset legend {
width: 133px; width: 133px;
} }
} }
.post_list .post_teaser.blocked {
opacity: 0.2;
font-size: 80%;
}
.post_list .post_teaser.blocked .voting_buttons {
width: 46px;
line-height: 24px;
}
.post_list .post_teaser.blocked .voting_buttons .upvote_button, .post_list .post_teaser.blocked .voting_buttons .downvote_button {
font-size: 80%;
}
.url_thumbnail { .url_thumbnail {
float: right; float: right;
@ -770,7 +781,7 @@ fieldset legend {
font-weight: bold; font-weight: bold;
} }
.profile_action_buttons { .profile_action_buttons, .rh_action_buttons {
float: right; float: right;
} }

View file

@ -163,6 +163,10 @@ nav, etc which are used site-wide */
.post_list { .post_list {
.post_teaser { .post_teaser {
border-bottom: solid 2px $light-grey;
padding-top: 8px;
padding-bottom: 8px;
h3 { h3 {
font-size: 110%; font-size: 110%;
margin-top: 4px; margin-top: 4px;
@ -193,10 +197,18 @@ nav, etc which are used site-wide */
} }
} }
} }
&.blocked {
opacity: 0.2;
font-size: 80%;
border-bottom: solid 2px $light-grey; .voting_buttons {
padding-top: 8px; width: 46px;
padding-bottom: 8px; line-height: 24px;
.upvote_button, .downvote_button {
font-size: 80%;
}
}
}
} }
} }
@ -454,7 +466,7 @@ fieldset {
} }
} }
.profile_action_buttons { .profile_action_buttons, .rh_action_buttons {
float: right; float: right;
} }

View file

@ -1,66 +1,72 @@
<div class="post_teaser {{ 'reported' if post.reports and current_user.is_authenticated and post.community.is_moderator() }}"> {% set content_blocked = post.blocked_by_content_filter(content_filters) %}
<div class="row"> {% if content_blocked and content_blocked == '-1' %}
<div class="col-12"> {# do nothing - blocked by keyword filter #}
<div class="row main_row"> {% else %}
<div class="col"> <div class="post_teaser{{ ' reported' if post.reports and current_user.is_authenticated and post.community.is_moderator() }}{{ ' blocked' if content_blocked }}"
<h3 class="post_teaser_title"> {% if content_blocked %} title="{{ _('Filtered: ') }}{{ content_blocked }}"{% endif %}>
<div class="voting_buttons"> <div class="row">
{% include "post/_post_voting_buttons.html" %} <div class="col-12">
</div> <div class="row main_row">
{% if post.image_id and not low_bandwidth %} <div class="col">
<div class="thumbnail"> <h3 class="post_teaser_title">
{% if post.type == POST_TYPE_LINK %} <div class="voting_buttons">
<a href="{{ post.url }}" rel="nofollow ugc" target="_blank"><img src="{{ post.image.thumbnail_url() }}" {% include "post/_post_voting_buttons.html" %}
alt="{{ post.image.alt_text if post.image.alt_text else '' }}" height="50" /></a> </div>
{% elif post.type == POST_TYPE_IMAGE %} {% if post.image_id and not low_bandwidth %}
{% if post.image_id %} <div class="thumbnail">
<a href="{{ post.image.view_url() }}" rel="nofollow ugc" target="_blank"><img src="{{ post.image.thumbnail_url() }}" {% if post.type == POST_TYPE_LINK %}
alt="{{ post.image.alt_text if post.image.alt_text else '' }}" height="50" /></a> <a href="{{ post.url }}" rel="nofollow ugc" target="_blank"><img src="{{ post.image.thumbnail_url() }}"
{% endif %} alt="{{ post.image.alt_text if post.image.alt_text else '' }}" height="50" /></a>
{% else %} {% elif post.type == POST_TYPE_IMAGE %}
<a href="{{ url_for('activitypub.post_ap', post_id=post.id) }}"><img src="{{ post.image.thumbnail_url() }}" {% if post.image_id %}
alt="{{ post.image.alt_text if post.image.alt_text else '' }}" height="50" /></a> <a href="{{ post.image.view_url() }}" rel="nofollow ugc" target="_blank"><img src="{{ post.image.thumbnail_url() }}"
alt="{{ post.image.alt_text if post.image.alt_text else '' }}" height="50" /></a>
{% endif %}
{% else %}
<a href="{{ url_for('activitypub.post_ap', post_id=post.id) }}"><img src="{{ post.image.thumbnail_url() }}"
alt="{{ post.image.alt_text if post.image.alt_text else '' }}" height="50" /></a>
{% endif %}
</div>
{% endif %}
<a href="{{ url_for('activitypub.post_ap', post_id=post.id) }}" class="post_teaser_title_a">{{ post.title }}</a>
{% if post.type == POST_TYPE_IMAGE %}<span class="fe fe-image"> </span>{% endif %}
{% if post.type == POST_TYPE_LINK and post.domain_id %}
{% if post.url and 'youtube.com' in post.url %}
<span class="fe fe-video"></span>
{% endif %}
<a href="{{ post.url }}" rel="nofollow ugc" target="_blank">
<img src="/static/images/external_link_black.svg" class="external_link_icon" alt="External link" />
</a>
<span class="domain_link">(<a href="/d/{{ post.domain_id }}">{{ post.domain.name }}</a>)</span>
{% endif %}
{% if post.reports and current_user.is_authenticated and post.community.is_moderator(current_user) %}
<span class="red fe fe-report" title="{{ _('Reported. Check post for issues.') }}"></span>
{% endif %}
</h3>
<span class="small">{% if show_post_community %}<strong><a href="/c/{{ post.community.link() }}">c/{{ post.community.name }}</a></strong>{% endif %}
by {{ render_username(post.author) }} {{ moment(post.posted_at).fromNow() }}</span>
</div>
</div>
<div class="row utilities_row">
<div class="col-6">
<a href="{{ url_for('activitypub.post_ap', post_id=post.id, _anchor='replies') }}"><span class="fe fe-reply"></span></a>
<a href="{{ url_for('activitypub.post_ap', post_id=post.id, _anchor='replies') }}">{{ post.reply_count }}</a>
{% if post.type == POST_TYPE_IMAGE %}
&nbsp;
{% if post.image_id %}
<a href="{{ post.image.view_url() }}" rel="nofollow ugc" class="preview_image"><span class="fe fe-magnify"></span></a>
{% else %}
<a href="{{ post.url }}" rel="nofollow ugc" class="preview_image" target="_blank"><span class="fe fe-magnify"></span></a>
{% endif %} {% endif %}
</div>
{% endif %}
<a href="{{ url_for('activitypub.post_ap', post_id=post.id) }}" class="post_teaser_title_a">{{ post.title }}</a>
{% if post.type == POST_TYPE_IMAGE %}<span class="fe fe-image"> </span>{% endif %}
{% if post.type == POST_TYPE_LINK and post.domain_id %}
{% if post.url and 'youtube.com' in post.url %}
<span class="fe fe-video"></span>
{% endif %} {% endif %}
<a href="{{ post.url }}" rel="nofollow ugc" target="_blank"> </div>
<img src="/static/images/external_link_black.svg" class="external_link_icon" alt="External link" /> <div class="col-2"><a href="{{ url_for('post.post_options', post_id=post.id) }}" rel="nofollow"><span class="fe fe-options" title="Options"> </span></a></div>
</a> </div>
<span class="domain_link">(<a href="/d/{{ post.domain_id }}">{{ post.domain.name }}</a>)</span>
{% endif %}
{% if post.reports and current_user.is_authenticated and post.community.is_moderator(current_user) %}
<span class="red fe fe-report" title="{{ _('Reported. Check post for issues.') }}"></span>
{% endif %}
</h3>
<span class="small">{% if show_post_community %}<strong><a href="/c/{{ post.community.link() }}">c/{{ post.community.name }}</a></strong>{% endif %}
by {{ render_username(post.author) }} {{ moment(post.posted_at).fromNow() }}</span>
</div> </div>
</div>
</div>
<div class="row utilities_row">
<div class="col-6">
<a href="{{ url_for('activitypub.post_ap', post_id=post.id, _anchor='replies') }}"><span class="fe fe-reply"></span></a>
<a href="{{ url_for('activitypub.post_ap', post_id=post.id, _anchor='replies') }}">{{ post.reply_count }}</a>
{% if post.type == POST_TYPE_IMAGE %}
&nbsp;
{% if post.image_id %}
<a href="{{ post.image.view_url() }}" rel="nofollow ugc" class="preview_image"><span class="fe fe-magnify"></span></a>
{% else %}
<a href="{{ post.url }}" rel="nofollow ugc" class="preview_image" target="_blank"><span class="fe fe-magnify"></span></a>
{% endif %}
{% endif %}
</div>
<div class="col-2"><a href="{{ url_for('post.post_options', post_id=post.id) }}" rel="nofollow"><span class="fe fe-options" title="Options"> </span></a></div>
</div>
</div> </div>
</div> {% endif %}
</div>

View file

@ -0,0 +1,42 @@
{% extends "base.html" %}
{% from 'bootstrap/form.html' import render_field %}
{% block app_content %}
<div class="row">
<div class="col main_pane">
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
<li class="breadcrumb-item"><a href="/u/{{ user.link() }}">{{ user.display_name() }}</a></li>
<li class="breadcrumb-item"><a href="/user/settings">{{ _('Settings') }}</a></li>
<li class="breadcrumb-item"><a href="/user/settings/filters">{{ _('Filters') }}</a></li>
{% if content_filter %}
<li class="breadcrumb-item active">{{ _('Edit filter') }}</li>
{% else %}
<li class="breadcrumb-item active">{{ _('Add filter') }}</li>
{% endif %}
</ol>
</nav>
{% if content_filter %}
<h1 class="mt-2">{{ _('Filter %(name)s', name=content_filter.title) }}</h1>
{% else %}
<h1 class="mt-2">{{ _('Add filter') }}</h1>
{% endif %}
<form method='post'>
{{ form.csrf_token() }}
{{ render_field(form.title) }}
<h4>{{ _('Filter in these places') }}</h4>
{{ render_field(form.filter_home) }}
{{ render_field(form.filter_posts) }}
{{ render_field(form.filter_replies) }}
{{ render_field(form.hide_type) }}
{{ render_field(form.keywords) }}
<small class="field_hint">{{ _('One per line. Case does not matter.') }}</small>
{{ render_field(form.expire_after) }}
<small class="field_hint">{{ _('Stop applying this filter after this date. Optional.') }}</small>
{{ render_field(form.submit) }}
</form>
</div>
</div>
{% endblock %}

View file

@ -22,6 +22,7 @@
{{ render_field(form.indexable) }} {{ render_field(form.indexable) }}
{{ render_field(form.import_file) }} {{ render_field(form.import_file) }}
{{ render_field(form.submit) }} {{ render_field(form.submit) }}
&nbsp;&nbsp;<a href="{{ url_for('user.user_settings_filters') }}">{{ _('Manage content filters') }}</a>
</form> </form>
</div> </div>
</div> </div>

View file

@ -0,0 +1,47 @@
{% extends "base.html" %}
{% from 'bootstrap/form.html' import render_field %}
{% block app_content %}
<div class="row">
<div class="col main_pane">
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
<li class="breadcrumb-item"><a href="/u/{{ user.link() }}">{{ user.display_name() }}</a></li>
<li class="breadcrumb-item active"><a href="/user/settings">{{ _('Settings') }}</a></li>
<li class="breadcrumb-item active">{{ _('Filters') }}</li>
</ol>
</nav>
<div class="rh_action_buttons">
<a class="btn btn-primary" href="{{ url_for('user.user_settings_filters_add') }}">{{ _('Add filter') }}</a>
</div>
<h1 class="mt-2">{{ _('Filters') }}</h1>
<p>{{ _('Filters can hide posts that contain keywords you specify, either by making them less noticeable or invisible.') }}</p>
{% if filters %}
<table class="table table-striped">
<tr>
<th>{{ _('Name') }}</th>
<th>{{ _('Keywords') }}</th>
<th>{{ _('Action') }}</th>
<th>{{ _('Expires') }}</th>
<th> </th>
</tr>
{% for filter in filters %}
<tr>
<td>{{ filter.title }}</td>
<td>{{ filter.keywords_string()|shorten(30) }}</td>
<td>{{ _('Invisible') if filter.hide_type == 1 else _('Semi-transparent') }}</td>
<td>{{ filter.expire_after if filter.expire_after }}</td>
<td>
<a href="{{ url_for('user.user_settings_filters_edit', filter_id=filter.id) }}">Edit</a> |
<a href="{{ url_for('user.user_settings_filters_delete', filter_id=filter.id) }}" class="confirm_first">Delete</a>
</td>
</tr>
{% endfor %}
</table>
{% else %}
<p>{{ _('No filters defined yet.') }}</p>
{% endif %}
</div>
</div>
{% endblock %}

View file

@ -112,7 +112,7 @@
<a class="w-100 btn btn-primary" href="/u/{{ user.user_name }}/profile">{{ _('Profile') }}</a> <a class="w-100 btn btn-primary" href="/u/{{ user.user_name }}/profile">{{ _('Profile') }}</a>
</div> </div>
<div class="col-6"> <div class="col-6">
<a class="w-100 btn btn-primary" href="/u/{{ user.user_name }}/settings">{{ _('Settings') }}</a> <a class="w-100 btn btn-primary" href="/user/settings">{{ _('Settings') }}</a>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,7 +1,8 @@
from flask import session from flask import session
from flask_login import current_user from flask_login import current_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, PasswordField, BooleanField, EmailField, TextAreaField, FileField from wtforms import StringField, SubmitField, PasswordField, BooleanField, EmailField, TextAreaField, FileField, \
RadioField, DateField
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length, Optional from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length, Optional
from flask_babel import _, lazy_gettext as _l from flask_babel import _, lazy_gettext as _l
@ -69,3 +70,17 @@ class ReportUserForm(FlaskForm):
if choice[0] == reason_id: if choice[0] == reason_id:
result.append(str(choice[1])) result.append(str(choice[1]))
return ', '.join(result) return ', '.join(result)
class FilterEditForm(FlaskForm):
title = StringField(_l('Name'), validators={DataRequired(), Length(min=3, max=50)})
filter_home = BooleanField(_l('Home feed'), default=True)
filter_posts = BooleanField(_l('Posts in communities'))
filter_replies = BooleanField(_l('Comments on posts'))
hide_type_choices = [(0, _l('Make semi-transparent')), (1, _l('Hide completely'))]
hide_type = RadioField(_l('Action to take'), choices=hide_type_choices, default=1, coerce=int)
keywords = TextAreaField(_l('Keywords that trigger this filter'),
render_kw={'placeholder': 'One keyword or phrase per line', 'rows': 3},
validators={DataRequired(), Length(min=3, max=500)})
expire_after = DateField(_l('Expire after'), validators={Optional()})
submit = SubmitField(_l('Save'))

View file

@ -11,11 +11,12 @@ from app.activitypub.util import default_context, find_actor_or_create
from app.community.util import save_icon_file, save_banner_file, retrieve_mods_and_backfill from app.community.util import save_icon_file, save_banner_file, retrieve_mods_and_backfill
from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_PENDING from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_PENDING
from app.models import Post, Community, CommunityMember, User, PostReply, PostVote, Notification, utcnow, File, Site, \ from app.models import Post, Community, CommunityMember, User, PostReply, PostVote, Notification, utcnow, File, Site, \
Instance, Report, UserBlock, CommunityBan, CommunityJoinRequest, CommunityBlock Instance, Report, UserBlock, CommunityBan, CommunityJoinRequest, CommunityBlock, Filter
from app.user import bp from app.user import bp
from app.user.forms import ProfileForm, SettingsForm, DeleteAccountForm, ReportUserForm from app.user.forms import ProfileForm, SettingsForm, DeleteAccountForm, ReportUserForm, FilterEditForm
from app.utils import get_setting, render_template, markdown_to_html, user_access, markdown_to_text, shorten_string, \ from app.utils import get_setting, render_template, markdown_to_html, user_access, markdown_to_text, shorten_string, \
is_image_url, ensure_directory_exists, gibberish, file_get_contents, community_membership is_image_url, ensure_directory_exists, gibberish, file_get_contents, community_membership, user_filters_home, \
user_filters_posts, user_filters_replies
from sqlalchemy import desc, or_, text from sqlalchemy import desc, or_, text
import os import os
@ -136,15 +137,12 @@ def edit_profile(actor):
return render_template('user/edit_profile.html', title=_('Edit profile'), form=form, user=current_user) return render_template('user/edit_profile.html', title=_('Edit profile'), form=form, user=current_user)
@bp.route('/u/<actor>/settings', methods=['GET', 'POST']) @bp.route('/user/settings', methods=['GET', 'POST'])
@login_required @login_required
def change_settings(actor): def change_settings():
actor = actor.strip() user = User.query.filter_by(id=current_user.id, deleted=False, banned=False, ap_id=None).first()
user = User.query.filter_by(user_name=actor, deleted=False, banned=False, ap_id=None).first()
if user is None: if user is None:
abort(404) abort(404)
if current_user.id != user.id:
abort(401)
form = SettingsForm() form = SettingsForm()
if form.validate_on_submit(): if form.validate_on_submit():
current_user.newsletter = form.newsletter.data current_user.newsletter = form.newsletter.data
@ -174,7 +172,7 @@ def change_settings(actor):
db.session.commit() db.session.commit()
flash(_('Your changes have been saved.'), 'success') flash(_('Your changes have been saved.'), 'success')
return redirect(url_for('user.change_settings', actor=actor)) return redirect(url_for('user.change_settings'))
elif request.method == 'GET': elif request.method == 'GET':
form.newsletter.data = current_user.newsletter form.newsletter.data = current_user.newsletter
form.ignore_bots.data = current_user.ignore_bots form.ignore_bots.data = current_user.ignore_bots
@ -573,3 +571,83 @@ def import_settings_task(user_id, filename):
... ...
db.session.commit() db.session.commit()
@bp.route('/user/settings/filters', methods=['GET'])
@login_required
def user_settings_filters():
filters = Filter.query.filter_by(user_id=current_user.id).order_by(Filter.title).all()
return render_template('user/filters.html', filters=filters, user=current_user)
@bp.route('/user/settings/filters/add', methods=['GET', 'POST'])
@login_required
def user_settings_filters_add():
form = FilterEditForm()
form.filter_replies.render_kw = {'disabled': True}
if form.validate_on_submit():
content_filter = Filter(title=form.title.data, filter_home=form.filter_home.data, filter_posts=form.filter_posts.data,
filter_replies=form.filter_replies.data, hide_type=form.hide_type.data, keywords=form.keywords.data,
expire_after=form.expire_after.data, user_id=current_user.id)
db.session.add(content_filter)
db.session.commit()
cache.delete_memoized(user_filters_home, current_user.id)
cache.delete_memoized(user_filters_posts, current_user.id)
cache.delete_memoized(user_filters_replies, current_user.id)
flash(_('Your changes have been saved.'), 'success')
return redirect(url_for('user.user_settings_filters'))
return render_template('user/edit_filters.html', title=_('Add filter'), form=form, user=current_user)
@bp.route('/user/settings/filters/<int:filter_id>/edit', methods=['GET', 'POST'])
@login_required
def user_settings_filters_edit(filter_id):
content_filter = Filter.query.get_or_404(filter_id)
if current_user.id != content_filter.user_id:
abort(401)
form = FilterEditForm()
form.filter_replies.render_kw = {'disabled': True}
if form.validate_on_submit():
content_filter.title = form.title.data
content_filter.filter_home = form.filter_home.data
content_filter.filter_posts = form.filter_posts.data
content_filter.filter_replies = form.filter_replies.data
content_filter.hide_type = form.hide_type.data
content_filter.keywords = form.keywords.data
content_filter.expire_after = form.expire_after.data
db.session.commit()
cache.delete_memoized(user_filters_home, current_user.id)
cache.delete_memoized(user_filters_posts, current_user.id)
cache.delete_memoized(user_filters_replies, current_user.id)
flash(_('Your changes have been saved.'), 'success')
return redirect(url_for('user.user_settings_filters'))
elif request.method == 'GET':
form.title.data = content_filter.title
form.filter_home.data = content_filter.filter_home
form.filter_posts.data = content_filter.filter_posts
form.filter_replies.data = content_filter.filter_replies
form.hide_type.data = content_filter.hide_type
form.keywords.data = content_filter.keywords
form.expire_after.data = content_filter.expire_after
return render_template('user/edit_filters.html', title=_('Edit filter'), form=form, content_filter=content_filter, user=current_user)
@bp.route('/user/settings/filters/<int:filter_id>/delete', methods=['GET', 'POST'])
@login_required
def user_settings_filters_delete(filter_id):
content_filter = Filter.query.get_or_404(filter_id)
if current_user.id != content_filter.user_id:
abort(401)
db.session.delete(content_filter)
db.session.commit()
cache.delete_memoized(user_filters_home, current_user.id)
cache.delete_memoized(user_filters_posts, current_user.id)
cache.delete_memoized(user_filters_replies, current_user.id)
flash(_('Filter deleted.'))
return redirect(url_for('user.user_settings_filters'))

View file

@ -1,7 +1,8 @@
from __future__ import annotations from __future__ import annotations
import random import random
from datetime import datetime, timedelta from collections import defaultdict
from datetime import datetime, timedelta, date
from typing import List, Literal, Union from typing import List, Literal, Union
import markdown2 import markdown2
@ -15,14 +16,14 @@ import os
import imghdr import imghdr
from flask import current_app, json, redirect, url_for, request, make_response, Response, g from flask import current_app, json, redirect, url_for, request, make_response, Response, g
from flask_login import current_user from flask_login import current_user
from sqlalchemy import text from sqlalchemy import text, or_
from wtforms.fields import SelectField, SelectMultipleField from wtforms.fields import SelectField, SelectMultipleField
from wtforms.widgets import Select, html_params, ListWidget, CheckboxInput from wtforms.widgets import Select, html_params, ListWidget, CheckboxInput
from app import db, cache from app import db, cache
import re import re
from app.models import Settings, Domain, Instance, BannedInstances, User, Community, DomainBlock, ActivityPubLog, IpBan, \ from app.models import Settings, Domain, Instance, BannedInstances, User, Community, DomainBlock, ActivityPubLog, IpBan, \
Site, Post, PostReply, utcnow Site, Post, PostReply, utcnow, Filter
# Flask's render_template function, with support for themes added # Flask's render_template function, with support for themes added
@ -514,6 +515,44 @@ def shorten_number(number):
return f'{number / 1000000:.1f}M' return f'{number / 1000000:.1f}M'
@cache.memoize(timeout=300)
def user_filters_home(user_id):
filters = Filter.query.filter_by(user_id=user_id, filter_home=True).filter(or_(Filter.expire_after > date.today(), Filter.expire_after == None))
result = defaultdict(set)
for filter in filters:
keywords = [keyword.strip().lower() for keyword in filter.keywords.splitlines()]
if filter.hide_type == 0:
result[filter.title].update(keywords)
else: # type == 1 means hide completely. These posts are excluded from output by the jinja template
result['-1'].update(keywords)
return result
@cache.memoize(timeout=300)
def user_filters_posts(user_id):
filters = Filter.query.filter_by(user_id=user_id, filter_posts=True).filter(or_(Filter.expire_after > date.today(), Filter.expire_after == None))
result = defaultdict(set)
for filter in filters:
keywords = [keyword.strip().lower() for keyword in filter.keywords.splitlines()]
if filter.hide_type == 0:
result[filter.title].update(keywords)
else:
result['-1'].update(keywords)
return result
@cache.memoize(timeout=300)
def user_filters_replies(user_id):
filters = Filter.query.filter_by(user_id=user_id, filter_replies=True).filter(or_(Filter.expire_after > date.today(), Filter.expire_after == None))
result = defaultdict(set)
for filter in filters:
keywords = [keyword.strip().lower() for keyword in filter.keywords.splitlines()]
if filter.hide_type == 0:
result[filter.title].update(keywords)
else:
result['-1'].update(keywords)
return result
# All the following post/comment ranking math is explained at https://medium.com/hacking-and-gonzo/how-reddit-ranking-algorithms-work-ef111e33d0d9 # All the following post/comment ranking math is explained at https://medium.com/hacking-and-gonzo/how-reddit-ranking-algorithms-work-ef111e33d0d9
epoch = datetime(1970, 1, 1) epoch = datetime(1970, 1, 1)

View file

@ -0,0 +1,46 @@
"""keyword filter
Revision ID: 0b49f0997073
Revises: aaf434e9e7bf
Create Date: 2024-01-11 16:02:01.944862
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0b49f0997073'
down_revision = 'aaf434e9e7bf'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('filter_keyword')
with op.batch_alter_table('filter', schema=None) as batch_op:
batch_op.add_column(sa.Column('filter_home', sa.Boolean(), nullable=True))
batch_op.add_column(sa.Column('expire_after', sa.Date(), nullable=True))
batch_op.add_column(sa.Column('keywords', sa.String(length=500), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('filter', schema=None) as batch_op:
batch_op.drop_column('keywords')
batch_op.drop_column('expire_after')
batch_op.drop_column('filter_home')
op.create_table('filter_keyword',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('keyword', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('filter_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['filter_id'], ['filter.id'], name='filter_keyword_filter_id_fkey'),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], name='filter_keyword_user_id_fkey'),
sa.PrimaryKeyConstraint('id', name='filter_keyword_pkey')
)
# ### end Alembic commands ###