This commit is contained in:
saint 2024-02-28 16:43:14 +01:00
commit 7cff8ceeac
26 changed files with 489 additions and 73 deletions

View file

@ -1055,6 +1055,7 @@ def comment_ap(comment_id):
}
resp = jsonify(reply_data)
resp.content_type = 'application/activity+json'
resp.headers.set('Vary', 'Accept, Accept-Encoding, Cookie')
return resp
else:
reply = PostReply.query.get_or_404(comment_id)
@ -1075,6 +1076,7 @@ def post_ap(post_id):
post_data['@context'] = default_context()
resp = jsonify(post_data)
resp.content_type = 'application/activity+json'
resp.headers.set('Vary', 'Accept, Accept-Encoding, Cookie')
return resp
else:
return show_post(post_id)

View file

@ -1,10 +1,13 @@
from flask_wtf import FlaskForm
from flask_wtf.file import FileRequired, FileAllowed
from sqlalchemy import func
from wtforms import StringField, PasswordField, SubmitField, HiddenField, BooleanField, TextAreaField, SelectField, \
FileField, IntegerField
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length, Optional
from flask_babel import _, lazy_gettext as _l
from app.models import Community, User
class SiteProfileForm(FlaskForm):
name = StringField(_l('Name'))
@ -96,6 +99,70 @@ class EditTopicForm(FlaskForm):
submit = SubmitField(_l('Save'))
class AddUserForm(FlaskForm):
user_name = StringField(_l('User name'), validators=[DataRequired()],
render_kw={'autofocus': True, 'autocomplete': 'off'})
email = StringField(_l('Email address'), validators=[Optional(), Length(max=255)])
password = PasswordField(_l('Password'), validators=[DataRequired(), Length(min=8, max=50)],
render_kw={'autocomplete': 'new-password'})
password2 = PasswordField(_l('Repeat password'), validators=[DataRequired(), EqualTo('password')])
about = TextAreaField(_l('Bio'), validators=[Optional(), Length(min=3, max=5000)])
matrix_user_id = StringField(_l('Matrix User ID'), validators=[Optional(), Length(max=255)])
profile_file = FileField(_l('Avatar image'))
banner_file = FileField(_l('Top banner image'))
bot = BooleanField(_l('This profile is a bot'))
verified = BooleanField(_l('Email address is verified'))
banned = BooleanField(_l('Banned'))
newsletter = BooleanField(_l('Subscribe to email newsletter'))
ignore_bots = BooleanField(_l('Hide posts by bots'))
nsfw = BooleanField(_l('Show NSFW posts'))
nsfl = BooleanField(_l('Show NSFL posts'))
role_options = [(2, _l('User')),
(3, _l('Staff')),
(4, _l('Admin')),
]
role = SelectField(_l('Role'), choices=role_options, default=2, coerce=int)
submit = SubmitField(_l('Save'))
def validate_email(self, email):
user = User.query.filter(func.lower(User.email) == func.lower(email.data.strip())).first()
if user is not None:
raise ValidationError(_l('An account with this email address already exists.'))
def validate_user_name(self, user_name):
if '@' in user_name.data:
raise ValidationError(_l('User names cannot contain @.'))
user = User.query.filter(func.lower(User.user_name) == func.lower(user_name.data.strip())).filter_by(ap_id=None).first()
if user is not None:
if user.deleted:
raise ValidationError(_l('This username was used in the past and cannot be reused.'))
else:
raise ValidationError(_l('An account with this user name already exists.'))
community = Community.query.filter(func.lower(Community.name) == func.lower(user_name.data.strip())).first()
if community is not None:
raise ValidationError(_l('A community with this name exists so it cannot be used for a user.'))
def validate_password(self, password):
if not password.data:
return
password.data = password.data.strip()
if password.data == 'password' or password.data == '12345678' or password.data == '1234567890':
raise ValidationError(_l('This password is too common.'))
first_char = password.data[0] # the first character in the string
all_the_same = True
# Compare all characters to the first character
for char in password.data:
if char != first_char:
all_the_same = False
if all_the_same:
raise ValidationError(_l('This password is not secure.'))
if password.data == 'password' or password.data == '12345678' or password.data == '1234567890':
raise ValidationError(_l('This password is too common.'))
class EditUserForm(FlaskForm):
about = TextAreaField(_l('Bio'), validators=[Optional(), Length(min=3, max=5000)])
email = StringField(_l('Email address'), validators=[Optional(), Length(max=255)])
@ -112,6 +179,11 @@ class EditUserForm(FlaskForm):
searchable = BooleanField(_l('Show profile in user list'))
indexable = BooleanField(_l('Allow search engines to index this profile'))
manually_approves_followers = BooleanField(_l('Manually approve followers'))
role_options = [(2, _l('User')),
(3, _l('Staff')),
(4, _l('Admin')),
]
role = SelectField(_l('Role'), choices=role_options, default=2, coerce=int)
submit = SubmitField(_l('Save'))

