mirror of
https://codeberg.org/rimu/pyfedi
synced 2025-01-23 19:36:56 -08:00
show replies below posts
This commit is contained in:
parent
8a18573974
commit
fa53128118
14 changed files with 299 additions and 28 deletions
|
@ -1,6 +1,3 @@
|
||||||
import markdown2
|
|
||||||
import werkzeug.exceptions
|
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
from app.activitypub import bp
|
from app.activitypub import bp
|
||||||
from flask import request, Response, current_app, abort, jsonify, json
|
from flask import request, Response, current_app, abort, jsonify, json
|
||||||
|
@ -14,7 +11,8 @@ from app.models import User, Community, CommunityJoinRequest, CommunityMember, C
|
||||||
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
|
||||||
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
|
domain_from_url, markdown_to_html
|
||||||
|
import werkzeug.exceptions
|
||||||
|
|
||||||
INBOX = []
|
INBOX = []
|
||||||
|
|
||||||
|
@ -134,7 +132,7 @@ def user_profile(actor):
|
||||||
"content": user.about,
|
"content": user.about,
|
||||||
"mediaType": "text/markdown"
|
"mediaType": "text/markdown"
|
||||||
}
|
}
|
||||||
actor_data['summary'] = allowlist_html(markdown2.markdown(user.about, safe_mode=True))
|
actor_data['summary'] = markdown_to_html(user.about)
|
||||||
resp = jsonify(actor_data)
|
resp = jsonify(actor_data)
|
||||||
resp.content_type = 'application/activity+json'
|
resp.content_type = 'application/activity+json'
|
||||||
return resp
|
return resp
|
||||||
|
@ -250,7 +248,7 @@ def shared_inbox():
|
||||||
if 'source' in request_json['object']['object'] and \
|
if 'source' in request_json['object']['object'] and \
|
||||||
request_json['object']['object']['source']['mediaType'] == 'text/markdown':
|
request_json['object']['object']['source']['mediaType'] == 'text/markdown':
|
||||||
post.body = request_json['object']['object']['source']['content']
|
post.body = request_json['object']['object']['source']['content']
|
||||||
post.body_html = allowlist_html(markdown2.markdown(post.body, safe_mode=True))
|
post.body_html = markdown_to_html(post.body)
|
||||||
elif 'content' in request_json['object']['object']:
|
elif 'content' in request_json['object']['object']:
|
||||||
post.body_html = allowlist_html(request_json['object']['object']['content'])
|
post.body_html = allowlist_html(request_json['object']['object']['content'])
|
||||||
post.body = html_to_markdown(post.body_html)
|
post.body = html_to_markdown(post.body_html)
|
||||||
|
@ -293,7 +291,7 @@ def shared_inbox():
|
||||||
request_json['object']['object']['source'][
|
request_json['object']['object']['source'][
|
||||||
'mediaType'] == 'text/markdown':
|
'mediaType'] == 'text/markdown':
|
||||||
post_reply.body = request_json['object']['object']['source']['content']
|
post_reply.body = request_json['object']['object']['source']['content']
|
||||||
post_reply.body_html = allowlist_html(markdown2.markdown(post_reply.body, safe_mode=True))
|
post_reply.body_html = markdown_to_html(post_reply.body)
|
||||||
elif 'content' in request_json['object']['object']:
|
elif 'content' in request_json['object']['object']:
|
||||||
post_reply.body_html = allowlist_html(
|
post_reply.body_html = allowlist_html(
|
||||||
request_json['object']['object']['content'])
|
request_json['object']['object']['content'])
|
||||||
|
|
|
@ -2,7 +2,6 @@ import json
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Union, Tuple
|
from typing import Union, Tuple
|
||||||
import markdown2
|
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from app import db, cache
|
from app import db, cache
|
||||||
|
@ -15,7 +14,7 @@ from cryptography.hazmat.primitives.asymmetric import padding
|
||||||
from app.constants import *
|
from app.constants import *
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from app.utils import get_request, allowlist_html
|
from app.utils import get_request, allowlist_html, html_to_markdown
|
||||||
|
|
||||||
|
|
||||||
def public_key():
|
def public_key():
|
||||||
|
@ -301,7 +300,7 @@ def parse_summary(user_json) -> str:
|
||||||
if 'source' in user_json and user_json['source'].get('mediaType') == 'text/markdown':
|
if 'source' in user_json and user_json['source'].get('mediaType') == 'text/markdown':
|
||||||
# Convert Markdown to HTML
|
# Convert Markdown to HTML
|
||||||
markdown_text = user_json['source']['content']
|
markdown_text = user_json['source']['content']
|
||||||
html_content = markdown2.markdown(markdown_text)
|
html_content = html_to_markdown(markdown_text)
|
||||||
return html_content
|
return html_content
|
||||||
elif 'summary' in user_json:
|
elif 'summary' in user_json:
|
||||||
return allowlist_html(user_json['summary'])
|
return allowlist_html(user_json['summary'])
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
import markdown2
|
|
||||||
from flask import redirect, url_for, flash, request, make_response, session, Markup, current_app, abort
|
from flask import redirect, url_for, flash, request, make_response, session, Markup, 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 _
|
||||||
|
@ -9,11 +8,12 @@ from sqlalchemy import or_
|
||||||
from app import db
|
from app import db
|
||||||
from app.activitypub.signature import RsaKeys, HttpSignature
|
from app.activitypub.signature import RsaKeys, HttpSignature
|
||||||
from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePost, NewReplyForm
|
from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePost, NewReplyForm
|
||||||
from app.community.util import search_for_community, community_url_exists, actor_to_community
|
from app.community.util import search_for_community, community_url_exists, actor_to_community, post_replies
|
||||||
from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE
|
from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE
|
||||||
from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post
|
from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, PostReply, \
|
||||||
|
PostReplyVote
|
||||||
from app.community import bp
|
from app.community import bp
|
||||||
from app.utils import get_setting, render_template, allowlist_html
|
from app.utils import get_setting, render_template, allowlist_html, markdown_to_html
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/add_local', methods=['GET', 'POST'])
|
@bp.route('/add_local', methods=['GET', 'POST'])
|
||||||
|
@ -80,7 +80,7 @@ def show_community(community: Community):
|
||||||
mod_list = User.query.filter(User.id.in_(mod_user_ids)).all()
|
mod_list = User.query.filter(User.id.in_(mod_user_ids)).all()
|
||||||
|
|
||||||
if current_user.ignore_bots:
|
if current_user.ignore_bots:
|
||||||
posts = community.posts.query.filter(Post.from_bot == False).all()
|
posts = community.posts.filter(Post.from_bot == False).all()
|
||||||
else:
|
else:
|
||||||
posts = community.posts
|
posts = community.posts
|
||||||
|
|
||||||
|
@ -187,7 +187,7 @@ def add_post(actor):
|
||||||
if form.type.data == '' or form.type.data == 'discussion':
|
if form.type.data == '' or form.type.data == 'discussion':
|
||||||
post.title = form.discussion_title.data
|
post.title = form.discussion_title.data
|
||||||
post.body = form.discussion_body.data
|
post.body = form.discussion_body.data
|
||||||
post.body_html = allowlist_html(markdown2.markdown(post.body, safe_mode=True))
|
post.body_html = markdown_to_html(post.body)
|
||||||
post.type = POST_TYPE_ARTICLE
|
post.type = POST_TYPE_ARTICLE
|
||||||
elif form.type.data == 'link':
|
elif form.type.data == 'link':
|
||||||
post.title = form.link_title.data
|
post.title = form.link_title.data
|
||||||
|
@ -217,11 +217,28 @@ def add_post(actor):
|
||||||
images_disabled=images_disabled)
|
images_disabled=images_disabled)
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/post/<int:post_id>')
|
@bp.route('/post/<int:post_id>', methods=['GET', 'POST'])
|
||||||
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)
|
||||||
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)
|
||||||
form = NewReplyForm()
|
form = NewReplyForm()
|
||||||
|
if form.validate_on_submit():
|
||||||
|
reply = PostReply(user_id=current_user.id, post_id=post.id, community_id=post.community.id, body=form.body.data,
|
||||||
|
body_html=markdown_to_html(form.body.data), body_html_safe=True,
|
||||||
|
from_bot=current_user.bot, up_votes=1, nsfw=post.nsfw, nsfl=post.nsfl)
|
||||||
|
db.session.add(reply)
|
||||||
|
db.session.commit()
|
||||||
|
reply_vote = PostReplyVote(user_id=current_user.id, author_id=current_user.id, post_reply_id=reply.id,
|
||||||
|
effect=1.0)
|
||||||
|
db.session.add(reply_vote)
|
||||||
|
db.session.commit()
|
||||||
|
form.body.data = ''
|
||||||
|
flash('Your comment has been added.')
|
||||||
|
# todo: flush cache
|
||||||
|
# todo: federation
|
||||||
|
replies = post_replies(post.id, 'top', show_first=reply.id)
|
||||||
|
else:
|
||||||
|
replies = post_replies(post.id, 'top')
|
||||||
return render_template('community/post.html', title=post.title, post=post, is_moderator=is_moderator,
|
return render_template('community/post.html', title=post.title, post=post, is_moderator=is_moderator,
|
||||||
canonical=post.ap_id, form=form)
|
canonical=post.ap_id, form=form, replies=replies)
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import List
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
from app.models import Community, File, BannedInstances
|
from app.models import Community, File, BannedInstances, PostReply
|
||||||
from app.utils import get_request
|
from app.utils import get_request
|
||||||
|
from sqlalchemy import desc
|
||||||
|
|
||||||
|
|
||||||
def search_for_community(address: str):
|
def search_for_community(address: str):
|
||||||
|
@ -78,3 +80,24 @@ def actor_to_community(actor) -> Community:
|
||||||
else:
|
else:
|
||||||
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()
|
||||||
return community
|
return community
|
||||||
|
|
||||||
|
|
||||||
|
# replies to a post, in a tree, sorted by a variety of methods
|
||||||
|
def post_replies(post_id: int, sort_by: str, show_first: int = 0) -> List[PostReply]:
|
||||||
|
comments = PostReply.query.filter_by(post_id=post_id)
|
||||||
|
if sort_by == 'hot':
|
||||||
|
comments = comments.order_by(desc(PostReply.ranking))
|
||||||
|
elif sort_by == 'top':
|
||||||
|
comments = comments.order_by(desc(PostReply.score))
|
||||||
|
elif sort_by == 'new':
|
||||||
|
comments = comments.order_by(desc(PostReply.posted_at))
|
||||||
|
|
||||||
|
comments_dict = {comment.id: {'comment': comment, 'replies': []} for comment in comments.all()}
|
||||||
|
|
||||||
|
for comment in comments:
|
||||||
|
if comment.parent_id is not None:
|
||||||
|
parent_comment = comments_dict.get(comment.parent_id)
|
||||||
|
if parent_comment:
|
||||||
|
parent_comment['replies'].append(comments_dict[comment.id])
|
||||||
|
|
||||||
|
return [comment for comment in comments_dict.values() if comment['comment'].parent_id is None]
|
||||||
|
|
|
@ -198,6 +198,12 @@ class User(UserMixin, db.Model):
|
||||||
return self.cover.source_url
|
return self.cover.source_url
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
def link(self) -> str:
|
||||||
|
if self.ap_id is None:
|
||||||
|
return self.user_name
|
||||||
|
else:
|
||||||
|
return self.ap_id
|
||||||
|
|
||||||
def get_reset_password_token(self, expires_in=600):
|
def get_reset_password_token(self, expires_in=600):
|
||||||
return jwt.encode(
|
return jwt.encode(
|
||||||
{'reset_password': self.id, 'exp': time() + expires_in},
|
{'reset_password': self.id, 'exp': time() + expires_in},
|
||||||
|
@ -312,9 +318,11 @@ class PostReply(db.Model):
|
||||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), index=True)
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), index=True)
|
||||||
post_id = db.Column(db.Integer, db.ForeignKey('post.id'), index=True)
|
post_id = db.Column(db.Integer, db.ForeignKey('post.id'), index=True)
|
||||||
community_id = db.Column(db.Integer, db.ForeignKey('community.id'), index=True)
|
community_id = db.Column(db.Integer, db.ForeignKey('community.id'), index=True)
|
||||||
|
domain_id = db.Column(db.Integer, db.ForeignKey('domain.id'), index=True)
|
||||||
image_id = db.Column(db.Integer, db.ForeignKey('file.id'), index=True)
|
image_id = db.Column(db.Integer, db.ForeignKey('file.id'), index=True)
|
||||||
parent_id = db.Column(db.Integer)
|
parent_id = db.Column(db.Integer)
|
||||||
root_id = db.Column(db.Integer)
|
root_id = db.Column(db.Integer)
|
||||||
|
depth = db.Column(db.Integer, default=0)
|
||||||
body = db.Column(db.Text)
|
body = db.Column(db.Text)
|
||||||
body_html = db.Column(db.Text)
|
body_html = db.Column(db.Text)
|
||||||
body_html_safe = db.Column(db.Boolean, default=False)
|
body_html_safe = db.Column(db.Boolean, default=False)
|
||||||
|
@ -327,7 +335,7 @@ class PostReply(db.Model):
|
||||||
from_bot = db.Column(db.Boolean, default=False)
|
from_bot = db.Column(db.Boolean, default=False)
|
||||||
up_votes = db.Column(db.Integer, default=0)
|
up_votes = db.Column(db.Integer, default=0)
|
||||||
down_votes = db.Column(db.Integer, default=0)
|
down_votes = db.Column(db.Integer, default=0)
|
||||||
ranking = db.Column(db.Integer, default=0)
|
ranking = db.Column(db.Integer, default=0, index=True)
|
||||||
language = db.Column(db.String(10))
|
language = db.Column(db.String(10))
|
||||||
edited_at = db.Column(db.DateTime)
|
edited_at = db.Column(db.DateTime)
|
||||||
|
|
||||||
|
|
|
@ -137,6 +137,14 @@
|
||||||
content: "\e9d7";
|
content: "\e9d7";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fe-arrow-up::before {
|
||||||
|
content: "\e914";
|
||||||
|
}
|
||||||
|
|
||||||
|
.fe-arrow-down::before {
|
||||||
|
content: "\e90c";
|
||||||
|
}
|
||||||
|
|
||||||
a.no-underline {
|
a.no-underline {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|
|
@ -136,6 +136,14 @@ nav, etc which are used site-wide */
|
||||||
content: "\e9d7";
|
content: "\e9d7";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fe-arrow-up::before {
|
||||||
|
content: "\e914";
|
||||||
|
}
|
||||||
|
|
||||||
|
.fe-arrow-down::before {
|
||||||
|
content: "\e90c";
|
||||||
|
}
|
||||||
|
|
||||||
a.no-underline {
|
a.no-underline {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
@ -316,6 +324,10 @@ fieldset legend {
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post_reply_form label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.post_list .post_teaser {
|
.post_list .post_teaser {
|
||||||
border-bottom: solid 2px #ddd;
|
border-bottom: solid 2px #ddd;
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
|
@ -329,4 +341,50 @@ fieldset legend {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comment {
|
||||||
|
clear: both;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.comment .comment_author img {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
.comment .hide_button {
|
||||||
|
float: right;
|
||||||
|
display: block;
|
||||||
|
width: 60px;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
.comment .hide_button a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.comment .voting_buttons {
|
||||||
|
float: right;
|
||||||
|
display: block;
|
||||||
|
width: 60px;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
.comment .voting_buttons div {
|
||||||
|
border: solid 1px #0071CE;
|
||||||
|
}
|
||||||
|
.comment .voting_buttons .upvote_button, .comment .voting_buttons .downvote_button {
|
||||||
|
padding-left: 3px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.comment .voting_buttons .upvote_button.digits_4, .comment .voting_buttons .downvote_button.digits_4 {
|
||||||
|
width: 68px;
|
||||||
|
}
|
||||||
|
.comment .voting_buttons .upvote_button.digits_5, .comment .voting_buttons .downvote_button.digits_5 {
|
||||||
|
width: 76px;
|
||||||
|
}
|
||||||
|
.comment .voting_buttons .upvote_button.digits_6, .comment .voting_buttons .downvote_button.digits_6 {
|
||||||
|
width: 84px;
|
||||||
|
}
|
||||||
|
.comment .voting_buttons .downvote_button {
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
.comment .voting_buttons a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
/*# sourceMappingURL=structure.css.map */
|
/*# sourceMappingURL=structure.css.map */
|
||||||
|
|
|
@ -111,6 +111,11 @@ nav, etc which are used site-wide */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post_reply_form {
|
||||||
|
label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
.post_list {
|
.post_list {
|
||||||
.post_teaser {
|
.post_teaser {
|
||||||
|
|
||||||
|
@ -130,3 +135,62 @@ nav, etc which are used site-wide */
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comment {
|
||||||
|
clear: both;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
.comment_author {
|
||||||
|
img {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hide_button {
|
||||||
|
float: right;
|
||||||
|
display: block;
|
||||||
|
width: 60px;
|
||||||
|
padding: 5px;
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.voting_buttons {
|
||||||
|
float: right;
|
||||||
|
display: block;
|
||||||
|
width: 60px;
|
||||||
|
padding: 5px;
|
||||||
|
|
||||||
|
div {
|
||||||
|
border: solid 1px $primary-colour;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upvote_button, .downvote_button {
|
||||||
|
padding-left: 3px;
|
||||||
|
border-radius: 3px;
|
||||||
|
|
||||||
|
&.digits_4 {
|
||||||
|
width: 68px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.digits_5 {
|
||||||
|
width: 76px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.digits_6 {
|
||||||
|
width: 84px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.downvote_button {
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -135,6 +135,14 @@
|
||||||
content: "\e9d7";
|
content: "\e9d7";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fe-arrow-up::before {
|
||||||
|
content: "\e914";
|
||||||
|
}
|
||||||
|
|
||||||
|
.fe-arrow-down::before {
|
||||||
|
content: "\e90c";
|
||||||
|
}
|
||||||
|
|
||||||
a.no-underline {
|
a.no-underline {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
|
@ -79,7 +79,42 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="row post_replies">
|
<div class="row post_replies">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
|
{% macro render_comment(comment) %}
|
||||||
|
<div class="comment" style="margin-left: {{ comment['comment'].depth * 20 }}px;">
|
||||||
|
<div class="voting_buttons">
|
||||||
|
<div class="upvote_button digits_{{ digits(comment['comment'].up_votes) }}"><a href="#"><span class="fe fe-arrow-up"></span>
|
||||||
|
{{ comment['comment'].up_votes }}</a>
|
||||||
|
</div>
|
||||||
|
<div class="downvote_button digits_{{ digits(comment['comment'].down_votes) }}"><a href="#"><span class="fe fe-arrow-down"></span>
|
||||||
|
{{ comment['comment'].down_votes }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hide_button"><a href="#">[-] hide</a></div>
|
||||||
|
<div class="comment_author">
|
||||||
|
{% if comment['comment'].author.avatar_id %}
|
||||||
|
<a href="/u/{{ comment['comment'].author.link() }}" title="{{ comment['comment'].author.ap_id }}">
|
||||||
|
<img src="{{ comment['comment'].author.avatar_image() }}" alt="Avatar" /></a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="/u/{{ comment['comment'].author.link() }}" title="{{ comment['comment'].author.link() }}">
|
||||||
|
<strong>{{ comment['comment'].author.user_name}}</strong></a>
|
||||||
|
<span class="text-muted small">{{ moment(comment['comment'].posted_at).fromNow(refresh=True) }}</span>
|
||||||
|
</div>
|
||||||
|
{{ comment['comment'].body_html | safe }}
|
||||||
|
</div>
|
||||||
|
{% if comment['replies'] %}
|
||||||
|
<div class="replies">
|
||||||
|
{% for reply in comment['replies'] %}
|
||||||
|
{{ render_comment(reply) | safe }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
<div class="comments">
|
||||||
|
{% for reply in replies %}
|
||||||
|
{{ render_comment(reply) | safe }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import markdown2
|
|
||||||
|
|
||||||
from flask import redirect, url_for, flash, request, make_response, session, Markup, current_app, abort
|
from flask import redirect, url_for, flash, request, make_response, session, Markup, 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 _
|
||||||
|
@ -8,7 +6,7 @@ from app import db
|
||||||
from app.models import Post, Community, CommunityMember, User, PostReply
|
from app.models import Post, Community, CommunityMember, User, PostReply
|
||||||
from app.user import bp
|
from app.user import bp
|
||||||
from app.user.forms import ProfileForm, SettingsForm
|
from app.user.forms import ProfileForm, SettingsForm
|
||||||
from app.utils import get_setting, render_template, allowlist_html
|
from app.utils import get_setting, render_template, markdown_to_html
|
||||||
from sqlalchemy import desc, or_
|
from sqlalchemy import desc, or_
|
||||||
|
|
||||||
|
|
||||||
|
@ -19,7 +17,7 @@ def show_profile(user):
|
||||||
moderates = moderates.filter(Community.private_mods == False)
|
moderates = moderates.filter(Community.private_mods == False)
|
||||||
post_replies = PostReply.query.filter_by(user_id=user.id).order_by(desc(PostReply.posted_at)).all()
|
post_replies = PostReply.query.filter_by(user_id=user.id).order_by(desc(PostReply.posted_at)).all()
|
||||||
canonical = user.ap_public_url if user.ap_public_url else None
|
canonical = user.ap_public_url if user.ap_public_url else None
|
||||||
user.about_html = allowlist_html(markdown2.markdown(user.about, safe_mode=True))
|
user.about_html = markdown_to_html(user.about)
|
||||||
return render_template('user/show_profile.html', user=user, posts=posts, post_replies=post_replies,
|
return render_template('user/show_profile.html', user=user, posts=posts, post_replies=post_replies,
|
||||||
moderates=moderates.all(), canonical=canonical, title=_('Posts by %(user_name)s',
|
moderates=moderates.all(), canonical=canonical, title=_('Posts by %(user_name)s',
|
||||||
user_name=user.user_name))
|
user_name=user.user_name))
|
||||||
|
|
20
app/utils.py
20
app/utils.py
|
@ -1,9 +1,10 @@
|
||||||
import random
|
import random
|
||||||
|
import markdown2
|
||||||
|
import math
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import flask
|
import flask
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
import html as html_module
|
|
||||||
import requests
|
import requests
|
||||||
import os
|
import os
|
||||||
from flask import current_app, json
|
from flask import current_app, json
|
||||||
|
@ -84,7 +85,8 @@ def is_image_url(url):
|
||||||
|
|
||||||
# sanitise HTML using an allow list
|
# sanitise HTML using an allow list
|
||||||
def allowlist_html(html: str) -> str:
|
def allowlist_html(html: str) -> str:
|
||||||
allowed_tags = ['p', 'strong', 'a', 'ul', 'ol', 'li', 'em', 'blockquote', 'cite', 'br', 'h3', 'h4', 'h5']
|
allowed_tags = ['p', 'strong', 'a', 'ul', 'ol', 'li', 'em', 'blockquote', 'cite', 'br', 'h3', 'h4', 'h5', 'pre',
|
||||||
|
'code']
|
||||||
# Parse the HTML using BeautifulSoup
|
# Parse the HTML using BeautifulSoup
|
||||||
soup = BeautifulSoup(html, 'html.parser')
|
soup = BeautifulSoup(html, 'html.parser')
|
||||||
|
|
||||||
|
@ -100,7 +102,7 @@ def allowlist_html(html: str) -> str:
|
||||||
del tag[attr]
|
del tag[attr]
|
||||||
|
|
||||||
# Encode the HTML to prevent script execution
|
# Encode the HTML to prevent script execution
|
||||||
return html_module.escape(str(soup))
|
return str(soup)
|
||||||
|
|
||||||
|
|
||||||
# convert basic HTML to Markdown
|
# convert basic HTML to Markdown
|
||||||
|
@ -138,6 +140,10 @@ def html_to_markdown_worker(element, indent_level=0):
|
||||||
return formatted_text
|
return formatted_text
|
||||||
|
|
||||||
|
|
||||||
|
def markdown_to_html(markdown_text) -> str:
|
||||||
|
return allowlist_html(markdown2.markdown(markdown_text, safe_mode=True))
|
||||||
|
|
||||||
|
|
||||||
def domain_from_url(url: str) -> Domain:
|
def domain_from_url(url: str) -> Domain:
|
||||||
parsed_url = urlparse(url)
|
parsed_url = urlparse(url)
|
||||||
domain = Domain.query.filter_by(name=parsed_url.hostname.lower()).first()
|
domain = Domain.query.filter_by(name=parsed_url.hostname.lower()).first()
|
||||||
|
@ -153,3 +159,11 @@ def shorten_string(input_str, max_length=50):
|
||||||
|
|
||||||
def shorten_url(input: str, max_length=20):
|
def shorten_url(input: str, max_length=20):
|
||||||
return shorten_string(input.replace('https://', '').replace('http://', ''))
|
return shorten_string(input.replace('https://', '').replace('http://', ''))
|
||||||
|
|
||||||
|
|
||||||
|
# the number of digits in a number. e.g. 1000 would be 4
|
||||||
|
def digits(input: int) -> int:
|
||||||
|
if input == 0:
|
||||||
|
return 1 # Special case: 0 has 1 digit
|
||||||
|
else:
|
||||||
|
return math.floor(math.log10(abs(input))) + 1
|
||||||
|
|
40
migrations/versions/e82f86c550ac_reply_depth.py
Normal file
40
migrations/versions/e82f86c550ac_reply_depth.py
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
"""reply depth
|
||||||
|
|
||||||
|
Revision ID: e82f86c550ac
|
||||||
|
Revises: 8c5cc19e0670
|
||||||
|
Create Date: 2023-10-10 20:51:09.662080
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'e82f86c550ac'
|
||||||
|
down_revision = '8c5cc19e0670'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('post_reply', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('domain_id', sa.Integer(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('depth', sa.Integer(), nullable=True))
|
||||||
|
batch_op.create_index(batch_op.f('ix_post_reply_domain_id'), ['domain_id'], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f('ix_post_reply_ranking'), ['ranking'], unique=False)
|
||||||
|
batch_op.create_foreign_key(None, 'domain', ['domain_id'], ['id'])
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('post_reply', schema=None) as batch_op:
|
||||||
|
batch_op.drop_constraint(None, type_='foreignkey')
|
||||||
|
batch_op.drop_index(batch_op.f('ix_post_reply_ranking'))
|
||||||
|
batch_op.drop_index(batch_op.f('ix_post_reply_domain_id'))
|
||||||
|
batch_op.drop_column('depth')
|
||||||
|
batch_op.drop_column('domain_id')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
|
@ -6,7 +6,7 @@ from app import create_app, db, cli
|
||||||
import os, click
|
import os, click
|
||||||
from flask import session, g
|
from flask import session, g
|
||||||
from app.constants import POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_ARTICLE
|
from app.constants import POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_ARTICLE
|
||||||
from app.utils import getmtime, gibberish, shorten_string, shorten_url
|
from app.utils import getmtime, gibberish, shorten_string, shorten_url, digits
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
cli.register(app)
|
cli.register(app)
|
||||||
|
@ -27,6 +27,7 @@ def make_shell_context():
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
app.jinja_env.globals['getmtime'] = getmtime
|
app.jinja_env.globals['getmtime'] = getmtime
|
||||||
app.jinja_env.globals['len'] = len
|
app.jinja_env.globals['len'] = len
|
||||||
|
app.jinja_env.globals['digits'] = digits
|
||||||
app.jinja_env.globals['str'] = str
|
app.jinja_env.globals['str'] = str
|
||||||
app.jinja_env.filters['shorten'] = shorten_string
|
app.jinja_env.filters['shorten'] = shorten_string
|
||||||
app.jinja_env.filters['shorten_url'] = shorten_url
|
app.jinja_env.filters['shorten_url'] = shorten_url
|
||||||
|
|
Loading…
Add table
Reference in a new issue