mirror of
https://codeberg.org/rimu/pyfedi
synced 2025-01-23 11:26:56 -08:00
Merge branch 'main' of https://codeberg.org/saint/pyfedi
This commit is contained in:
commit
7cff8ceeac
26 changed files with 489 additions and 73 deletions
|
@ -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)
|
||||
|
|
|
@ -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'))
|
||||
|
||||
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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():
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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'])
|
||||
|
|
|
@ -11,6 +11,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||
setupKeyboardShortcuts();
|
||||
setupTopicChooser();
|
||||
setupConversationChooser();
|
||||
setupMarkdownEditorEnabler();
|
||||
});
|
||||
|
||||
|
||||
|
@ -549,4 +550,36 @@ function displayTimeTracked() {
|
|||
if(timeSpentElement && timeSpent) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
],
|
||||
"theme_color": "#007BBF",
|
||||
"background_color": "#ffffff",
|
||||
"display": "browser",
|
||||
"display": "minimal-ui",
|
||||
"start_url": "/",
|
||||
"scope": "/"
|
||||
}
|
31
app/static/service_worker.js
Normal file
31
app/static/service_worker.js
Normal 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;
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
|
@ -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 */
|
||||
|
|
|
@ -934,4 +934,20 @@ fieldset {
|
|||
bottom: 0;
|
||||
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;
|
||||
}
|
|
@ -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> |
|
||||
|
|
47
app/templates/admin/add_user.html
Normal file
47
app/templates/admin/add_user.html
Normal 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 %}
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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') %}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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) }}
|
||||
|
|
|
@ -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():
|
||||
|
|
|
@ -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())
|
||||
)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -14,3 +14,5 @@ CACHE_REDIS_URL='redis://localhost:6379/1'
|
|||
BOUNCE_HOST=''
|
||||
BOUNCE_USERNAME=''
|
||||
BOUNCE_PASSWORD=''
|
||||
|
||||
FLASK_APP=pyfedi.py
|
||||
|
|
Loading…
Reference in a new issue