federating replies and lots of caching

This commit is contained in:
rimu 2023-12-10 15:10:09 +13:00
parent 7fd8935983
commit 094708f396
11 changed files with 128 additions and 50 deletions

View file

@ -13,7 +13,7 @@ from app.models import User, Community, CommunityJoinRequest, CommunityMember, C
PostReply, Instance, PostVote, PostReplyVote, File, AllowedInstances, BannedInstances PostReply, Instance, PostVote, PostReplyVote, File, AllowedInstances, BannedInstances
from app.activitypub.util import public_key, users_total, active_half_year, active_month, local_posts, local_comments, \ from app.activitypub.util import public_key, users_total, active_half_year, active_month, local_posts, local_comments, \
post_to_activity, find_actor_or_create, default_context, instance_blocked, find_reply_parent, find_liked_object, \ post_to_activity, find_actor_or_create, default_context, instance_blocked, find_reply_parent, find_liked_object, \
lemmy_site_data, instance_weight lemmy_site_data, instance_weight, cache_key_by_ap_header, is_activitypub_request
from app.utils import gibberish, get_setting, is_image_url, allowlist_html, html_to_markdown, render_template, \ from app.utils import gibberish, get_setting, is_image_url, allowlist_html, html_to_markdown, render_template, \
domain_from_url, markdown_to_html, community_membership, ap_datetime domain_from_url, markdown_to_html, community_membership, ap_datetime
import werkzeug.exceptions import werkzeug.exceptions
@ -69,6 +69,7 @@ def webfinger():
@bp.route('/.well-known/nodeinfo') @bp.route('/.well-known/nodeinfo')
@cache.cached(timeout=600)
def nodeinfo(): def nodeinfo():
nodeinfo_data = {"links": [{"rel": "http://nodeinfo.diaspora.software/ns/schema/2.0", nodeinfo_data = {"links": [{"rel": "http://nodeinfo.diaspora.software/ns/schema/2.0",
"href": f"https://{current_app.config['SERVER_NAME']}/nodeinfo/2.0"}]} "href": f"https://{current_app.config['SERVER_NAME']}/nodeinfo/2.0"}]}
@ -77,6 +78,7 @@ def nodeinfo():
@bp.route('/nodeinfo/2.0') @bp.route('/nodeinfo/2.0')
@bp.route('/nodeinfo/2.0.json') @bp.route('/nodeinfo/2.0.json')
@cache.cached(timeout=600)
def nodeinfo2(): def nodeinfo2():
nodeinfo_data = { nodeinfo_data = {
@ -103,11 +105,13 @@ def nodeinfo2():
@bp.route('/api/v3/site') @bp.route('/api/v3/site')
@cache.cached(timeout=600)
def lemmy_site(): def lemmy_site():
return jsonify(lemmy_site_data()) return jsonify(lemmy_site_data())
@bp.route('/api/v3/federated_instances') @bp.route('/api/v3/federated_instances')
@cache.cached(timeout=600)
def lemmy_federated_instances(): def lemmy_federated_instances():
instances = Instance.query.all() instances = Instance.query.all()
linked = [] linked = []
@ -133,11 +137,8 @@ def lemmy_federated_instances():
}) })
def is_activitypub_request():
return 'application/ld+json' in request.headers.get('Accept', '') or 'application/activity+json' in request.headers.get('Accept', '')
@bp.route('/u/<actor>', methods=['GET']) @bp.route('/u/<actor>', methods=['GET'])
@cache.cached(timeout=10, make_cache_key=cache_key_by_ap_header)
def user_profile(actor): def user_profile(actor):
""" Requests to this endpoint can be for a JSON representation of the user, or a HTML rendering of their profile. """ Requests to this endpoint can be for a JSON representation of the user, or a HTML rendering of their profile.
The two types of requests are differentiated by the header """ The two types of requests are differentiated by the header """
@ -290,7 +291,8 @@ def shared_inbox():
community = find_actor_or_create(community_ap_id) community = find_actor_or_create(community_ap_id)
user = find_actor_or_create(user_ap_id) user = find_actor_or_create(user_ap_id)
if user and community: if user and community:
user.last_seen = datetime.utcnow() user.last_seen = community.last_active = datetime.utcnow()
object_type = request_json['object']['type'] object_type = request_json['object']['type']
new_content_types = ['Page', 'Article', 'Link', 'Note'] new_content_types = ['Page', 'Article', 'Link', 'Note']
if object_type in new_content_types: # create a new post if object_type in new_content_types: # create a new post
@ -399,7 +401,7 @@ def shared_inbox():
community = find_actor_or_create(community_ap_id) community = find_actor_or_create(community_ap_id)
user = find_actor_or_create(user_ap_id) user = find_actor_or_create(user_ap_id)
if user and community: if user and community:
user.last_seen = datetime.utcnow() user.last_seen = community.last_active = datetime.utcnow()
object_type = request_json['object']['object']['type'] object_type = request_json['object']['object']['type']
new_content_types = ['Page', 'Article', 'Link', 'Note'] new_content_types = ['Page', 'Article', 'Link', 'Note']
if object_type in new_content_types: # create a new post if object_type in new_content_types: # create a new post
@ -853,6 +855,13 @@ def shared_inbox():
activity_log.result = 'success' activity_log.result = 'success'
else: else:
activity_log.exception_message = 'Could not find user or content for vote' activity_log.exception_message = 'Could not find user or content for vote'
# Flush the caches of any major object that was created. To be sure.
if 'user' in vars() and user is not None:
user.flush_cache()
#if 'community' in vars() and community is not None:
# community.flush_cache()
if 'post' in vars() and post is not None:
post.flush_cache()
else: else:
activity_log.exception_message = 'Instance banned' activity_log.exception_message = 'Instance banned'
else: else:
@ -889,6 +898,7 @@ def community_outbox(actor):
@bp.route('/c/<actor>/moderators', methods=['GET']) @bp.route('/c/<actor>/moderators', methods=['GET'])
@cache.cached(timeout=10, make_cache_key=cache_key_by_ap_header)
def community_moderators(actor): def community_moderators(actor):
actor = actor.strip() actor = actor.strip()
community = Community.query.filter_by(name=actor, banned=False, ap_id=None).first() community = Community.query.filter_by(name=actor, banned=False, ap_id=None).first()
@ -935,6 +945,7 @@ def inbox(actor):
@bp.route('/comment/<int:comment_id>', methods=['GET']) @bp.route('/comment/<int:comment_id>', methods=['GET'])
@cache.cached(timeout=10, make_cache_key=cache_key_by_ap_header)
def comment_ap(comment_id): def comment_ap(comment_id):
if is_activitypub_request(): if is_activitypub_request():
reply = PostReply.query.get_or_404(comment_id) reply = PostReply.query.get_or_404(comment_id)
@ -974,6 +985,7 @@ def comment_ap(comment_id):
@bp.route('/post/<int:post_id>', methods=['GET', 'POST']) @bp.route('/post/<int:post_id>', methods=['GET', 'POST'])
@cache.cached(timeout=10, make_cache_key=cache_key_by_ap_header)
def post_ap(post_id): def post_ap(post_id):
if request.method == 'GET' and is_activitypub_request(): if request.method == 'GET' and is_activitypub_request():
post = Post.query.get_or_404(post_id) post = Post.query.get_or_404(post_id)

