Merge remote-tracking branch 'upstream/main'

This commit is contained in:
rra 2024-04-12 16:50:42 +02:00
commit 03d5859ebf
32 changed files with 11866 additions and 1727 deletions

View file

@ -374,6 +374,7 @@ def shared_inbox():
redis_client.set(request_json['id'], 1, ex=90) # Save the activity ID into redis, to avoid duplicate activities that Lemmy sometimes sends
activity_log.activity_id = request_json['id']
g.site = Site.query.get(1) # g.site is not initialized by @app.before_request when request.path == '/inbox'
if g.site.log_activitypub_json:
activity_log.activity_json = json.dumps(request_json)
activity_log.result = 'processing'

View file

@ -223,6 +223,8 @@ def banned_user_agents():
@cache.memoize(150)
def instance_blocked(host: str) -> bool: # see also utils.instance_banned()
if host is None or host == '':
return True
host = host.lower()
if 'https://' in host or 'http://' in host:
host = urlparse(host).hostname
@ -232,6 +234,8 @@ def instance_blocked(host: str) -> bool: # see also utils.instance_banned
@cache.memoize(150)
def instance_allowed(host: str) -> bool:
if host is None or host == '':
return True
host = host.lower()
if 'https://' in host or 'http://' in host:
host = urlparse(host).hostname
@ -561,11 +565,11 @@ def actor_json_to_model(activity_json, address, server):
current_app.logger.error(f'KeyError for {address}@{server} while parsing ' + str(activity_json))
return None
if 'icon' in activity_json:
if 'icon' in activity_json and activity_json['icon'] is not None and 'url' in activity_json['icon']:
avatar = File(source_url=activity_json['icon']['url'])
user.avatar = avatar
db.session.add(avatar)
if 'image' in activity_json:
if 'image' in activity_json and activity_json['image'] is not None and 'url' in activity_json['image']:
cover = File(source_url=activity_json['image']['url'])
user.cover = cover
db.session.add(cover)
@ -625,11 +629,11 @@ def actor_json_to_model(activity_json, address, server):
elif 'content' in activity_json:
community.description_html = allowlist_html(activity_json['content'])
community.description = ''
if 'icon' in activity_json:
if 'icon' in activity_json and activity_json['icon'] is not None and 'url' in activity_json['icon']:
icon = File(source_url=activity_json['icon']['url'])
community.icon = icon
db.session.add(icon)
if 'image' in activity_json:
if 'image' in activity_json and activity_json['image'] is not None and 'url' in activity_json['image']:
image = File(source_url=activity_json['image']['url'])
community.image = image
db.session.add(image)
@ -702,12 +706,12 @@ def post_json_to_model(activity_log, post_json, user, community) -> Post:
if not domain.banned:
domain.post_count += 1
post.domain = domain
if 'image' in post_json and post.image is None:
image = File(source_url=post_json['image']['url'])
db.session.add(image)
post.image = image
if post is not None:
if 'image' in post_json and post.image is None:
image = File(source_url=post_json['image']['url'])
db.session.add(image)
post.image = image
db.session.add(post)
community.post_count += 1
activity_log.result = 'success'
@ -793,18 +797,19 @@ def make_image_sizes_async(file_id, thumbnail_width, medium_width, directory):
db.session.commit()
# Alert regarding fascist meme content
try:
image_text = pytesseract.image_to_string(Image.open(BytesIO(source_image)).convert('L'), timeout=30)
except FileNotFoundError as e:
image_text = ''
if 'Anonymous' in image_text and ('No.' in image_text or ' N0' in image_text): # chan posts usually contain the text 'Anonymous' and ' No.12345'
post = Post.query.filter_by(image_id=file.id).first()
notification = Notification(title='Review this',
user_id=1,
author_id=post.user_id,
url=url_for('activitypub.post_ap', post_id=post.id))
db.session.add(notification)
db.session.commit()
if img_width < 2000: # images > 2000px tend to be real photos instead of 4chan screenshots.
try:
image_text = pytesseract.image_to_string(Image.open(BytesIO(source_image)).convert('L'), timeout=30)
except FileNotFoundError as e:
image_text = ''
if 'Anonymous' in image_text and ('No.' in image_text or ' N0' in image_text): # chan posts usually contain the text 'Anonymous' and ' No.12345'
post = Post.query.filter_by(image_id=file.id).first()
notification = Notification(title='Review this',
user_id=1,
author_id=post.user_id,
url=url_for('activitypub.post_ap', post_id=post.id))
db.session.add(notification)
db.session.commit()
# create a summary from markdown if present, otherwise use html if available
@ -1548,7 +1553,7 @@ def update_post_from_activity(post: Post, request_json: dict):
old_cross_posts = Post.query.filter(Post.id.in_(post.cross_posts)).all()
post.cross_posts.clear()
for ocp in old_cross_posts:
if ocp.cross_posts is not None:
if ocp.cross_posts is not None and post.id in ocp.cross_posts:
ocp.cross_posts.remove(post.id)
if post is not None:

View file

@ -458,6 +458,7 @@ def admin_users_trash():
page = request.args.get('page', 1, type=int)
search = request.args.get('search', '')
local_remote = request.args.get('local_remote', '')
type = request.args.get('type', 'bad_rep')
users = User.query.filter_by(deleted=False)
if local_remote == 'local':
@ -466,14 +467,19 @@ def admin_users_trash():
users = users.filter(User.ap_id != None)
if search:
users = users.filter(User.email.ilike(f"%{search}%"))
users = users.filter(User.reputation < -10)
users = users.order_by(User.reputation).paginate(page=page, per_page=1000, error_out=False)
if type == '' or type == 'bad_rep':
users = users.filter(User.reputation < -10)
users = users.order_by(User.reputation).paginate(page=page, per_page=1000, error_out=False)
elif type == 'bad_attitude':
users = users.filter(User.attitude < 0.0)
users = users.order_by(-User.attitude).paginate(page=page, per_page=1000, error_out=False)
next_url = url_for('admin.admin_users_trash', page=users.next_num) if users.has_next else None
prev_url = url_for('admin.admin_users_trash', page=users.prev_num) if users.has_prev and page != 1 else None
return render_template('admin/users.html', title=_('Problematic users'), next_url=next_url, prev_url=prev_url, users=users,
local_remote=local_remote, search=search,
local_remote=local_remote, search=search, type=type,
moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()),
site=g.site

View file

@ -105,7 +105,7 @@ def empty():
@login_required
def chat_options(conversation_id):
conversation = Conversation.query.get_or_404(conversation_id)
if current_user.is_admin() or current_user.is_member(current_user):
if current_user.is_admin() or conversation.is_member(current_user):
return render_template('chat/chat_options.html', conversation=conversation,
moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()),

View file

@ -88,7 +88,7 @@ class BanUserCommunityForm(FlaskForm):
class CreateDiscussionForm(FlaskForm):
communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int)
discussion_title = StringField(_l('Title'), validators=[DataRequired(), Length(min=3, max=255)])
discussion_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)])
discussion_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)], render_kw={'rows': 5})
sticky = BooleanField(_l('Sticky'))
nsfw = BooleanField(_l('NSFW'))
nsfl = BooleanField(_l('Gore/gross'))
@ -99,7 +99,7 @@ class CreateDiscussionForm(FlaskForm):
class CreateLinkForm(FlaskForm):
communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int)
link_title = StringField(_l('Title'), validators=[DataRequired(), Length(min=3, max=255)])
link_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)])
link_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)], render_kw={'rows': 5})
link_url = StringField(_l('URL'), validators=[DataRequired(), Regexp(r'^https?://', message='Submitted links need to start with "http://"" or "https://"')],
render_kw={'placeholder': 'https://...'})
sticky = BooleanField(_l('Sticky'))
@ -120,7 +120,7 @@ class CreateImageForm(FlaskForm):
communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int)
image_title = StringField(_l('Title'), validators=[DataRequired(), Length(min=3, max=255)])
image_alt_text = StringField(_l('Alt text'), validators=[Optional(), Length(min=3, max=255)])
image_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)])
image_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)], render_kw={'rows': 5})
image_file = FileField(_('Image'), validators=[DataRequired()])
sticky = BooleanField(_l('Sticky'))
nsfw = BooleanField(_l('NSFW'))

View file