View file

@ -11,11 +11,11 @@ from app.activitypub.routes import process_inbox_request, process_delete_request
from app.activitypub.signature import post_request
from app.activitypub.util import default_context
from app.admin.forms import FederationForm, SiteMiscForm, SiteProfileForm, EditCommunityForm, EditUserForm, \
EditTopicForm, SendNewsletterForm
EditTopicForm, SendNewsletterForm, AddUserForm
from app.admin.util import unsubscribe_from_everything_then_delete, unsubscribe_from_community, send_newsletter
from app.community.util import save_icon_file, save_banner_file
from app.models import AllowedInstances, BannedInstances, ActivityPubLog, utcnow, Site, Community, CommunityMember, \
User, Instance, File, Report, Topic, UserRegistration
User, Instance, File, Report, Topic, UserRegistration, Role
from app.utils import render_template, permission_required, set_setting, get_setting, gibberish, markdown_to_html, \
moderating_communities, joined_communities, finalize_user_setup, theme_list
from app.admin import bp
@ -553,6 +553,15 @@ def admin_user_edit(user_id):
user.searchable = form.searchable.data
user.indexable = form.indexable.data
user.ap_manually_approves_followers = form.manually_approves_followers.data
# Update user roles. The UI only lets the user choose 1 role but the DB structure allows for multiple roles per user.
for role in user.roles:
if role.id != form.role.data:
user.roles.remove(role)
user.roles.append(Role.query.get(form.role.data))
if form.role.data == 4:
flash(_("Permissions are cached for 50 seconds so new admin roles won't take effect immediately."))
db.session.commit()
user.flush_cache()
flash(_('Saved'))
@ -573,6 +582,8 @@ def admin_user_edit(user_id):
form.searchable.data = user.searchable
form.indexable.data = user.indexable
form.manually_approves_followers.data = user.ap_manually_approves_followers
if user.roles:
form.role.data = user.roles[0].id
return render_template('admin/edit_user.html', title=_('Edit user'), form=form, user=user,
moderating_communities=moderating_communities(current_user.get_id()),
@ -581,6 +592,74 @@ def admin_user_edit(user_id):
)
@bp.route('/users/add', methods=['GET', 'POST'])
@login_required
@permission_required('administer all users')
def admin_users_add():
form = AddUserForm()
user = User()
if form.validate_on_submit():
user.user_name = form.user_name.data
user.set_password(form.password.data)
user.about = form.about.data
user.email = form.email.data
user.about_html = markdown_to_html(form.about.data)
user.matrix_user_id = form.matrix_user_id.data
user.bot = form.bot.data
profile_file = request.files['profile_file']
if profile_file and profile_file.filename != '':
# remove old avatar
if user.avatar_id:
file = File.query.get(user.avatar_id)
file.delete_from_disk()
user.avatar_id = None
db.session.delete(file)
# add new avatar
file = save_icon_file(profile_file, 'users')
if file:
user.avatar = file
banner_file = request.files['banner_file']
if banner_file and banner_file.filename != '':
# remove old cover
if user.cover_id:
file = File.query.get(user.cover_id)
file.delete_from_disk()
user.cover_id = None
db.session.delete(file)
# add new cover
file = save_banner_file(banner_file, 'users')
if file:
user.cover = file
user.newsletter = form.newsletter.data
user.ignore_bots = form.ignore_bots.data
user.show_nsfw = form.nsfw.data
user.show_nsfl = form.nsfl.data
from app.activitypub.signature import RsaKeys
user.verified = True
user.last_seen = utcnow()
private_key, public_key = RsaKeys.generate_keypair()
user.private_key = private_key
user.public_key = public_key
user.ap_profile_id = f"https://{current_app.config['SERVER_NAME']}/u/{user.user_name}"
user.ap_public_url = f"https://{current_app.config['SERVER_NAME']}/u/{user.user_name}"
user.ap_inbox_url = f"https://{current_app.config['SERVER_NAME']}/u/{user.user_name}/inbox"
user.roles.append(Role.query.get(form.role.data))
db.session.add(user)
db.session.commit()
flash(_('User added'))
return redirect(url_for('admin.admin_users', local_remote='local'))
return render_template('admin/add_user.html', title=_('Add user'), form=form, user=user,
moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()),
site=g.site
)
@bp.route('/user/<int:user_id>/delete', methods=['GET'])
@login_required
@permission_required('administer all users')