View file

@ -2,7 +2,7 @@ import json
import os import os
from datetime import datetime from datetime import datetime
from typing import Union, Tuple from typing import Union, Tuple
from flask import current_app from flask import current_app, request
from sqlalchemy import text from sqlalchemy import text
from app import db, cache from app import db, cache
from app.models import User, Post, Community, BannedInstances, File, PostReply, AllowedInstances, Instance from app.models import User, Post, Community, BannedInstances, File, PostReply, AllowedInstances, Instance
@ -405,6 +405,15 @@ def instance_weight(domain):
return 1.0 return 1.0
def is_activitypub_request():
return 'application/ld+json' in request.headers.get('Accept', '') or 'application/activity+json' in request.headers.get('Accept', '')
# differentiate between cached JSON and cached HTML by appending is_activitypub_request() to the cache key
def cache_key_by_ap_header(**kwargs):
return request.path + "_" + str(is_activitypub_request())
def lemmy_site_data(): def lemmy_site_data():
data = { data = {
"site_view": { "site_view": {

View file

@ -16,7 +16,8 @@ from app.models import User, Community, CommunityMember, CommunityJoinRequest, C
File, PostVote File, PostVote
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
import os import os
from PIL import Image, ImageOps from PIL import Image, ImageOps
from datetime import datetime from datetime import datetime
@ -87,6 +88,12 @@ def add_remote():
# @bp.route('/c/<actor>', methods=['GET']) - defined in activitypub/routes.py, which calls this function for user requests. A bit weird. # @bp.route('/c/<actor>', methods=['GET']) - defined in activitypub/routes.py, which calls this function for user requests. A bit weird.
def show_community(community: Community): def show_community(community: Community):
# If nothing has changed since their last visit, return HTTP 304
current_etag = f"{community.id}_{hash(community.last_active)}"
if request_etag_matches(current_etag):
return return_304(current_etag)
mods = community.moderators() mods = community.moderators()
is_moderator = current_user.is_authenticated and any(mod.user_id == current_user.id for mod in mods) is_moderator = current_user.is_authenticated and any(mod.user_id == current_user.id for mod in mods)
@ -110,7 +117,7 @@ def show_community(community: Community):
return render_template('community/community.html', community=community, title=community.title, return render_template('community/community.html', community=community, title=community.title,
is_moderator=is_moderator, is_owner=is_owner, mods=mod_list, posts=posts, description=description, is_moderator=is_moderator, is_owner=is_owner, 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, 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_MEMBER=SUBSCRIPTION_MEMBER, etag=f"{community.id}_{hash(community.last_active)}")
@bp.route('/<actor>/subscribe', methods=['GET']) @bp.route('/<actor>/subscribe', methods=['GET'])

View file

@ -355,8 +355,6 @@ class User(UserMixin, db.Model):
def profile_id(self): def profile_id(self):
return self.ap_profile_id if self.ap_profile_id else f"https://{current_app.config['SERVER_NAME']}/u/{self.user_name}" return self.ap_profile_id if self.ap_profile_id else f"https://{current_app.config['SERVER_NAME']}/u/{self.user_name}"
def created_recently(self): def created_recently(self):
return self.created and self.created > datetime.utcnow() - timedelta(days=7) return self.created and self.created > datetime.utcnow() - timedelta(days=7)
@ -369,6 +367,10 @@ class User(UserMixin, db.Model):
return return
return User.query.get(id) return User.query.get(id)
def flush_cache(self):
cache.delete('/u/' + self.user_name + '_False')
cache.delete('/u/' + self.user_name + '_True')
def purge_content(self): def purge_content(self):
files = File.query.join(Post).filter(Post.user_id == self.id).all() files = File.query.join(Post).filter(Post.user_id == self.id).all()
for file in files: for file in files:
@ -446,6 +448,7 @@ class Post(db.Model):
image = db.relationship(File, lazy='joined', foreign_keys=[image_id], cascade="all, delete") image = db.relationship(File, lazy='joined', foreign_keys=[image_id], cascade="all, delete")
domain = db.relationship('Domain', lazy='joined', foreign_keys=[domain_id]) domain = db.relationship('Domain', lazy='joined', foreign_keys=[domain_id])
author = db.relationship('User', lazy='joined', overlaps='posts', foreign_keys=[user_id]) author = db.relationship('User', lazy='joined', overlaps='posts', foreign_keys=[user_id])
replies = db.relationship('PostReply', lazy='dynamic', backref='post')
def is_local(self): def is_local(self):
return self.ap_id is None or self.ap_id.startswith('https://' + current_app.config['SERVER_NAME']) return self.ap_id is None or self.ap_id.startswith('https://' + current_app.config['SERVER_NAME'])
@ -472,6 +475,10 @@ class Post(db.Model):
def profile_id(self): def profile_id(self):
return f"https://{current_app.config['SERVER_NAME']}/post/{self.id}" return f"https://{current_app.config['SERVER_NAME']}/post/{self.id}"
def flush_cache(self):
cache.delete(f'/post/{self.id}_False')
cache.delete(f'/post/{self.id}_True')
class PostReply(db.Model): class PostReply(db.Model):
query_class = FullTextSearchQuery query_class = FullTextSearchQuery
@ -515,7 +522,10 @@ class PostReply(db.Model):
return cls.query.filter_by(ap_id=ap_id).first() return cls.query.filter_by(ap_id=ap_id).first()
def profile_id(self): def profile_id(self):
return f"https://{current_app.config['SERVER_NAME']}/comment/{self.id}" if self.ap_id:
return self.ap_id
else:
return f"https://{current_app.config['SERVER_NAME']}/comment/{self.id}"
# the ap_id of the parent object, whether it's another PostReply or a Post # the ap_id of the parent object, whether it's another PostReply or a Post
def in_reply_to(self): def in_reply_to(self):

View file

@ -1,12 +1,13 @@
from datetime import datetime from datetime import datetime
from flask import redirect, url_for, flash, request, make_response, session, Markup, current_app, abort from flask import redirect, url_for, flash, current_app, abort
from flask_login import login_user, logout_user, current_user, login_required from flask_login import login_user, logout_user, current_user, login_required
from flask_babel import _ from flask_babel import _
from sqlalchemy import or_, desc from sqlalchemy import or_, desc
from app import db, constants from app import db, constants
from app.activitypub.signature import HttpSignature from app.activitypub.signature import HttpSignature
from app.activitypub.util import default_context
from app.community.util import save_post from app.community.util import save_post
from app.post.forms import NewReplyForm from app.post.forms import NewReplyForm
from app.community.forms import CreatePostForm from app.community.forms import CreatePostForm
@ -16,11 +17,18 @@ from app.models import Post, PostReply, \
PostReplyVote, PostVote, Notification PostReplyVote, PostVote, Notification
from app.post import bp from app.post 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, ap_datetime shorten_string, markdown_to_text, domain_from_url, validate_image, gibberish, ap_datetime, return_304, \
request_etag_matches
def show_post(post_id: int): def show_post(post_id: int):
post = Post.query.get_or_404(post_id) post = Post.query.get_or_404(post_id)
# If nothing has changed since their last visit, return HTTP 304
current_etag = f"{post.id}_{hash(post.last_active)}"
if request_etag_matches(current_etag):
return return_304(current_etag)
mods = post.community.moderators() mods = post.community.moderators()
is_moderator = current_user.is_authenticated and any(mod.user_id == current_user.id for mod in mods) is_moderator = current_user.is_authenticated and any(mod.user_id == current_user.id for mod in mods)
@ -33,11 +41,12 @@ def show_post(post_id: int):
notify_author=form.notify_author.data) notify_author=form.notify_author.data)
if post.notify_author and current_user.id != post.user_id: # todo: check if replier is blocked if post.notify_author and current_user.id != post.user_id: # todo: check if replier is blocked
notification = Notification(title=_('Reply: ') + shorten_string(form.body.data), user_id=post.user_id, notification = Notification(title=_('Reply: ') + shorten_string(form.body.data), user_id=post.user_id,
author_id=current_user.id, url=url_for('post.show_post', post_id=post.id)) author_id=current_user.id, url=url_for('activitypub.post_ap', post_id=post.id))
db.session.add(notification) db.session.add(notification)
post.last_active = post.community.last_active = datetime.utcnow() post.last_active = post.community.last_active = datetime.utcnow()
post.reply_count += 1 post.reply_count += 1
post.community.post_reply_count += 1 post.community.post_reply_count += 1
db.session.add(reply) db.session.add(reply)
db.session.commit() db.session.commit()
reply.ap_id = reply.profile_id() reply.ap_id = reply.profile_id()
@ -47,7 +56,9 @@ def show_post(post_id: int):
db.session.commit() db.session.commit()
form.body.data = '' form.body.data = ''
flash('Your comment has been added.') flash('Your comment has been added.')
# todo: flush cache
post.flush_cache()
# federation # federation
if not post.community.is_local(): # this is a remote community, send it to the instance that hosts it if not post.community.is_local(): # this is a remote community, send it to the instance that hosts it
reply_json = { reply_json = {
@ -100,7 +111,7 @@ def show_post(post_id: int):
else: # local community - send it to followers on remote instances else: # local community - send it to followers on remote instances
... ...
return redirect(url_for('post.show_post', return redirect(url_for('activitypub.post_ap',
post_id=post_id)) # redirect to current page to avoid refresh resubmitting the form post_id=post_id)) # redirect to current page to avoid refresh resubmitting the form
else: else:
replies = post_replies(post.id, 'top') replies = post_replies(post.id, 'top')
@ -112,7 +123,8 @@ def show_post(post_id: int):
return render_template('post/post.html', title=post.title, post=post, is_moderator=is_moderator, return render_template('post/post.html', title=post.title, post=post, is_moderator=is_moderator,
canonical=post.ap_id, form=form, replies=replies, THREAD_CUTOFF_DEPTH=constants.THREAD_CUTOFF_DEPTH, 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, 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) POST_TYPE_LINK=constants.POST_TYPE_LINK, POST_TYPE_ARTICLE=constants.POST_TYPE_ARTICLE,
etag=f"{post.id}_{hash(post.last_active)}")
@bp.route('/post/<int:post_id>/<vote_direction>', methods=['GET', 'POST']) @bp.route('/post/<int:post_id>/<vote_direction>', methods=['GET', 'POST'])
@ -163,6 +175,7 @@ def post_vote(post_id: int, vote_direction):
db.session.add(vote) db.session.add(vote)
current_user.last_seen = datetime.utcnow() current_user.last_seen = datetime.utcnow()
db.session.commit() db.session.commit()
post.flush_cache()
return render_template('post/_post_voting_buttons.html', post=post, return render_template('post/_post_voting_buttons.html', post=post,
upvoted_class=upvoted_class, upvoted_class=upvoted_class,
downvoted_class=downvoted_class) downvoted_class=downvoted_class)
@ -214,6 +227,7 @@ def comment_vote(comment_id, vote_direction):
db.session.add(vote) db.session.add(vote)
current_user.last_seen = datetime.utcnow() current_user.last_seen = datetime.utcnow()
db.session.commit() db.session.commit()
comment.post.flush_cache()
return render_template('post/_voting_buttons.html', comment=comment, return render_template('post/_voting_buttons.html', comment=comment,
upvoted_class=upvoted_class, upvoted_class=upvoted_class,
downvoted_class=downvoted_class) downvoted_class=downvoted_class)
@ -249,7 +263,7 @@ def add_reply(post_id: int, comment_id: int):
db.session.add(reply) db.session.add(reply)
if in_reply_to.notify_author and current_user.id != in_reply_to.user_id and in_reply_to.author.ap_id is None: # todo: check if replier is blocked if in_reply_to.notify_author and current_user.id != in_reply_to.user_id and in_reply_to.author.ap_id is None: # todo: check if replier is blocked
notification = Notification(title=_('Reply: ') + shorten_string(form.body.data), user_id=in_reply_to.user_id, notification = Notification(title=_('Reply: ') + shorten_string(form.body.data), user_id=in_reply_to.user_id,
author_id=current_user.id, url=url_for('post.show_post', post_id=post.id)) author_id=current_user.id, url=url_for('activitypub.post_ap', post_id=post.id))
db.session.add(notification) db.session.add(notification)
db.session.commit() db.session.commit()
reply.ap_id = reply.profile_id() reply.ap_id = reply.profile_id()
@ -262,7 +276,8 @@ def add_reply(post_id: int, comment_id: int):
db.session.commit() db.session.commit()
form.body.data = '' form.body.data = ''
flash('Your comment has been added.') flash('Your comment has been added.')
# todo: flush cache
post.flush_cache()
# federation # federation
if not post.community.is_local(): # this is a remote community, send it to the instance that hosts it if not post.community.is_local(): # this is a remote community, send it to the instance that hosts it
@ -271,13 +286,16 @@ def add_reply(post_id: int, comment_id: int):
'id': reply.profile_id(), 'id': reply.profile_id(),
'attributedTo': current_user.profile_id(), 'attributedTo': current_user.profile_id(),
'to': [ 'to': [
'https://www.w3.org/ns/activitystreams#Public' 'https://www.w3.org/ns/activitystreams#Public',
in_reply_to.author.profile_id()
], ],
'cc': [ 'cc': [
post.community.profile_id(), post.community.profile_id(),
current_user.followers_url()
], ],
'content': reply.body_html, 'content': reply.body_html,
'inReplyTo': in_reply_to.profile_id(), 'inReplyTo': in_reply_to.profile_id(),
'url': reply.profile_id(),
'mediaType': 'text/html', 'mediaType': 'text/html',
'source': { 'source': {
'content': reply.body, 'content': reply.body,
@ -285,31 +303,28 @@ def add_reply(post_id: int, comment_id: int):
}, },
'published': ap_datetime(datetime.utcnow()), 'published': ap_datetime(datetime.utcnow()),
'distinguished': False, 'distinguished': False,
'audience': post.community.profile_id() 'audience': post.community.profile_id(),
'contentMap': {
'en': reply.body_html
}
} }
create_json = { create_json = {
'@context': default_context(),
'type': 'Create', 'type': 'Create',
'actor': current_user.profile_id(), 'actor': current_user.profile_id(),
'audience': post.community.profile_id(), 'audience': post.community.profile_id(),
'to': [ 'to': [
'https://www.w3.org/ns/activitystreams#Public' 'https://www.w3.org/ns/activitystreams#Public',
in_reply_to.author.profile_id()
], ],
'cc': [ 'cc': [
post.community.ap_profile_id post.community.profile_id(),
current_user.followers_url()
], ],
'object': reply_json, 'object': reply_json,
'id': f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}" 'id': f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}"
} }
if in_reply_to.notify_author and in_reply_to.author.ap_id is not None: if in_reply_to.notify_author and in_reply_to.author.ap_id is not None:
create_json['cc'].append(in_reply_to.author.ap_profile_id)
create_json['tag'] = [
{
'href': in_reply_to.author.ap_profile_id,
'name': '@' + in_reply_to.author.ap_id,
'type': 'Mention'
}
]
reply_json['cc'].append(in_reply_to.author.ap_profile_id)
reply_json['tag'] = [ reply_json['tag'] = [
{ {
'href': in_reply_to.author.ap_profile_id, 'href': in_reply_to.author.ap_profile_id,
@ -327,12 +342,12 @@ def add_reply(post_id: int, comment_id: int):
current_app.logger.error('Response code for reply attempt was ' + current_app.logger.error('Response code for reply attempt was ' +
str(message.status_code) + ' ' + message.text) str(message.status_code) + ' ' + message.text)
except Exception as ex: except Exception as ex:
flash('Failed to send request to subscribe: ' + str(ex), 'error') flash('Failed to send reply: ' + str(ex), 'error')
current_app.logger.error("Exception while trying to subscribe" + str(ex)) current_app.logger.error("Exception while trying to send reply" + str(ex))
else: # local community - send it to followers on remote instances else: # local community - send it to followers on remote instances
... ...
if reply.depth <= constants.THREAD_CUTOFF_DEPTH: if reply.depth <= constants.THREAD_CUTOFF_DEPTH:
return redirect(url_for('post.show_post', post_id=post_id, _anchor=f'comment_{reply.parent_id}')) return redirect(url_for('activitypub.post_ap', post_id=post_id, _anchor=f'comment_{reply.parent_id}'))
else: else:
return redirect(url_for('post.continue_discussion', post_id=post_id, comment_id=reply.parent_id)) return redirect(url_for('post.continue_discussion', post_id=post_id, comment_id=reply.parent_id))
else: else:
@ -365,8 +380,9 @@ def post_edit(post_id: int):
post.community.last_active = datetime.utcnow() post.community.last_active = datetime.utcnow()
post.edited_at = datetime.utcnow() post.edited_at = datetime.utcnow()
db.session.commit() db.session.commit()
post.flush_cache()
flash(_('Your changes have been saved.'), 'success') flash(_('Your changes have been saved.'), 'success')
return redirect(url_for('post.show_post', post_id=post.id)) return redirect(url_for('activitypub.post_ap', post_id=post.id))
else: else:
if post.type == constants.POST_TYPE_ARTICLE: if post.type == constants.POST_TYPE_ARTICLE:
form.type.data = 'discussion' form.type.data = 'discussion'
@ -392,6 +408,7 @@ def post_delete(post_id: int):
community = post.community community = post.community
if post.user_id == current_user.id or community.is_moderator(): if post.user_id == current_user.id or community.is_moderator():
post.delete_dependencies() post.delete_dependencies()
post.flush_cache()
db.session.delete(post) db.session.delete(post)
db.session.commit() db.session.commit()
flash('Post deleted.') flash('Post deleted.')

View file

@ -4,7 +4,7 @@
<div class="row main_row"> <div class="row main_row">
<div class="col"> <div class="col">
<h3> <h3>
<a href="{{ url_for('post.show_post', post_id=post.id) }}">{{ post.title }}</a> <a href="{{ url_for('activitypub.post_ap', post_id=post.id) }}">{{ post.title }}</a>
{% if post.type == POST_TYPE_IMAGE %}<span class="fe fe-image"> </span>{% endif %} {% 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.type == POST_TYPE_LINK and post.domain_id %}
{% if post.url and 'youtube.com' in post.url %} {% if post.url and 'youtube.com' in post.url %}
@ -19,7 +19,7 @@
<span class="small">{{ render_username(post.author) }} · {{ moment(post.posted_at).fromNow() }}</span> <span class="small">{{ render_username(post.author) }} · {{ moment(post.posted_at).fromNow() }}</span>
{% if post.image_id %} {% if post.image_id %}
<div class="thumbnail"> <div class="thumbnail">
<a href="{{ url_for('post.show_post', post_id=post.id) }}"><img src="{{ post.image.thumbnail_url() }}" alt="{{ post.image.alt_text }}" <a href="{{ url_for('activitypub.post_ap', post_id=post.id) }}"><img src="{{ post.image.thumbnail_url() }}" alt="{{ post.image.alt_text }}"
height="50" /></a> height="50" /></a>
</div> </div>
{% endif %} {% endif %}
@ -28,8 +28,8 @@
</div> </div>
<div class="row utilities_row"> <div class="row utilities_row">
<div class="col-6"> <div class="col-6">
<a href="{{ url_for('post.show_post', 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') }}"><span class="fe fe-reply"></span></a>
<a href="{{ url_for('post.show_post', post_id=post.id, _anchor='replies') }}">{{ post.reply_count }}</a> <a href="{{ url_for('activitypub.post_ap', post_id=post.id, _anchor='replies') }}">{{ post.reply_count }}</a>
</div> </div>
<div class="col-2"><a href="{{ url_for('post.post_options', post_id=post.id) }}"><span class="fe fe-options" title="Options"> </span></a></div> <div class="col-2"><a href="{{ url_for('post.post_options', post_id=post.id) }}"><span class="fe fe-options" title="Options"> </span></a></div>
</div> </div>

View file

@ -5,7 +5,7 @@
<div class="row"> <div class="row">
<div class="col-12 col-md-8 position-relative main_pane"> <div class="col-12 col-md-8 position-relative main_pane">
{% include 'post/_post_full.html' %} {% include 'post/_post_full.html' %}
<p><a href="{{ url_for('post.show_post', post_id=post.id, _anchor='replies') }}">Back to main discussion</a></p> <p><a href="{{ url_for('activitypub.post_ap', post_id=post.id, _anchor='replies') }}">Back to main discussion</a></p>
<div class="row post_replies"> <div class="row post_replies">
<div class="col"> <div class="col">
{% macro render_comment(comment) %} {% macro render_comment(comment) %}

View file

@ -93,7 +93,7 @@
<div class="card-body"> <div class="card-body">
<ul> <ul>
{% for post in upvoted %} {% for post in upvoted %}
<li><a href="{{ url_for('post.show_post', post_id=post.id) }}">{{ post.title }}</a></li> <li><a href="{{ url_for('activitypub.post_ap', post_id=post.id) }}">{{ post.title }}</a></li>
{% endfor %} {% endfor %}
</ul> </ul>

View file

@ -143,7 +143,7 @@
<ul> <ul>
{% for post in upvoted %} {% for post in upvoted %}
<li><a href="{{ url_for('post.show_post', post_id=post.id) }}">{{ post.title }}</a></li> <li><a href="{{ url_for('activitypub.post_ap', post_id=post.id) }}">{{ post.title }}</a></li>
{% endfor %} {% endfor %}
</ul> </ul>

View file

@ -4,7 +4,7 @@ from flask import redirect, url_for, flash, request, make_response, session, Mar
from flask_login import login_user, logout_user, current_user, login_required from flask_login import login_user, logout_user, current_user, login_required
from flask_babel import _ from flask_babel import _
from app import db from app import db, cache
from app.models import Post, Community, CommunityMember, User, PostReply, PostVote, Notification from app.models import Post, Community, CommunityMember, User, PostReply, PostVote, Notification
from app.user import bp from app.user import bp
from app.user.forms import ProfileForm, SettingsForm from app.user.forms import ProfileForm, SettingsForm
@ -47,9 +47,11 @@ def edit_profile(actor):
if form.password_field.data.strip() != '': if form.password_field.data.strip() != '':
current_user.set_password(form.password_field.data) current_user.set_password(form.password_field.data)
current_user.about = form.about.data current_user.about = form.about.data
current_user.flush_cache()
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.edit_profile', actor=actor)) return redirect(url_for('user.edit_profile', actor=actor))
elif request.method == 'GET': elif request.method == 'GET':
form.email.data = current_user.email form.email.data = current_user.email

View file

@ -11,7 +11,7 @@ from bs4 import BeautifulSoup
import requests import requests
import os import os
import imghdr import imghdr
from flask import current_app, json, redirect, url_for, request from flask import current_app, json, redirect, url_for, request, make_response, Response
from flask_login import current_user from flask_login import current_user
from sqlalchemy import text from sqlalchemy import text
@ -20,12 +20,33 @@ from app.models import Settings, Domain, Instance, BannedInstances, User, Commun
# Flask's render_template function, with support for themes added # Flask's render_template function, with support for themes added
def render_template(template_name: str, **context) -> str: def render_template(template_name: str, **context) -> Response:
theme = get_setting('theme', '') theme = get_setting('theme', '')
if theme != '': if theme != '':
return flask.render_template(f'themes/{theme}/{template_name}', **context) content = flask.render_template(f'themes/{theme}/{template_name}', **context)
else: else:
return flask.render_template(template_name, **context) content = flask.render_template(template_name, **context)
# Browser caching using ETags and Cache-Control
resp = make_response(content)
if 'etag' in context:
resp.headers.add_header('ETag', context['etag'])
resp.headers.add_header('Cache-Control', 'no-cache, max-age=600, must-revalidate')
return resp
def request_etag_matches(etag):
if 'If-None-Match' in request.headers:
old_etag = request.headers['If-None-Match']
return old_etag == etag
return False
def return_304(etag):
resp = make_response('', 304)
resp.headers.add_header('ETag', request.headers['If-None-Match'])
resp.headers.add_header('Cache-Control', 'no-cache, max-age=600, must-revalidate')
return resp
# Jinja: when a file was modified. Useful for cache-busting # Jinja: when a file was modified. Useful for cache-busting