@ -30,7 +30,7 @@ from app.utils import get_setting, render_template, allowlist_html, markdown_to_
shorten_string, gibberish, community_membership, ap_datetime, \
request_etag_matches, return_304, instance_banned, can_create_post, can_upvote, can_downvote, user_filters_posts, \
joined_communities, moderating_communities, blocked_domains, mimetype_from_url, blocked_instances, \
community_moderators, communities_banned_from, show_ban_message
community_moderators, communities_banned_from, show_ban_message, recently_upvoted_posts, recently_downvoted_posts
from feedgen.feed import FeedGenerator
from datetime import timezone, timedelta
@ -241,12 +241,21 @@ def show_community(community: Community):
prev_url = url_for('activitypub.community_profile', actor=community.ap_id if community.ap_id is not None else community.name,
page=posts.prev_num, sort=sort, layout=post_layout) if posts.has_prev and page != 1 else None
# Voting history
if current_user.is_authenticated:
recently_upvoted = recently_upvoted_posts(current_user.id)
recently_downvoted = recently_downvoted_posts(current_user.id)
else:
recently_upvoted = []
recently_downvoted = []
return render_template('community/community.html', community=community, title=community.title, breadcrumbs=breadcrumbs,
is_moderator=is_moderator, is_owner=is_owner, is_admin=is_admin, mods=mod_list, posts=posts, description=description,
og_image=og_image, POST_TYPE_IMAGE=POST_TYPE_IMAGE, POST_TYPE_LINK=POST_TYPE_LINK, SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING,
SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER=SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR=SUBSCRIPTION_MODERATOR,
etag=f"{community.id}{sort}{post_layout}_{hash(community.last_active)}", related_communities=related_communities,
next_url=next_url, prev_url=prev_url, low_bandwidth=low_bandwidth,
recently_upvoted=recently_upvoted, recently_downvoted=recently_downvoted,
rss_feed=f"https://{current_app.config['SERVER_NAME']}/community/{community.link()}/feed", rss_feed_name=f"{community.title} on PieFed",
content_filters=content_filters, moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()), sort=sort,
@ -436,7 +445,7 @@ def join_then_add(actor):
db.session.commit()
flash('You joined ' + community.title)
if not community.user_is_banned(current_user):
return redirect(url_for('community.add_post', actor=community.link()))
return redirect(url_for('community.add_discussion_post', actor=community.link()))
else:
abort(401)

View file

@ -3,9 +3,11 @@ from app import db
from app.errors import bp
@bp.app_errorhandler(404)
def not_found_error(error):
return render_template('errors/404.html'), 404
# 404 error handler removed because a lot of 404s are just images in /static/* and it doesn't make sense to waste cpu cycles presenting a nice page.
# Also rendering a page requires populating g.site which means hitting the DB.
# @bp.app_errorhandler(404)
# def not_found_error(error):
# return render_template('errors/404.html'), 404
@bp.app_errorhandler(500)

View file