View file

@ -59,6 +59,15 @@ def register(app):
print(private_key)
print(public_key)
@app.cli.command("admin-keys")
def keys():
private_key, public_key = RsaKeys.generate_keypair()
u: User = User.query.get(1)
u.private_key = private_key
u.public_key = public_key
db.session.commit()
print('Admin keys have been reset')
@app.cli.command("init-db")
def init_db():
with app.app_context():

View file

@ -22,7 +22,7 @@ from app.community import bp
from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \
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
joined_communities, moderating_communities, blocked_domains, mimetype_from_url
from feedgen.feed import FeedGenerator
from datetime import timezone, timedelta
@ -190,7 +190,7 @@ def show_community(community: Community):
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,
rss_feed=f"https://{current_app.config['SERVER_NAME']}/community/{community.link()}/feed", rss_feed_name=f"{community.title} posts on PieFed",
rss_feed=f"https://{current_app.config['SERVER_NAME']}/community/{community.link()}/feed", rss_feed_name=f"{community.title} on PieFed",
content_filters=content_filters, moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()), sort=sort,
inoculation=inoculation[randint(0, len(inoculation) - 1)], post_layout=post_layout, current_app=current_app)
@ -216,7 +216,7 @@ def show_community_rss(actor):
og_image = community.image.source_url if community.image_id else None
fg = FeedGenerator()
fg.id(f"https://{current_app.config['SERVER_NAME']}/c/{actor}")
fg.title(community.title)
fg.title(f'{community.title} on {g.site.name}')
fg.link(href=f"https://{current_app.config['SERVER_NAME']}/c/{actor}", rel='alternate')
if og_image:
fg.logo(og_image)
@ -233,6 +233,10 @@ def show_community_rss(actor):
fe = fg.add_entry()
fe.title(post.title)
fe.link(href=f"https://{current_app.config['SERVER_NAME']}/post/{post.id}")
if post.url:
type = mimetype_from_url(post.url)
if type and not type.startswith('text/'):
fe.enclosure(post.url, type=type)
fe.description(post.body_html)
fe.guid(post.profile_id(), permalink=True)
fe.author(name=post.author.user_name)

View file

@ -172,7 +172,7 @@ def show_post(post_id: int):
og_image = post.image.source_url if post.image_id else None
description = shorten_string(markdown_to_text(post.body), 150) if post.body else None
return render_template('post/post.html', title=post.title, post=post, is_moderator=is_moderator, community=post.community,
response = render_template('post/post.html', title=post.title, post=post, is_moderator=is_moderator, community=post.community,
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,
@ -182,6 +182,8 @@ def show_post(post_id: int):
joined_communities=joined_communities(current_user.get_id()),
inoculation=inoculation[randint(0, len(inoculation) - 1)]
)
response.headers.set('Vary', 'Accept, Accept-Encoding, Cookie')
return response
@bp.route('/post/<int:post_id>/<vote_direction>', methods=['GET', 'POST'])
@ -367,11 +369,13 @@ def continue_discussion(post_id, comment_id):
is_moderator = current_user.is_authenticated and any(mod.user_id == current_user.id for mod in mods)
replies = get_comment_branch(post.id, comment.id, 'top')
return render_template('post/continue_discussion.html', title=_('Discussing %(title)s', title=post.title), post=post,
response = render_template('post/continue_discussion.html', title=_('Discussing %(title)s', title=post.title), post=post,
is_moderator=is_moderator, comment=comment, replies=replies, markdown_editor=current_user.is_authenticated and current_user.markdown_editor,
moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()), community=post.community,
inoculation=inoculation[randint(0, len(inoculation) - 1)])
response.headers.set('Vary', 'Accept, Accept-Encoding, Cookie')
return response
@bp.route('/post/<int:post_id>/comment/<int:comment_id>/reply', methods=['GET', 'POST'])

View file

@ -11,6 +11,7 @@ document.addEventListener("DOMContentLoaded", function () {
setupKeyboardShortcuts();
setupTopicChooser();
setupConversationChooser();
setupMarkdownEditorEnabler();
});
@ -550,3 +551,35 @@ function displayTimeTracked() {
timeSpentElement.textContent = formatTime(timeSpent)
}
}
function setupMarkdownEditorEnabler() {
const markdownEnablerLinks = document.querySelectorAll('.markdown_editor_enabler');
markdownEnablerLinks.forEach(function(link) {
link.addEventListener('click', function(event) {
event.preventDefault();
const dataId = link.dataset.id;
if(dataId) {
var downarea = new DownArea({
elem: document.querySelector('#' + dataId),
resize: DownArea.RESIZE_VERTICAL,
hide: ['heading', 'bold-italic'],
});
setupAutoResize(dataId);
link.style.display = 'none';
}
});
});
}
/* register a service worker */
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/static/service_worker.js', {scope: '/static/'}).then(function(registration) {
// Registration was successful
// console.log('ServiceWorker2 registration successful with scope: ', registration.scope);
}, function(err) {
// registration failed :(
console.log('ServiceWorker registration failed: ', err);
});
});
}

