diff --git a/app/activitypub/routes.py b/app/activitypub/routes.py index 7e953cd4..92b3cc06 100644 --- a/app/activitypub/routes.py +++ b/app/activitypub/routes.py @@ -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) diff --git a/app/admin/forms.py b/app/admin/forms.py index 68066e44..6a759280 100644 --- a/app/admin/forms.py +++ b/app/admin/forms.py @@ -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')) diff --git a/app/admin/routes.py b/app/admin/routes.py index e2e148b8..32f2e7d4 100644 --- a/app/admin/routes.py +++ b/app/admin/routes.py @@ -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//delete', methods=['GET']) @login_required @permission_required('administer all users') diff --git a/app/cli.py b/app/cli.py index 4790af2a..4c828c8b 100644 --- a/app/cli.py +++ b/app/cli.py @@ -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(): diff --git a/app/community/routes.py b/app/community/routes.py index 25fdc294..b77cf25b 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -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) diff --git a/app/post/routes.py b/app/post/routes.py index b0d60a6f..82ed87dc 100644 --- a/app/post/routes.py +++ b/app/post/routes.py @@ -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//', 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//comment//reply', methods=['GET', 'POST']) diff --git a/app/static/js/scripts.js b/app/static/js/scripts.js index a00aea38..29a8f709 100644 --- a/app/static/js/scripts.js +++ b/app/static/js/scripts.js @@ -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) } -} \ No newline at end of file +} + +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); + }); + }); +} diff --git a/app/static/site.webmanifest b/app/static/manifest.json similarity index 94% rename from app/static/site.webmanifest rename to app/static/manifest.json index aaa029d9..a8538cf1 100644 --- a/app/static/site.webmanifest +++ b/app/static/manifest.json @@ -15,7 +15,7 @@ ], "theme_color": "#007BBF", "background_color": "#ffffff", - "display": "browser", + "display": "minimal-ui", "start_url": "/", "scope": "/" } diff --git a/app/static/service_worker.js b/app/static/service_worker.js new file mode 100644 index 00000000..625d42fc --- /dev/null +++ b/app/static/service_worker.js @@ -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; + }); + }) + ); + } +}); diff --git a/app/static/structure.css b/app/static/structure.css index 55918c60..19556312 100644 --- a/app/static/structure.css +++ b/app/static/structure.css @@ -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 */ diff --git a/app/static/structure.scss b/app/static/structure.scss index 43d18179..fb51b917 100644 --- a/app/static/structure.scss +++ b/app/static/structure.scss @@ -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; } \ No newline at end of file diff --git a/app/templates/admin/_nav.html b/app/templates/admin/_nav.html index c321f746..03485e9e 100644 --- a/app/templates/admin/_nav.html +++ b/app/templates/admin/_nav.html @@ -1,5 +1,6 @@