From c6216f2588a50531b494bb5fcfd4b96649e3181f Mon Sep 17 00:00:00 2001
From: rimu <3310831+rimu@users.noreply.github.com>
Date: Thu, 11 Jan 2024 20:39:22 +1300
Subject: [PATCH] per-user keyword filters
---
app/community/routes.py | 7 +-
app/main/routes.py | 17 ++-
app/models.py | 29 +++-
app/static/structure.css | 13 +-
app/static/structure.scss | 20 ++-
app/templates/post/_post_teaser.html | 128 +++++++++---------
app/templates/user/edit_filters.html | 42 ++++++
app/templates/user/edit_settings.html | 1 +
app/templates/user/filters.html | 47 +++++++
app/templates/user/show_profile.html | 2 +-
app/user/forms.py | 17 ++-
app/user/routes.py | 98 ++++++++++++--
app/utils.py | 45 +++++-
.../versions/0b49f0997073_keyword_filter.py | 46 +++++++
14 files changed, 419 insertions(+), 93 deletions(-)
create mode 100644 app/templates/user/edit_filters.html
create mode 100644 app/templates/user/filters.html
create mode 100644 migrations/versions/0b49f0997073_keyword_filter.py
diff --git a/app/community/routes.py b/app/community/routes.py
index 0245cde4..d3301215 100644
--- a/app/community/routes.py
+++ b/app/community/routes.py
@@ -17,7 +17,7 @@ from app.models import User, Community, CommunityMember, CommunityJoinRequest, C
from app.community import bp
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, \
- 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 datetime import timezone, timedelta
@@ -118,7 +118,9 @@ def show_community(community: Community):
if current_user.is_anonymous or current_user.ignore_bots:
posts = community.posts.filter(Post.from_bot == False)
+ content_filters = {}
else:
+ content_filters = user_filters_posts(current_user.id)
posts = community.posts
if sort == '' or sort == 'hot':
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,
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',
- 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
diff --git a/app/main/routes.py b/app/main/routes.py
index 31024d0c..db442a46 100644
--- a/app/main/routes.py
+++ b/app/main/routes.py
@@ -14,7 +14,7 @@ from flask_babel import _, get_locale
from sqlalchemy import select, desc, text
from sqlalchemy_searchable import search
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
@@ -38,12 +38,14 @@ def index():
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 = posts.join(Community, Community.id == Post.community_id).filter(Community.show_home == True)
+ content_filters = {}
else:
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)
domains_ids = blocked_domains(current_user.id)
if domains_ids:
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)
@@ -57,7 +59,8 @@ def index():
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,
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'])
@@ -73,6 +76,7 @@ def new_posts():
if current_user.is_anonymous:
posts = Post.query.filter(Post.from_bot == False, Post.nsfw == False, Post.nsfl == False)
+ content_filters = {}
else:
posts = Post.query.join(CommunityMember, Post.community_id == CommunityMember.community_id).filter(
CommunityMember.is_banned == False)
@@ -80,6 +84,7 @@ def new_posts():
domains_ids = blocked_domains(current_user.id)
if domains_ids:
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)
@@ -93,7 +98,8 @@ def new_posts():
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,
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'])
@@ -109,6 +115,7 @@ def top_posts():
if current_user.is_anonymous:
posts = Post.query.filter(Post.from_bot == False, Post.nsfw == False, Post.nsfl == False)
+ content_filters = {}
else:
posts = Post.query.join(CommunityMember, Post.community_id == CommunityMember.community_id).filter(
CommunityMember.is_banned == False)
@@ -116,6 +123,7 @@ def top_posts():
domains_ids = blocked_domains(current_user.id)
if domains_ids:
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)
@@ -129,7 +137,8 @@ def top_posts():
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,
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'])
diff --git a/app/models.py b/app/models.py
index 8e33d3bd..f4f5e27e 100644
--- a/app/models.py
+++ b/app/models.py
@@ -665,6 +665,14 @@ class Post(db.Model):
else:
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):
cache.delete(f'/post/{self.id}_False')
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()
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):
id = db.Column(db.Integer, primary_key=True)
@@ -864,17 +880,18 @@ class ActivityPubLog(db.Model):
class Filter(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(50))
+ filter_home = db.Column(db.Boolean, default=True)
filter_posts = db.Column(db.Boolean, default=True)
filter_replies = db.Column(db.Boolean, default=False)
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'))
+ expire_after = db.Column(db.Date)
+ keywords = db.Column(db.String(500))
-
-class FilterKeyword(db.Model):
- id = db.Column(db.Integer, primary_key=True)
- keyword = db.Column(db.String(100))
- filter_id = db.Column(db.Integer, db.ForeignKey('filter.id'))
- user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
+ def keywords_string(self):
+ if self.keywords is None or self.keywords == '':
+ return ''
+ return ', '.join(self.keywords.split('\n'))
class Role(db.Model):
diff --git a/app/static/structure.css b/app/static/structure.css
index 076c5d70..ffad637d 100644
--- a/app/static/structure.css
+++ b/app/static/structure.css
@@ -553,6 +553,17 @@ fieldset legend {
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 {
float: right;
@@ -770,7 +781,7 @@ fieldset legend {
font-weight: bold;
}
-.profile_action_buttons {
+.profile_action_buttons, .rh_action_buttons {
float: right;
}
diff --git a/app/static/structure.scss b/app/static/structure.scss
index 9d3c2818..35be044b 100644
--- a/app/static/structure.scss
+++ b/app/static/structure.scss
@@ -163,6 +163,10 @@ nav, etc which are used site-wide */
.post_list {
.post_teaser {
+ border-bottom: solid 2px $light-grey;
+ padding-top: 8px;
+ padding-bottom: 8px;
+
h3 {
font-size: 110%;
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;
- padding-top: 8px;
- padding-bottom: 8px;
+ .voting_buttons {
+ width: 46px;
+ 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;
}
diff --git a/app/templates/post/_post_teaser.html b/app/templates/post/_post_teaser.html
index 8b8e25f5..041f696c 100644
--- a/app/templates/post/_post_teaser.html
+++ b/app/templates/post/_post_teaser.html
@@ -1,66 +1,72 @@
-
-
-
-
-
-
-
- {% include "post/_post_voting_buttons.html" %}
-
- {% if post.image_id and not low_bandwidth %}
-
- {% if post.type == POST_TYPE_LINK %}
-
- {% elif post.type == POST_TYPE_IMAGE %}
- {% if post.image_id %}
-
- {% endif %}
- {% else %}
-
+{% set content_blocked = post.blocked_by_content_filter(content_filters) %}
+{% if content_blocked and content_blocked == '-1' %}
+ {# do nothing - blocked by keyword filter #}
+{% else %}
+
+
+
+
+
+
+
+ {% include "post/_post_voting_buttons.html" %}
+
+ {% if post.image_id and not low_bandwidth %}
+
+ {% if post.type == POST_TYPE_LINK %}
+
+ {% elif post.type == POST_TYPE_IMAGE %}
+ {% if post.image_id %}
+
+ {% endif %}
+ {% else %}
+
+ {% endif %}
+
+ {% endif %}
+ {{ post.title }}
+ {% if post.type == POST_TYPE_IMAGE %} {% endif %}
+ {% if post.type == POST_TYPE_LINK and post.domain_id %}
+ {% if post.url and 'youtube.com' in post.url %}
+
+ {% endif %}
+
+
+
+ ({{ post.domain.name }})
+ {% endif %}
+ {% if post.reports and current_user.is_authenticated and post.community.is_moderator(current_user) %}
+
+ {% endif %}
+
+
{% if show_post_community %}c/{{ post.community.name }}{% endif %}
+ by {{ render_username(post.author) }} {{ moment(post.posted_at).fromNow() }}
+
+
+
+
+
+
+
+
{{ post.reply_count }}
+ {% if post.type == POST_TYPE_IMAGE %}
+
+ {% if post.image_id %}
+
+ {% else %}
+
{% endif %}
-
- {% endif %}
-
{{ post.title }}
- {% if post.type == POST_TYPE_IMAGE %}
{% endif %}
- {% if post.type == POST_TYPE_LINK and post.domain_id %}
- {% if post.url and 'youtube.com' in post.url %}
-
{% endif %}
-
-
-
-
({{ post.domain.name }})
- {% endif %}
- {% if post.reports and current_user.is_authenticated and post.community.is_moderator(current_user) %}
-
- {% endif %}
-
-
{% if show_post_community %}c/{{ post.community.name }}{% endif %}
- by {{ render_username(post.author) }} {{ moment(post.posted_at).fromNow() }}
-
+
+
+
+
+
-
-
-
-
-
{{ post.reply_count }}
- {% if post.type == POST_TYPE_IMAGE %}
-
- {% if post.image_id %}
-
- {% else %}
-
- {% endif %}
- {% endif %}
-
-
-
-
-
-
-
\ No newline at end of file
+{% endif %}
diff --git a/app/templates/user/edit_filters.html b/app/templates/user/edit_filters.html
new file mode 100644
index 00000000..ad2bd647
--- /dev/null
+++ b/app/templates/user/edit_filters.html
@@ -0,0 +1,42 @@
+{% extends "base.html" %}
+{% from 'bootstrap/form.html' import render_field %}
+
+{% block app_content %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/app/templates/user/edit_settings.html b/app/templates/user/edit_settings.html
index 520f4880..9275a135 100644
--- a/app/templates/user/edit_settings.html
+++ b/app/templates/user/edit_settings.html
@@ -22,6 +22,7 @@
{{ render_field(form.indexable) }}
{{ render_field(form.import_file) }}
{{ render_field(form.submit) }}
+
{{ _('Manage content filters') }}
diff --git a/app/templates/user/filters.html b/app/templates/user/filters.html
new file mode 100644
index 00000000..680c7672
--- /dev/null
+++ b/app/templates/user/filters.html
@@ -0,0 +1,47 @@
+{% extends "base.html" %}
+{% from 'bootstrap/form.html' import render_field %}
+
+{% block app_content %}
+
+
+
+
+
{{ _('Filters') }}
+
{{ _('Filters can hide posts that contain keywords you specify, either by making them less noticeable or invisible.') }}
+ {% if filters %}
+
+
+ {{ _('Name') }} |
+ {{ _('Keywords') }} |
+ {{ _('Action') }} |
+ {{ _('Expires') }} |
+ |
+
+ {% for filter in filters %}
+
+ {{ filter.title }} |
+ {{ filter.keywords_string()|shorten(30) }} |
+ {{ _('Invisible') if filter.hide_type == 1 else _('Semi-transparent') }} |
+ {{ filter.expire_after if filter.expire_after }} |
+
+ Edit |
+ Delete
+ |
+
+ {% endfor %}
+
+ {% else %}
+
{{ _('No filters defined yet.') }}
+ {% endif %}
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/app/templates/user/show_profile.html b/app/templates/user/show_profile.html
index 33031dfb..c1a2acff 100644
--- a/app/templates/user/show_profile.html
+++ b/app/templates/user/show_profile.html
@@ -112,7 +112,7 @@
{{ _('Profile') }}
diff --git a/app/user/forms.py b/app/user/forms.py
index 1ea5e73c..a740d2d8 100644
--- a/app/user/forms.py
+++ b/app/user/forms.py
@@ -1,7 +1,8 @@
from flask import session
from flask_login import current_user
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 flask_babel import _, lazy_gettext as _l
@@ -69,3 +70,17 @@ class ReportUserForm(FlaskForm):
if choice[0] == reason_id:
result.append(str(choice[1]))
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'))
diff --git a/app/user/routes.py b/app/user/routes.py
index 16ee84f4..6e1c5a44 100644
--- a/app/user/routes.py
+++ b/app/user/routes.py
@@ -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.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_PENDING
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.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, \
- 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
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)
-@bp.route('/u//settings', methods=['GET', 'POST'])
+@bp.route('/user/settings', methods=['GET', 'POST'])
@login_required
-def change_settings(actor):
- actor = actor.strip()
- user = User.query.filter_by(user_name=actor, deleted=False, banned=False, ap_id=None).first()
+def change_settings():
+ user = User.query.filter_by(id=current_user.id, deleted=False, banned=False, ap_id=None).first()
if user is None:
abort(404)
- if current_user.id != user.id:
- abort(401)
form = SettingsForm()
if form.validate_on_submit():
current_user.newsletter = form.newsletter.data
@@ -174,7 +172,7 @@ def change_settings(actor):
db.session.commit()
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':
form.newsletter.data = current_user.newsletter
form.ignore_bots.data = current_user.ignore_bots
@@ -573,3 +571,83 @@ def import_settings_task(user_id, filename):
...
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//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//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'))
diff --git a/app/utils.py b/app/utils.py
index 9699b6d2..63cfc533 100644
--- a/app/utils.py
+++ b/app/utils.py
@@ -1,7 +1,8 @@
from __future__ import annotations
import random
-from datetime import datetime, timedelta
+from collections import defaultdict
+from datetime import datetime, timedelta, date
from typing import List, Literal, Union
import markdown2
@@ -15,14 +16,14 @@ import os
import imghdr
from flask import current_app, json, redirect, url_for, request, make_response, Response, g
from flask_login import current_user
-from sqlalchemy import text
+from sqlalchemy import text, or_
from wtforms.fields import SelectField, SelectMultipleField
from wtforms.widgets import Select, html_params, ListWidget, CheckboxInput
from app import db, cache
import re
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
@@ -514,6 +515,44 @@ def shorten_number(number):
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
epoch = datetime(1970, 1, 1)
diff --git a/migrations/versions/0b49f0997073_keyword_filter.py b/migrations/versions/0b49f0997073_keyword_filter.py
new file mode 100644
index 00000000..c74c9f4e
--- /dev/null
+++ b/migrations/versions/0b49f0997073_keyword_filter.py
@@ -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 ###