View file

@ -15,7 +15,7 @@
],
"theme_color": "#007BBF",
"background_color": "#ffffff",
"display": "browser",
"display": "minimal-ui",
"start_url": "/",
"scope": "/"
}

View file

@ -0,0 +1,31 @@
//https://developers.google.com/web/fundamentals/primers/service-workers
// Font files
var fontFiles = [
'/static/fonts/feather/feather.ttf',
];
//this is just an empty service worker so that the 'Install CB as an app' prompt appears in web browsers
self.addEventListener('install', function(event) {
event.waitUntil(caches.open('core').then(function (cache) {
fontFiles.forEach(function (file) {
cache.add(new Request(file));
});
return;
}));
});
self.addEventListener('fetch', function(event) {
// Fonts
// Offline-first
if (request.url.includes('feather.ttf')) {
event.respondWith(
caches.match(request).then(function (response) {
return response || fetch(request).then(function (response) {
// Return the requested file
return response;
});
})
);
}
});

View file

@ -1255,4 +1255,22 @@ fieldset legend {
right: 0;
}
#post_reply_markdown_editor_enabler {
display: none;
position: absolute;
bottom: 3px;
right: 0;
}
@media (min-width: 768px) {
#post_reply_markdown_editor_enabler {
display: inline-block;
}
}
.create_post_markdown_editor_enabler {
text-align: right;
margin-bottom: 10px;
display: block;
}
/*# sourceMappingURL=structure.css.map */

View file

@ -935,3 +935,19 @@ fieldset {
right: 0;
}
}
#post_reply_markdown_editor_enabler {
display: none;
position: absolute;
bottom: 3px;
right: 0;
@include breakpoint(phablet) {
display: inline-block;
}
}
.create_post_markdown_editor_enabler {
text-align: right;
margin-bottom: 10px;
display: block;
}

View file

@ -1,5 +1,6 @@
<nav class="mb-4">
<a href="{{ url_for('admin.admin_home') }}">{{ _('Home') }}</a> |
<h2 class="visually-hidden">{{ _('Admin navigation') }}</h2>
<a href="{{ url_for('admin.admin_home') }}">{{ _('Admin home') }}</a> |
<a href="{{ url_for('admin.admin_site') }}">{{ _('Site profile') }}</a> |
<a href="{{ url_for('admin.admin_misc') }}">{{ _('Misc settings') }}</a> |
<a href="{{ url_for('admin.admin_communities') }}">{{ _('Communities') }}</a> |

View file