@ -25,7 +25,7 @@ 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, user_filters_home, \
joined_communities, moderating_communities, parse_page, theme_list, get_request, markdown_to_html, allowlist_html, \
blocked_instances, communities_banned_from, topic_tree
blocked_instances, communities_banned_from, topic_tree, recently_upvoted_posts, recently_downvoted_posts
from app.models import Community, CommunityMember, Post, Site, User, utcnow, Domain, Topic, File, Instance, \
InstanceRole, Notification
from PIL import Image
@ -139,9 +139,18 @@ def home_page(type, sort):
active_communities = active_communities.filter(Community.id.not_in(banned_from))
active_communities = active_communities.order_by(desc(Community.last_active)).limit(5).all()
# Voting history
if current_user.is_authenticated:
recently_upvoted = recently_upvoted_posts(current_user.id)
recently_downvoted = recently_downvoted_posts(current_user.id)
else:
recently_upvoted = []
recently_downvoted = []
return render_template('index.html', posts=posts, active_communities=active_communities, show_post_community=True,
POST_TYPE_IMAGE=POST_TYPE_IMAGE, POST_TYPE_LINK=POST_TYPE_LINK,
low_bandwidth=low_bandwidth,
low_bandwidth=low_bandwidth, recently_upvoted=recently_upvoted,
recently_downvoted=recently_downvoted,
SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER,
etag=f"{type}_{sort}_{hash(str(g.site.last_active))}", next_url=next_url, prev_url=prev_url,
#rss_feed=f"https://{current_app.config['SERVER_NAME']}/feed",
@ -291,7 +300,9 @@ def list_files(directory):
@bp.route('/test')
def test():
return ''
md = "::: spoiler I'm all for ya having fun and your right to hurt yourself.\n\nI am a former racer, commuter, and professional Buyer for a chain of bike shops. I'm also disabled from the crash involving the 6th and 7th cars that have hit me in the last 170k+ miles of riding. I only barely survived what I simplify as a \"broken neck and back.\" Cars making U-turns are what will get you if you ride long enough, \n\nespecially commuting. It will look like just another person turning in front of you, you'll compensate like usual, and before your brain can even register what is really happening, what was your normal escape route will close and you're going to crash really hard. It is the only kind of crash that your intuition is useless against.\n:::"
return markdown_to_html(md)
users_to_notify = User.query.join(Notification, User.id == Notification.user_id).filter(
User.ap_id == None,
@ -346,6 +357,36 @@ def test_email():
return f'Email sent to {current_user.email}.'
@bp.route('/find_voters')
def find_voters():
user_ids = db.session.execute(text('SELECT id from "user" ORDER BY last_seen DESC LIMIT 5000')).scalars()
voters = {}
for user_id in user_ids:
recently_downvoted = recently_downvoted_posts(user_id)
if len(recently_downvoted) > 10:
voters[user_id] = str(recently_downvoted)
return str(find_duplicate_values(voters))
def find_duplicate_values(dictionary):
# Create a dictionary to store the keys for each value
value_to_keys = {}
# Iterate through the input dictionary
for key, value in dictionary.items():
# If the value is not already in the dictionary, add it
if value not in value_to_keys:
value_to_keys[value] = [key]
else:
# If the value is already in the dictionary, append the key to the list
value_to_keys[value].append(key)
# Filter out the values that have only one key (i.e., unique values)
duplicates = {value: keys for value, keys in value_to_keys.items() if len(keys) > 1}
return duplicates
def verification_warning():
if hasattr(current_user, 'verified') and current_user.verified is False:
flash(_('Please click the link in your email inbox to verify your account.'), 'warning')

View file

@ -1278,6 +1278,7 @@ class Site(db.Model):
last_active = db.Column(db.DateTime, default=utcnow)
log_activitypub_json = db.Column(db.Boolean, default=False)
default_theme = db.Column(db.String(20), default='')
contact_email = db.Column(db.String(255), default='')
@staticmethod
def admins() -> List[User]:

View file

@ -7,7 +7,7 @@ from app.utils import MultiCheckboxField
class NewReplyForm(FlaskForm):
body = TextAreaField(_l('Body'), render_kw={'placeholder': 'What are your thoughts?', 'rows': 3}, validators={DataRequired(), Length(min=3, max=5000)})
body = TextAreaField(_l('Body'), render_kw={'placeholder': 'What are your thoughts?', 'rows': 5}, validators={DataRequired(), Length(min=3, max=5000)})
notify_author = BooleanField(_l('Notify about replies'))
submit = SubmitField(_l('Comment'))

View file

@ -24,7 +24,8 @@ from app.utils import get_setting, render_template, allowlist_html, markdown_to_
shorten_string, markdown_to_text, gibberish, ap_datetime, return_304, \
request_etag_matches, ip_address, user_ip_banned, instance_banned, can_downvote, can_upvote, post_ranking, \
reply_already_exists, reply_is_just_link_to_gif_reaction, confidence, moderating_communities, joined_communities, \
blocked_instances, blocked_domains, community_moderators, blocked_phrases, show_ban_message
blocked_instances, blocked_domains, community_moderators, blocked_phrases, show_ban_message, recently_upvoted_posts, \
recently_downvoted_posts, recently_upvoted_post_replies, recently_downvoted_post_replies
def show_post(post_id: int):
@ -239,12 +240,26 @@ def show_post(post_id: int):
breadcrumb.url = '/communities'
breadcrumbs.append(breadcrumb)
# Voting history
if current_user.is_authenticated:
recently_upvoted = recently_upvoted_posts(current_user.id)
recently_downvoted = recently_downvoted_posts(current_user.id)
recently_upvoted_replies = recently_upvoted_post_replies(current_user.id)
recently_downvoted_replies = recently_downvoted_post_replies(current_user.id)
else:
recently_upvoted = []
recently_downvoted = []
recently_upvoted_replies = []
recently_downvoted_replies = []
response = render_template('post/post.html', title=post.title, post=post, is_moderator=is_moderator, community=post.community,
breadcrumbs=breadcrumbs, related_communities=related_communities, mods=mod_list,
canonical=post.ap_id, form=form, replies=replies, THREAD_CUTOFF_DEPTH=constants.THREAD_CUTOFF_DEPTH,
description=description, og_image=og_image, POST_TYPE_IMAGE=constants.POST_TYPE_IMAGE,
POST_TYPE_LINK=constants.POST_TYPE_LINK, POST_TYPE_ARTICLE=constants.POST_TYPE_ARTICLE,
noindex=not post.author.indexable,
recently_upvoted=recently_upvoted, recently_downvoted=recently_downvoted,
recently_upvoted_replies=recently_upvoted_replies, recently_downvoted_replies=recently_downvoted_replies,
etag=f"{post.id}{sort}_{hash(post.last_active)}", markdown_editor=current_user.is_authenticated and current_user.markdown_editor,
low_bandwidth=request.cookies.get('low_bandwidth', '0') == '1', SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER,
moderating_communities=moderating_communities(current_user.get_id()),
@ -259,7 +274,6 @@ def show_post(post_id: int):
@login_required
@validation_required
def post_vote(post_id: int, vote_direction):
upvoted_class = downvoted_class = ''
post = Post.query.get_or_404(post_id)
existing_vote = PostVote.query.filter_by(user_id=current_user.id, post_id=post.id).first()
if existing_vote:
@ -275,7 +289,6 @@ def post_vote(post_id: int, vote_direction):
post.up_votes -= 1
post.down_votes += 1
post.score -= 2
downvoted_class = 'voted_down'
else: # previous vote was down
if vote_direction == 'downvote': # new vote is also down, so remove it
db.session.delete(existing_vote)
@ -286,18 +299,15 @@ def post_vote(post_id: int, vote_direction):
post.up_votes += 1
post.down_votes -= 1
post.score += 2
upvoted_class = 'voted_up'
else:
if vote_direction == 'upvote':
effect = 1
post.up_votes += 1
post.score += 1
upvoted_class = 'voted_up'
else:
effect = -1
post.down_votes += 1
post.score -= 1
downvoted_class = 'voted_down'
vote = PostVote(user_id=current_user.id, post_id=post.id, author_id=post.author.id,
effect=effect)
# upvotes do not increase reputation in low quality communities
@ -346,17 +356,25 @@ def post_vote(post_id: int, vote_direction):
current_user.recalculate_attitude()
db.session.commit()
post.flush_cache()
recently_upvoted = []
recently_downvoted = []
if vote_direction == 'upvote':
recently_upvoted = [post_id]
elif vote_direction == 'downvote':
recently_downvoted = [post_id]
cache.delete_memoized(recently_upvoted_posts, current_user.id)
cache.delete_memoized(recently_downvoted_posts, current_user.id)
template = 'post/_post_voting_buttons.html' if request.args.get('style', '') == '' else 'post/_post_voting_buttons_masonry.html'
return render_template(template, post=post, community=post.community,
upvoted_class=upvoted_class,
downvoted_class=downvoted_class)
return render_template(template, post=post, community=post.community, recently_upvoted=recently_upvoted,
recently_downvoted=recently_downvoted)
@bp.route('/comment/<int:comment_id>/<vote_direction>', methods=['POST'])
@login_required
@validation_required
def comment_vote(comment_id, vote_direction):
upvoted_class = downvoted_class = ''
comment = PostReply.query.get_or_404(comment_id)
existing_vote = PostReplyVote.query.filter_by(user_id=current_user.id, post_reply_id=comment.id).first()
if existing_vote:
@ -423,9 +441,20 @@ def comment_vote(comment_id, vote_direction):
db.session.commit()
comment.post.flush_cache()
recently_upvoted = []
recently_downvoted = []
if vote_direction == 'upvote':
recently_upvoted = [comment_id]
elif vote_direction == 'downvote':
recently_downvoted = [comment_id]
cache.delete_memoized(recently_upvoted_post_replies, current_user.id)
cache.delete_memoized(recently_downvoted_post_replies, current_user.id)
return render_template('post/_comment_voting_buttons.html', comment=comment,
upvoted_class=upvoted_class,
downvoted_class=downvoted_class, community=comment.community)
recently_upvoted_replies=recently_upvoted,
recently_downvoted_replies=recently_downvoted,
community=comment.community)
@bp.route('/post/<int:post_id>/comment/<int:comment_id>')

View file

@ -6,7 +6,7 @@ from sqlalchemy import or_
from app.models import Post
from app.search import bp
from app.utils import moderating_communities, joined_communities, render_template, blocked_domains, blocked_instances, \
communities_banned_from
communities_banned_from, recently_upvoted_posts, recently_downvoted_posts
@bp.route('/search', methods=['GET', 'POST'])
@ -46,8 +46,18 @@ def run_search():
next_url = url_for('search.run_search', page=posts.next_num, q=q) if posts.has_next else None
prev_url = url_for('search.run_search', page=posts.prev_num, q=q) if posts.has_prev and page != 1 else None
# Voting history
if current_user.is_authenticated:
recently_upvoted = recently_upvoted_posts(current_user.id)
recently_downvoted = recently_downvoted_posts(current_user.id)
else:
recently_upvoted = []
recently_downvoted = []
return render_template('search/results.html', title=_('Search results for %(q)s', q=q), posts=posts, q=q,
next_url=next_url, prev_url=prev_url, show_post_community=True,
recently_upvoted=recently_upvoted,
recently_downvoted=recently_downvoted,
moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()),
site=g.site)

View file

@ -19,6 +19,8 @@
<input type="search" name="search" value="{{ search }}">
<input type="radio" name="local_remote" value="local" id="local_remote_local" {{ 'checked' if local_remote == 'local' }}><label for="local_remote_local"> Local</label>
<input type="radio" name="local_remote" value="remote" id="local_remote_remote" {{ 'checked' if local_remote == 'remote' }}><label for="local_remote_remote"> Remote</label>
<input type="radio" name="type" value="bad_rep" id="type_bad_rep" {{ 'checked' if type == 'bad_rep' }}><label for="type_bad_rep"> Bad rep</label>
<input type="radio" name="type" value="bad_attitude" id="type_bad_attitude" {{ 'checked' if type == 'bad_attitude' }}><label for="type_bad_attitude"> Bad attitude</label>
<input type="submit" name="submit" value="Search" class="btn btn-primary">
</form>
<table class="table table-striped mt-1">

View file

@ -1,6 +1,6 @@
{% if current_user.is_authenticated and current_user.verified %}
{% if can_upvote(current_user, community) %}
<div class="upvote_button {{ upvoted_class }}" role="button" aria-label="{{ _('UpVote button.') }}" aria-live="assertive"
<div class="upvote_button {{ 'voted_up' if in_sorted_list(recently_upvoted_replies, comment.id) }}" role="button" aria-label="{{ _('UpVote button.') }}" aria-live="assertive"
hx-post="/comment/{{ comment.id }}/upvote" hx-trigger="click throttle:1s" hx-target="closest .voting_buttons_new" tabindex="0">
<span class="fe fe-arrow-up"></span>
<img class="htmx-indicator" src="/static/images/spinner.svg" alt="" style="opacity: 0;">
@ -8,7 +8,7 @@
{% endif %}
<span title="{{ comment.up_votes }}, {{ comment.down_votes }}" aria-live="assertive" aria-label="{{ _('Score: ') }}{{ comment.up_votes - comment.down_votes }}.">{{ comment.up_votes - comment.down_votes }}</span>
{% if can_downvote(current_user, community) %}
<div class="downvote_button {{ downvoted_class }}" role="button" aria-label="{{ _('DownVote button.') }}" aria-live="assertive"
<div class="downvote_button {{ 'voted_down' if in_sorted_list(recently_downvoted_replies, comment.id) }}" role="button" aria-label="{{ _('DownVote button.') }}" aria-live="assertive"
hx-post="/comment/{{ comment.id }}/downvote" hx-trigger="click throttle:1s" hx-target="closest .voting_buttons_new" tabindex="0">
<span class="fe fe-arrow-down"></span>
<img class="htmx-indicator" src="/static/images/spinner.svg" alt="" style="opacity: 0;">

View file

@ -85,6 +85,7 @@
<p><audio controls preload="{{ 'none' if low_bandwidth else 'metadata' }}" src="{{ post.url }}"></audio></p>
{% endif %}
{% if 'youtube.com' in post.url %}
<p><a href="https://piped.video/watch?v={{ post.youtube_embed() }}">{{ _('Watch on piped.video') }} <span class="fe fe-external"></span></a></p>
<div style="padding-bottom: 56.25%; position: relative;"><iframe style="position: absolute; top: 0px; left: 0px; width: 100%; height: 100%;" src="https://www.youtube.com/embed/{{ post.youtube_embed() }}?rel=0" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture; fullscreen" width="100%" height="100%" frameborder="0"></iframe></div>
{% endif %}
{% elif post.type == POST_TYPE_IMAGE %}

View file

@ -8,9 +8,11 @@
<div class="col-12">
<div class="row main_row">
<div class="col">
{% if not hide_vote_buttons %}
<div class="voting_buttons" aria-hidden="true">
{% include "post/_post_voting_buttons.html" %}
</div>
{% endif %}
{% if post.image_id %}
<div class="thumbnail{{ ' lbw' if low_bandwidth }}" aria-hidden="true">
{% if low_bandwidth %}

View file

@ -1,6 +1,6 @@
{% if current_user.is_authenticated and current_user.verified %}
{% if can_upvote(current_user, post.community) %}
<div class="upvote_button {{ upvoted_class }}" role="button" aria-label="{{ _('UpVote button, %(count)d upvotes so far.', count=post.up_votes) }}" aria-live="assertive"
<div class="upvote_button {{ 'voted_up' if in_sorted_list(recently_upvoted, post.id) }}" role="button" aria-label="{{ _('UpVote button, %(count)d upvotes so far.', count=post.up_votes) }}" aria-live="assertive"
hx-post="/post/{{ post.id }}/upvote" hx-trigger="click throttle:1s" hx-target="closest .voting_buttons" tabindex="0">
<span class="fe fe-arrow-up"></span>
{{ shorten_number(post.up_votes) }}
@ -8,7 +8,7 @@
</div>
{% endif %}
{% if can_downvote(current_user, post.community) %}
<div class="downvote_button {{ downvoted_class }}" role="button" aria-label="{{ _('DownVote button, %(count)d downvotes so far.', count=post.down_votes) }}" aria-live="assertive"
<div class="downvote_button {{ 'voted_down' if in_sorted_list(recently_downvoted, post.id) }}" role="button" aria-label="{{ _('DownVote button, %(count)d downvotes so far.', count=post.down_votes) }}" aria-live="assertive"
hx-post="/post/{{ post.id }}/downvote" hx-trigger="click throttle:1s" hx-target="closest .voting_buttons" tabindex="0">
<span class="fe fe-arrow-down"></span>
{{ shorten_number(post.down_votes) }}

View file

@ -1,13 +1,13 @@
{% if current_user.is_authenticated and current_user.verified %}
{% if can_upvote(current_user, post.community) %}
<div class="upvote_button {{ upvoted_class }}" role="button" aria-label="{{ _('UpVote') }}" aria-live="assertive"
<div class="upvote_button {{ 'voted_up' if in_sorted_list(recently_upvoted, post.id) }}" role="button" aria-label="{{ _('UpVote') }}" aria-live="assertive"
hx-post="/post/{{ post.id }}/upvote?style=masonry" hx-trigger="click throttle:1s" hx-target="closest .voting_buttons_masonry" tabindex="0" title="{{ post.up_votes }} upvotes">
<span class="fe fe-arrow-up"></span>
<img class="htmx-indicator" src="/static/images/spinner.svg" alt="" style="opacity: 0;">
</div>
{% endif %}
{% if can_downvote(current_user, post.community) %}
<div class="downvote_button {{ downvoted_class }}" role="button" aria-label="{{ _('DownVote') }}" aria-live="assertive"
<div class="downvote_button {{ 'voted_down' if in_sorted_list(recently_downvoted, post.id) }}" role="button" aria-label="{{ _('DownVote') }}" aria-live="assertive"
hx-post="/post/{{ post.id }}/downvote?style=masonry" hx-trigger="click throttle:1s" hx-target="closest .voting_buttons_masonry" tabindex="0" title="{{ post.down_votes }} downvotes">
<span class="fe fe-arrow-down"></span>
<img class="htmx-indicator" src="/static/images/spinner.svg" alt="" style="opacity: 0;">

View file

@ -12,3 +12,18 @@ User-Agent: *
Disallow: /d/
Disallow: /static/media/users/
Disallow: /post/*/options
User-agent: GPTBot
Disallow: /
User-agent: AhrefsBot
Disallow: /
User-agent: SemrushBot
Disallow: /
User-agent: DotBot
Disallow: /
User-agent: SeznamBot
Disallow: /

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -14,7 +14,7 @@ class ProfileForm(FlaskForm):
email = EmailField(_l('Email address'), validators=[Email(), DataRequired(), Length(min=5, max=255)])
password_field = PasswordField(_l('Set new password'), validators=[Optional(), Length(min=1, max=50)],
render_kw={"autocomplete": 'new-password'})
about = TextAreaField(_l('Bio'), validators=[Optional(), Length(min=3, max=5000)])
about = TextAreaField(_l('Bio'), validators=[Optional(), Length(min=3, max=5000)], render_kw={'rows': 5})
matrixuserid = StringField(_l('Matrix User ID'), validators=[Optional(), Length(max=255)], render_kw={'autocomplete': 'off'})
profile_file = FileField(_('Avatar image'))
banner_file = FileField(_('Top banner image'))

View file

@ -19,7 +19,7 @@ from app.user.utils import purge_user_then_delete
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, user_filters_home, \
user_filters_posts, user_filters_replies, moderating_communities, joined_communities, theme_list, blocked_instances, \
allowlist_html
allowlist_html, recently_upvoted_posts, recently_downvoted_posts
from sqlalchemy import desc, or_, text
import os
@ -85,7 +85,7 @@ def show_profile(user):
description=description, subscribed=subscribed, upvoted=upvoted,
post_next_url=post_next_url, post_prev_url=post_prev_url,
replies_next_url=replies_next_url, replies_prev_url=replies_prev_url,
noindex=not user.indexable, show_post_community=True,
noindex=not user.indexable, show_post_community=True, hide_vote_buttons=True,
moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id())
)

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import bisect
import hashlib
import mimetypes
import random
@ -220,8 +221,8 @@ def markdown_to_html(markdown_text) -> str:
if markdown_text:
raw_html = markdown2.markdown(markdown_text, safe_mode=True, extras={'middle-word-em': False, 'tables': True, 'fenced-code-blocks': True, 'strike': True})
# replace lemmy spoiler tokens with appropriate html tags instead. (until possibly added as extra to markdown2)
re_spoiler = re.compile(r':{3} spoiler\s+?(\S.+?\n)(.+?)\n:{3}', re.S)
raw_html = re_spoiler.sub(r'<details><summary>\1</summary>\2</details>', raw_html)
re_spoiler = re.compile(r':{3} spoiler\s+?(\S.+?)(?:\n|</p>)(.+?)(?:\n|<p>):{3}', re.S)
raw_html = re_spoiler.sub(r'<details><summary>\1</summary><p>\2</p></details>', raw_html)
return allowlist_html(raw_html)
else:
return ''
@ -244,6 +245,8 @@ def microblog_content_to_title(html: str) -> str:
continue
else:
tag = tag.extract()
else:
tag = tag.extract()
if title_found:
result = soup.text
@ -451,6 +454,8 @@ def user_ip_banned() -> bool:
@cache.memoize(timeout=30)
def instance_banned(domain: str) -> bool: # see also activitypub.util.instance_blocked()
if domain is None or domain == '':
return False
banned = BannedInstances.query.filter_by(domain=domain).first()
return banned is not None
@ -794,9 +799,13 @@ def current_theme():
if current_user.theme is not None and current_user.theme != '':
return current_user.theme
else:
return g.site.default_theme if g.site.default_theme is not None else ''
if hasattr(g, 'site'):
site = g.site
else:
site = Site.query.get(1)
return site.default_theme if site.default_theme is not None else ''
else:
return g.site.default_theme if g.site.default_theme is not None else ''
return ''
def theme_list():
@ -855,3 +864,37 @@ def show_ban_message():
resp = make_response(redirect(url_for('main.index')))
resp.set_cookie('sesion', '17489047567495', expires=datetime(year=2099, month=12, day=30))
return resp
# search a sorted list using a binary search. Faster than using 'in' with a unsorted list.
def in_sorted_list(arr, target):
index = bisect.bisect_left(arr, target)
return index < len(arr) and arr[index] == target
@cache.memoize(timeout=600)
def recently_upvoted_posts(user_id) -> List[int]:
post_ids = db.session.execute(text('SELECT post_id FROM "post_vote" WHERE user_id = :user_id AND effect > 0 ORDER BY id DESC LIMIT 1000'),
{'user_id': user_id}).scalars()
return sorted(post_ids) # sorted so that in_sorted_list can be used
@cache.memoize(timeout=600)
def recently_downvoted_posts(user_id) -> List[int]:
post_ids = db.session.execute(text('SELECT post_id FROM "post_vote" WHERE user_id = :user_id AND effect < 0 ORDER BY id DESC LIMIT 1000'),
{'user_id': user_id}).scalars()
return sorted(post_ids)
@cache.memoize(timeout=600)
def recently_upvoted_post_replies(user_id) -> List[int]:
reply_ids = db.session.execute(text('SELECT post_reply_id FROM "post_reply_vote" WHERE user_id = :user_id AND effect > 0 ORDER BY id DESC LIMIT 1000'),
{'user_id': user_id}).scalars()
return sorted(reply_ids) # sorted so that in_sorted_list can be used
@cache.memoize(timeout=600)
def recently_downvoted_post_replies(user_id) -> List[int]:
reply_ids = db.session.execute(text('SELECT post_reply_id FROM "post_reply_vote" WHERE user_id = :user_id AND effect < 0 ORDER BY id DESC LIMIT 1000'),
{'user_id': user_id}).scalars()
return sorted(reply_ids)

View file

@ -2,3 +2,5 @@
This document helps prevent rearguing decisions after they have been made. It can also help new contributors come up to
speed by providing a summary of how we got to the present state.
[Switch to using HTML while federating content instead of Markdown](https://codeberg.org/rimu/pyfedi/issues/133#issuecomment-1756067)

View file

@ -0,0 +1,32 @@
"""contact email
Revision ID: 91a931afd6d9
Revises: 08b3f718df5d
Create Date: 2024-04-12 16:22:32.137053
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '91a931afd6d9'
down_revision = '08b3f718df5d'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('site', schema=None) as batch_op:
batch_op.add_column(sa.Column('contact_email', sa.String(length=255), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('site', schema=None) as batch_op:
batch_op.drop_column('contact_email')
# ### end Alembic commands ###

View file

@ -11,7 +11,8 @@ from flask import session, g, json, request, current_app
from app.constants import POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_ARTICLE
from app.models import Site
from app.utils import getmtime, gibberish, shorten_string, shorten_url, digits, user_access, community_membership, \
can_create_post, can_upvote, can_downvote, shorten_number, ap_datetime, current_theme, community_link_to_href
can_create_post, can_upvote, can_downvote, shorten_number, ap_datetime, current_theme, community_link_to_href, \
in_sorted_list
app = create_app()
cli.register(app)
@ -42,6 +43,7 @@ with app.app_context():
app.jinja_env.globals['can_create'] = can_create_post
app.jinja_env.globals['can_upvote'] = can_upvote
app.jinja_env.globals['can_downvote'] = can_downvote
app.jinja_env.globals['in_sorted_list'] = in_sorted_list
app.jinja_env.globals['theme'] = current_theme
app.jinja_env.globals['file_exists'] = os.path.exists
app.jinja_env.filters['community_links'] = community_link_to_href
@ -53,7 +55,8 @@ with app.app_context():
def before_request():
session['nonce'] = gibberish()
g.locale = str(get_locale())
g.site = Site.query.get(1)
if request.path != '/inbox' and not request.path.startswith('/static/'): # do not load g.site on shared inbox, to increase chance of duplicate detection working properly
g.site = Site.query.get(1)
if current_user.is_authenticated:
current_user.last_seen = datetime.utcnow()
current_user.email_unread_sent = False