@ -0,0 +1,47 @@
{% if theme() and file_exists('app/templates/themes/' + theme() + '/base.html') %}
{% extends 'themes/' + theme() + '/base.html' %}
{% else %}
{% extends "base.html" %}
{% endif %} %}
{% from 'bootstrap/form.html' import render_field %}
{% block app_content %}
<div class="row">
<div class="col">
{% include 'admin/_nav.html' %}
</div>
</div>
<div class="row">
<div class="col col-login mx-auto">
<h3>{{ _('Add new user') }}</h3>
<form method="post" enctype="multipart/form-data" id="add_local_user_form">
{{ form.csrf_token() }}
{{ render_field(form.user_name) }}
{{ render_field(form.email) }}
{{ render_field(form.password) }}
{{ render_field(form.password2) }}
{{ render_field(form.about) }}
{{ render_field(form.matrix_user_id) }}
{% if user.avatar_id %}
<img class="user_icon_big rounded-circle" src="{{ user.avatar_image() }}" width="120" height="120" />
{% endif %}
{{ render_field(form.profile_file) }}
<small class="field_hint">Provide a square image that looks good when small.</small>
{% if user.cover_id %}
<a href="{{ user.cover_image() }}"><img class="user_icon_big" src="{{ user.cover_image() }}" style="width: 300px; height: auto;" /></a>
{% endif %}
{{ render_field(form.banner_file) }}
<small class="field_hint">Provide a wide image - letterbox orientation.</small>
{{ render_field(form.bot) }}
{{ render_field(form.verified) }}
{{ render_field(form.banned) }}
{{ render_field(form.newsletter) }}
{{ render_field(form.nsfw) }}
{{ render_field(form.nsfl) }}
{{ render_field(form.role) }}
{{ render_field(form.submit) }}
</form>
</div>
</div>
{% endblock %}

View file

@ -39,6 +39,7 @@
{{ render_field(form.searchable) }}
{{ render_field(form.indexable) }}
{{ render_field(form.manually_approves_followers) }}
{{ render_field(form.role) }}
{{ render_field(form.submit) }}
</form>
<p class="mt-4">

View file

@ -14,13 +14,14 @@
<div class="row">
<div class="col">
<a class="btn btn-primary" href="{{ url_for('admin.admin_users_add') }}" style="float: right;">{{ _('Add local user') }}</a>
<form method="get">
<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="submit" name="submit" value="Search" class="btn btn-primary">
</form>
<table class="table table-striped">
<table class="table table-striped mt-1">
<tr>
<th>Name</th>
<th>Local/Remote</th>

View file

@ -41,7 +41,7 @@
<link href="{{ url_for('static', filename='structure.css', changed=getmtime('structure.css')) }}" type="text/css" rel="stylesheet" />
<link href="{{ url_for('static', filename='styles.css', changed=getmtime('styles.css')) }}" type="text/css" rel="stylesheet" />
<link href="{{ url_for('static', filename='themes/high_contrast/styles.css') }}" type="text/css" rel="alternate stylesheet" title="High contrast" />
{% if markdown_editor %}
{% if not low_bandwidth %}
<link href="{{ url_for('static', filename='js/markdown/downarea.css') }}" type="text/css" rel="stylesheet" />
{% endif %}
{% if theme() and file_exists('app/templates/themes/' + theme() + '/styles.css') %}
@ -52,7 +52,7 @@
<link rel="apple-touch-icon" sizes="152x152" href="/static/images/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/static/images/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/images/favicon-16x16.png">
<link rel="manifest" href="/static/site.webmanifest">
<link rel="manifest" href="/static/manifest.json">
<link rel="shortcut icon" type="image/png" href="/static/images/favicon-32x32.png">
<link href='/static/images/favicon.ico' rel='icon' type='image/x-icon'>
<meta name="msapplication-TileColor" content="#da532c">
@ -235,7 +235,7 @@
{% endif %}
<script type="text/javascript" src="{{ url_for('static', filename='js/htmx.min.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/scripts.js', changed=getmtime('js/scripts.js')) }}"></script>
{% if markdown_editor and not low_bandwidth %}
{% if not low_bandwidth %}
<script type="text/javascript" src="{{ url_for('static', filename='js/markdown/downarea.js') }}"></script>
{% endif %}
{% if theme() and file_exists('app/templates/themes/' + theme() + '/scripts.js') %}

View file

@ -28,34 +28,42 @@
<div class="tab-pane fade show active" id="discussion-tab-pane" role="tabpanel" aria-labelledby="home-tab" tabindex="0">
{{ render_field(form.discussion_title) }}
{{ render_field(form.discussion_body) }}
{% if markdown_editor %}
<script nonce="{{ session['nonce'] }}">
window.addEventListener("load", function () {
var downarea = new DownArea({
elem: document.querySelector('#discussion_body'),
resize: DownArea.RESIZE_VERTICAL,
hide: ['heading', 'bold-italic'],
});
setupAutoResize('discussion_body');
});
</script>
{% if not low_bandwidth %}
{% if markdown_editor %}
<script nonce="{{ session['nonce'] }}">
window.addEventListener("load", function () {
var downarea = new DownArea({
elem: document.querySelector('#discussion_body'),
resize: DownArea.RESIZE_VERTICAL,
hide: ['heading', 'bold-italic'],
});
setupAutoResize('discussion_body');
});
</script>
{% else %}
<a href="#" aria-hidden="true" class="markdown_editor_enabler create_post_markdown_editor_enabler" data-id="discussion_body">{{ _('Enable markdown editor') }}</a>
{% endif %}
{% endif %}
</div>
<div class="tab-pane fade" id="link-tab-pane" role="tabpanel" aria-labelledby="profile-tab" tabindex="0">
{{ render_field(form.link_title) }}
{{ render_field(form.link_url) }}
{{ render_field(form.link_body) }}
{% if markdown_editor %}
<script nonce="{{ session['nonce'] }}">
window.addEventListener("load", function () {
var downarea = new DownArea({
elem: document.querySelector('#link_body'),
resize: DownArea.RESIZE_VERTICAL,
hide: ['heading', 'bold-italic'],
});
setupAutoResize('link_body');
});
</script>
{% if not low_bandwidth %}
{% if markdown_editor %}
<script nonce="{{ session['nonce'] }}">
window.addEventListener("load", function () {
var downarea = new DownArea({
elem: document.querySelector('#link_body'),
resize: DownArea.RESIZE_VERTICAL,
hide: ['heading', 'bold-italic'],
});
setupAutoResize('link_body');
});
</script>
{% else %}
<a href="#" aria-hidden="true" class="markdown_editor_enabler create_post_markdown_editor_enabler" data-id="link_body">{{ _('Enable markdown editor') }}</a>
{% endif %}
{% endif %}
</div>
<div class="tab-pane fade" id="image-tab-pane" role="tabpanel" aria-labelledby="contact-tab" tabindex="0">
@ -64,17 +72,21 @@
{{ render_field(form.image_alt_text) }}
<small class="field_hint">{{ _('Describe the image, to help visually impaired people.') }}</small>
{{ render_field(form.image_body) }}
{% if markdown_editor %}
<script nonce="{{ session['nonce'] }}">
window.addEventListener("load", function () {
var downarea = new DownArea({
elem: document.querySelector('#image_body'),
resize: DownArea.RESIZE_VERTICAL,
hide: ['heading', 'bold-italic'],
});
setupAutoResize('image_body');
});
</script>
{% if not low_bandwidth %}
{% if markdown_editor %}
<script nonce="{{ session['nonce'] }}">
window.addEventListener("load", function () {
var downarea = new DownArea({
elem: document.querySelector('#image_body'),
resize: DownArea.RESIZE_VERTICAL,
hide: ['heading', 'bold-italic'],
});
setupAutoResize('image_body');
});
</script>
{% else %}
<a href="#" aria-hidden="true" class="markdown_editor_enabler create_post_markdown_editor_enabler" data-id="image_body">{{ _('Enable markdown editor') }}</a>
{% endif %}
{% endif %}
</div>
<div class="tab-pane fade" id="poll-tab-pane" role="tabpanel" aria-labelledby="disabled-tab" tabindex="0">

View file

@ -21,17 +21,21 @@
<p>{{ _('This post is hosted on beehaw.org which has <a href="https://docs.beehaw.org/docs/core-principles/what-is-beehaw/" target="_blank" rel="nofollow">higher standards of behaviour than most places. Be nice</a>.') }}</p>
{% endif %}
{{ render_form(form) }}
{% if markdown_editor %}
<script nonce="{{ session['nonce'] }}">
window.addEventListener("load", function () {
var downarea = new DownArea({
elem: document.querySelector('#body'),
resize: DownArea.RESIZE_VERTICAL,
hide: ['heading', 'bold-italic'],
{% if not low_bandwidth %}
{% if markdown_editor %}
<script nonce="{{ session['nonce'] }}">
window.addEventListener("load", function () {
var downarea = new DownArea({
elem: document.querySelector('#body'),
resize: DownArea.RESIZE_VERTICAL,
hide: ['heading', 'bold-italic'],
});
setupAutoResize('body');
});
setupAutoResize('body');
});
</script>
</script>
{% else %}
<a href="#" aria-hidden="true" id="post_reply_markdown_editor_enabler" class="markdown_editor_enabler" data-id="body">{{ _('Enable markdown editor') }}</a>
{% endif %}
{% endif %}
</div>
</div>

View file

@ -20,20 +20,24 @@
<div class="col">
<div class="reply_form_inner position-relative">
{% if post.community.ap_id and '@beehaw.org' in post.community.ap_id %}
<p>{{ _('This post is hosted on beehaw.org which has <a href="https://docs.beehaw.org/docs/core-principles/what-is-beehaw/" target="_blank" rel="nofollow">high er standards of behaviour than most places. Be nice</a>.') }}</p>
<p>{{ _('This post is hosted on beehaw.org which has <a href="https://docs.beehaw.org/docs/core-principles/what-is-beehaw/" target="_blank" rel="nofollow">higher standards of behaviour than most places. Be nice</a>.') }}</p>
{% endif %}
{{ render_form(form) }}
{% if markdown_editor %}
<script nonce="{{ session['nonce'] }}">
window.addEventListener("load", function () {
var downarea = new DownArea({
elem: document.querySelector('#body'),
resize: DownArea.RESIZE_VERTICAL,
hide: ['heading', 'bold-italic'],
{% if not low_bandwidth %}
{% if markdown_editor %}
<script nonce="{{ session['nonce'] }}">
window.addEventListener("load", function () {
var downarea = new DownArea({
elem: document.querySelector('#body'),
resize: DownArea.RESIZE_VERTICAL,
hide: ['heading', 'bold-italic'],
});
setupAutoResize('body');
});
setupAutoResize('body');
});
</script>
</script>
{% else %}
<a href="#" aria-hidden="true" id="post_reply_markdown_editor_enabler" class="markdown_editor_enabler" data-id="body">{{ _('Enable markdown editor') }}</a>
{% endif %}
{% endif %}
</div>
</div>
@ -45,7 +49,7 @@
<p><a href="{{ url_for('auth.validation_required') }}">{{ _('Verify your email address to comment') }}</a></p>
{% endif %}
{% else %}
<p><a href="{{ url_for('auth.login') }}">{{ _('Log in to comment') }}</a></p>
<p><a href="{{ url_for('auth.login', next='/post/' + str(post.id)) }}">{{ _('Log in to comment') }}</a></p>
{% endif %}
{% else %}
<p>{{ _('Comments are disabled.') }}</p>

View file

@ -80,6 +80,9 @@
{% endfor %}
</ul>
<p class="mt-4"><a class="btn btn-primary" href="/communities">{{ _('Explore communities') }}</a></p>
<p>
<a class="no-underline" href="{{ rss_feed }}" rel="nofollow"><span class="fe fe-rss"></span> </a><a href="{{ rss_feed }}" rel="nofollow">RSS feed</a>
</p>
</div>
</div>
{% endif %}

View file

@ -24,6 +24,23 @@
{{ render_field(form.password_field) }}
<hr />
{{ render_field(form.about) }}
{% if not low_bandwidth %}
{% if markdown_editor %}
<script nonce="{{ session['nonce'] }}">
window.addEventListener("load", function () {
var downarea = new DownArea({
elem: document.querySelector('#about'),
resize: DownArea.RESIZE_VERTICAL,
hide: ['heading', 'bold-italic'],
value: {{ form.about.data | tojson | safe }}
});
setupAutoResize('about');
});
</script>
{% else %}
<a href="#" aria-hidden="true" class="markdown_editor_enabler create_post_markdown_editor_enabler" data-id="about">{{ _('Enable markdown editor') }}</a>
{% endif %}
{% endif %}
{{ render_field(form.matrixuserid) }}
<small class="field_hint">e.g. @something:matrix.org. Include leading @ and use : before server</small>
{{ render_field(form.profile_file) }}

View file

@ -1,7 +1,8 @@
from datetime import timedelta
from datetime import timedelta, timezone
from random import randint
from flask import request, flash, json, url_for, current_app, redirect, abort
from feedgen.feed import FeedGenerator
from flask import request, flash, json, url_for, current_app, redirect, abort, make_response, g
from flask_login import login_required, current_user
from flask_babel import _
from sqlalchemy import text, desc, or_
@ -14,7 +15,7 @@ from app.topic import bp
from app import db, celery, cache
from app.topic.forms import ChooseTopicsForm
from app.utils import render_template, user_filters_posts, moderating_communities, joined_communities, \
community_membership, blocked_domains, validation_required
community_membership, blocked_domains, validation_required, mimetype_from_url
@bp.route('/topic/<topic_name>', methods=['GET'])
@ -79,6 +80,8 @@ def show_topic(topic_name):
return render_template('topic/show_topic.html', title=_(topic.name), posts=posts, topic=topic, sort=sort,
page=page, post_layout=post_layout, next_url=next_url, prev_url=prev_url,
topic_communities=topic_communities, content_filters=content_filters,
rss_feed=f"https://{current_app.config['SERVER_NAME']}/topic/{topic_name}.rss",
rss_feed_name=f"{topic.name} on {g.site.name}",
show_post_community=True, moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()),
inoculation=inoculation[randint(0, len(inoculation) - 1)],
@ -87,6 +90,48 @@ def show_topic(topic_name):
abort(404)
@bp.route('/topic/<topic_name>.rss', methods=['GET'])
@cache.cached(timeout=600)
def show_topic_rss(topic_name):
topic = Topic.query.filter(Topic.machine_name == topic_name.strip().lower()).first()
if topic:
posts = Post.query.join(Community, Post.community_id == Community.id).filter(Community.topic_id == topic.id,
Community.banned == False)
posts = posts.filter(Post.from_bot == False, Post.nsfw == False, Post.nsfl == False)
posts = posts.order_by(desc(Post.created_at)).limit(100).all()
fg = FeedGenerator()
fg.id(f"https://{current_app.config['SERVER_NAME']}/topic/{topic_name}")
fg.title(f'{topic.name} on {g.site.name}')
fg.link(href=f"https://{current_app.config['SERVER_NAME']}/topic/{topic_name}", rel='alternate')
fg.logo(f"https://{current_app.config['SERVER_NAME']}/static/images/apple-touch-icon.png")
fg.subtitle(' ')
fg.link(href=f"https://{current_app.config['SERVER_NAME']}/topic/{topic_name}.rss", rel='self')
fg.language('en')
for post in posts:
fe = fg.add_entry()
fe.title(post.title)
fe.link(href=f"https://{current_app.config['SERVER_NAME']}/post/{post.id}")
if post.url:
type = mimetype_from_url(post.url)
if type and not type.startswith('text/'):
fe.enclosure(post.url, type=type)
fe.description(post.body_html)
fe.guid(post.profile_id(), permalink=True)
fe.author(name=post.author.user_name)
fe.pubDate(post.created_at.replace(tzinfo=timezone.utc))
response = make_response(fg.rss_str())
response.headers.set('Content-Type', 'application/rss+xml')
response.headers.add_header('ETag', f"{topic.id}_{hash(g.site.last_active)}")
response.headers.add_header('Cache-Control', 'no-cache, max-age=600, must-revalidate')
return response
else:
abort(404)
@bp.route('/choose_topics', methods=['GET', 'POST'])
@login_required
def choose_topics():

View file

@ -140,6 +140,7 @@ def edit_profile(actor):
form.password_field.data = ''
return render_template('user/edit_profile.html', title=_('Edit profile'), form=form, user=current_user,
markdown_editor=current_user.markdown_editor,
moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id())
)

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import hashlib
import mimetypes
import random
import urllib
from collections import defaultdict
@ -56,6 +57,7 @@ def return_304(etag, content_type=None):
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')
resp.headers.add_header('Vary', 'Accept, Accept-Encoding, Cookie')
if content_type:
resp.headers.set('Content-Type', content_type)
return resp
@ -327,6 +329,13 @@ def ensure_directory_exists(directory):
rebuild_directory += '/'
def mimetype_from_url(url):
parsed_url = urlparse(url)
path = parsed_url.path.split('?')[0] # Strip off anything after '?'
mime_type, _ = mimetypes.guess_type(path)
return mime_type
def validation_required(func):
@wraps(func)
def decorated_view(*args, **kwargs):

View file

@ -17,6 +17,8 @@ services:
env_file:
- ./.env.docker
entrypoint: ./entrypoint_celery.sh
volumes:
- ./media/:/app/app/static/media/
web:
build:
context: .
@ -27,7 +29,6 @@ services:
env_file:
- ./.env.docker
volumes:
- ./.env:/app/.env
- ./.gunicorn.conf.py:/app/gunicorn.conf.py
- ./media/:/app/app/static/media/
ports:

View file

@ -14,3 +14,5 @@ CACHE_REDIS_URL='redis://localhost:6379/1'
BOUNCE_HOST=''
BOUNCE_USERNAME=''
BOUNCE_PASSWORD=''
FLASK_APP=pyfedi.py