From 9efda995e36ea40cd47db4a7fe3ab95ac2ff1d9f Mon Sep 17 00:00:00 2001 From: rimu <3310831+rimu@users.noreply.github.com> Date: Fri, 8 Dec 2023 17:13:38 +1300 Subject: [PATCH] upload images for community icon and banner --- app/__init__.py | 2 +- app/activitypub/routes.py | 22 +++++-- app/community/forms.py | 4 +- app/community/routes.py | 70 ++++++++++++++++++-- app/community/util.py | 89 +++++++++++++++++++++++++- app/models.py | 69 ++++++++++++++++---- app/static/structure.css | 6 ++ app/static/structure.scss | 8 +++ app/static/styles.css | 6 ++ app/static/styles.scss | 6 ++ app/templates/community/add_local.html | 21 +++++- app/templates/community/add_post.html | 2 +- app/templates/list_communities.html | 2 +- app/utils.py | 9 ++- 14 files changed, 283 insertions(+), 33 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index 9a8a0546..261d6fed 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -18,7 +18,7 @@ from sqlalchemy_searchable import make_searchable from config import Config -db = SQLAlchemy(session_options={"autoflush": False}) +db = SQLAlchemy(session_options={"autoflush": False}) # engine_options={'pool_size': 5, 'max_overflow': 10} migrate = Migrate() login = LoginManager() login.login_view = 'auth.login' diff --git a/app/activitypub/routes.py b/app/activitypub/routes.py index 9fa56ded..9bbf12e7 100644 --- a/app/activitypub/routes.py +++ b/app/activitypub/routes.py @@ -14,7 +14,7 @@ from app.activitypub.util import public_key, users_total, active_half_year, acti post_to_activity, find_actor_or_create, default_context, instance_blocked, find_reply_parent, find_liked_object, \ lemmy_site_data, instance_weight from app.utils import gibberish, get_setting, is_image_url, allowlist_html, html_to_markdown, render_template, \ - domain_from_url, markdown_to_html, community_membership + domain_from_url, markdown_to_html, community_membership, ap_datetime import werkzeug.exceptions INBOX = [] @@ -159,12 +159,17 @@ def user_profile(actor): "endpoints": { "sharedInbox": f"https://{server}/inbox" }, - "published": user.created.isoformat() + '+00:00', + "published": ap_datetime(user.created), } if user.avatar_id is not None: actor_data["icon"] = { "type": "Image", - "url": f"https://{server}/avatars/{user.avatar.file_path}" + "url": f"https://{current_app.config['SERVER_NAME']}{user.avatar_image()}" + } + if user.cover_id is not None: + actor_data["image"] = { + "type": "Image", + "url": f"https://{current_app.config['SERVER_NAME']}{user.cover_image()}" } if user.about: actor_data['source'] = { @@ -219,13 +224,18 @@ def community_profile(actor): "endpoints": { "sharedInbox": f"https://{server}/inbox" }, - "published": community.created_at.isoformat() + '+00:00', - "updated": community.last_active.isoformat() + '+00:00', + "published": ap_datetime(community.created_at), + "updated": ap_datetime(community.last_active), } if community.icon_id is not None: actor_data["icon"] = { "type": "Image", - "url": f"https://{server}/avatars/{community.icon.file_path}" + "url": f"https://{current_app.config['SERVER_NAME']}{community.icon_image()}" + } + if community.image_id is not None: + actor_data["image"] = { + "type": "Image", + "url": f"https://{current_app.config['SERVER_NAME']}{community.header_image()}" } resp = jsonify(actor_data) resp.content_type = 'application/activity+json' diff --git a/app/community/forms.py b/app/community/forms.py index cf1f9d68..6041c4b2 100644 --- a/app/community/forms.py +++ b/app/community/forms.py @@ -8,8 +8,10 @@ from app.utils import domain_from_url class AddLocalCommunity(FlaskForm): community_name = StringField(_l('Name'), validators=[DataRequired()]) - url = StringField(_l('Url'), render_kw={'placeholder': '/c/'}) + url = StringField(_l('Url')) description = TextAreaField(_l('Description')) + icon_file = FileField(_('Icon image')) + banner_file = FileField(_('Banner image')) rules = TextAreaField(_l('Rules')) nsfw = BooleanField('18+ NSFW') submit = SubmitField(_l('Create')) diff --git a/app/community/routes.py b/app/community/routes.py index 78709237..f4310253 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -9,14 +9,14 @@ from app.activitypub.signature import RsaKeys, HttpSignature from app.activitypub.util import default_context from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePostForm from app.community.util import search_for_community, community_url_exists, actor_to_community, \ - ensure_directory_exists, opengraph_parse, url_to_thumbnail_file, save_post + ensure_directory_exists, opengraph_parse, url_to_thumbnail_file, save_post, save_icon_file, save_banner_file from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE, \ SUBSCRIPTION_PENDING from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, \ File, PostVote from app.community import bp from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \ - shorten_string, markdown_to_text, domain_from_url, validate_image, gibberish, community_membership + shorten_string, markdown_to_text, domain_from_url, validate_image, gibberish, community_membership, ap_datetime import os from PIL import Image, ImageOps from datetime import datetime @@ -39,6 +39,16 @@ def add_local(): public_key=public_key, ap_profile_id=current_app.config['SERVER_NAME'] + '/c/' + form.url.data, subscriptions_count=1) + icon_file = request.files['icon_file'] + if icon_file and icon_file.filename != '': + file = save_icon_file(icon_file) + if file: + community.icon = file + banner_file = request.files['banner_file'] + if banner_file and banner_file.filename != '': + file = save_banner_file(banner_file) + if file: + community.image = file db.session.add(community) db.session.commit() membership = CommunityMember(user_id=current_user.id, community_id=community.id, is_moderator=True, @@ -228,7 +238,7 @@ def add_post(actor): form.nsfw.render_kw = {'disabled': True} if get_setting('allow_nsfl', False) is False: form.nsfl.render_kw = {'disabled': True} - images_disabled = 'disabled' if not get_setting('allow_local_image_posts', True) else '' + images_disabled = 'disabled' if not get_setting('allow_local_image_posts', True) else '' # bug: this will disable posting of images to *remote* hosts too form.communities.choices = [(c.id, c.display_name()) for c in current_user.communities()] @@ -239,10 +249,58 @@ def add_post(actor): community.last_active = datetime.utcnow() db.session.commit() + if community.ap_id: # this is a remote community - send the post to the instance that hosts it + page = { + 'type': 'Page', + 'id': f"https://{current_app.config['SERVER_NAME']}/post/{post.id}", + 'attributedTo': current_user.ap_profile_id, + 'to': [ + community.ap_profile_id, + 'https://www.w3.org/ns/activitystreams#Public' + ], + 'name': post.title, + 'cc': [], + 'content': post.body_html, + 'mediaType': 'text/html', + 'source': { + 'content': post.body, + 'mediaType': 'text/markdown' + }, + 'attachment': [], + 'commentsEnabled': post.comments_enabled, + 'sensitive': post.nsfw, + 'nsfl': post.nsfl, + 'published': ap_datetime(datetime.utcnow()), + 'audience': community.ap_profile_id + } + create = { + "id": f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}", + "actor": current_user.ap_profile_id, + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + community.ap_profile_id + ], + "type": "Create", + "audience": community.ap_profile_id, + "object": page + } + try: + message = HttpSignature.signed_request(community.ap_inbox_url, create, current_user.private_key, + current_user.ap_profile_id + '#main-key') + if message.status_code == 200: + flash('Your post has been sent to ' + community.title) + else: + flash('Response status code was not 200', 'warning') + current_app.logger.error('Response code for post attempt was ' + + str(message.status_code) + ' ' + message.text) + except Exception as ex: + flash('Failed to send request to subscribe: ' + str(ex), 'error') + current_app.logger.error("Exception while trying to subscribe" + str(ex)) + else: # local community - send post out to followers + ... - # todo: federate post creation out to followers - - flash('Post has been added') return redirect(f"/c/{community.link()}") else: form.communities.data = community.id diff --git a/app/community/util.py b/app/community/util.py index 67bdec83..dc783ee4 100644 --- a/app/community/util.py +++ b/app/community/util.py @@ -16,6 +16,8 @@ import os from opengraph_parse import parse_page +allowed_extensions = ['.gif', '.jpg', '.jpeg', '.png', '.webp', '.heic'] + def search_for_community(address: str): if address.startswith('!'): name, server = address[1:].split('@') @@ -176,7 +178,6 @@ def save_post(form, post): db.session.add(file) elif form.type.data == 'image': - allowed_extensions = ['.gif', '.jpg', '.jpeg', '.png', '.webp', '.heic'] post.title = form.image_title.data post.type = POST_TYPE_IMAGE uploaded_file = request.files['image_file'] @@ -185,15 +186,18 @@ def save_post(form, post): remove_old_file(post.image_id) post.image_id = None + # check if this is an allowed type of file file_ext = os.path.splitext(uploaded_file.filename)[1] if file_ext.lower() not in allowed_extensions or file_ext != validate_image( uploaded_file.stream): abort(400) new_filename = gibberish(15) + # set up the storage directory directory = 'app/static/media/posts/' + new_filename[0:2] + '/' + new_filename[2:4] ensure_directory_exists(directory) + # save the file final_place = os.path.join(directory, new_filename + file_ext) final_place_thumbnail = os.path.join(directory, new_filename + '_thumbnail.webp') uploaded_file.save(final_place) @@ -203,14 +207,15 @@ def save_post(form, post): # resize if necessary img = Image.open(final_place) + img = ImageOps.exif_transpose(img) img_width = img.width img_height = img.height - img = ImageOps.exif_transpose(img) if img.width > 2000 or img.height > 2000: img.thumbnail((2000, 2000)) img.save(final_place) img_width = img.width img_height = img.height + # save a second, smaller, version as a thumbnail img.thumbnail((256, 256)) img.save(final_place_thumbnail, format="WebP", quality=93) thumbnail_width = img.width @@ -237,3 +242,83 @@ def save_post(form, post): def remove_old_file(file_id): remove_file = File.query.get(file_id) remove_file.delete_from_disk() + + +def save_icon_file(icon_file) -> File: + # check if this is an allowed type of file + file_ext = os.path.splitext(icon_file.filename)[1] + if file_ext.lower() not in allowed_extensions or file_ext != validate_image( + icon_file.stream): + abort(400) + new_filename = gibberish(15) + + # set up the storage directory + directory = 'app/static/media/communities/' + new_filename[0:2] + '/' + new_filename[2:4] + ensure_directory_exists(directory) + + # save the file + final_place = os.path.join(directory, new_filename + file_ext) + final_place_thumbnail = os.path.join(directory, new_filename + '_thumbnail.webp') + icon_file.save(final_place) + + if file_ext.lower() == '.heic': + register_heif_opener() + + # resize if necessary + img = Image.open(final_place) + img = ImageOps.exif_transpose(img) + img_width = img.width + img_height = img.height + if img.width > 200 or img.height > 200: + img.thumbnail((200, 200)) + img.save(final_place) + img_width = img.width + img_height = img.height + # save a second, smaller, version as a thumbnail + img.thumbnail((32, 32)) + img.save(final_place_thumbnail, format="WebP", quality=93) + thumbnail_width = img.width + thumbnail_height = img.height + + file = File(file_path=final_place, file_name=new_filename + file_ext, alt_text='community icon', + width=img_width, height=img_height, thumbnail_width=thumbnail_width, + thumbnail_height=thumbnail_height, thumbnail_path=final_place_thumbnail) + db.session.add(file) + return file + + +def save_banner_file(banner_file) -> File: + # check if this is an allowed type of file + file_ext = os.path.splitext(banner_file.filename)[1] + if file_ext.lower() not in allowed_extensions or file_ext != validate_image( + banner_file.stream): + abort(400) + new_filename = gibberish(15) + + # set up the storage directory + directory = 'app/static/media/communities/' + new_filename[0:2] + '/' + new_filename[2:4] + ensure_directory_exists(directory) + + # save the file + final_place = os.path.join(directory, new_filename + file_ext) + final_place_thumbnail = os.path.join(directory, new_filename + '_thumbnail.webp') + banner_file.save(final_place) + + if file_ext.lower() == '.heic': + register_heif_opener() + + # resize if necessary + img = Image.open(final_place) + img = ImageOps.exif_transpose(img) + img_width = img.width + img_height = img.height + if img.width > 1000 or img.height > 300: + img.thumbnail((1000, 300)) + img.save(final_place) + img_width = img.width + img_height = img.height + + file = File(file_path=final_place, file_name=new_filename + file_ext, alt_text='community banner', + width=img_width, height=img_height) + db.session.add(file) + return file \ No newline at end of file diff --git a/app/models.py b/app/models.py index 56bdfd9f..d8b076fa 100644 --- a/app/models.py +++ b/app/models.py @@ -103,21 +103,45 @@ class Community(db.Model): image = db.relationship('File', foreign_keys=[image_id], single_parent=True, cascade="all, delete-orphan") @cache.memoize(timeout=500) - def icon_image(self) -> str: + def icon_image(self, size='default') -> str: if self.icon_id is not None: - if self.icon.file_path is not None: - return self.icon.file_path - if self.icon.source_url is not None: - return self.icon.source_url + if size == 'default': + if self.icon.file_path is not None: + if self.icon.file_path.startswith('app/'): + return self.icon.file_path.replace('app/', '/') + else: + return self.icon.file_path + if self.icon.source_url is not None: + if self.icon.source_url.startswith('app/'): + return self.icon.source_url.replace('app/', '/') + else: + return self.icon.source_url + elif size == 'tiny': + if self.icon.thumbnail_path is not None: + if self.icon.thumbnail_path.startswith('app/'): + return self.icon.thumbnail_path.replace('app/', '/') + else: + return self.icon.thumbnail_path + if self.icon.source_url is not None: + if self.icon.source_url.startswith('app/'): + return self.icon.source_url.replace('app/', '/') + else: + return self.icon.source_url return '' @cache.memoize(timeout=500) def header_image(self) -> str: if self.image_id is not None: if self.image.file_path is not None: - return self.image.file_path + if self.image.file_path.startswith('app/'): + return self.image.file_path.replace('app/', '/') + else: + return self.image.file_path if self.image.source_url is not None: - return self.image.source_url + if self.image.source_url.startswith('app/'): + return self.image.source_url.replace('app/', '/') + else: + return self.image.source_url return '' def display_name(self) -> str: @@ -143,6 +167,9 @@ class Community(db.Model): def is_moderator(self): return any(moderator.user_id == current_user.id for moderator in self.moderators()) + def profile_id(self): + return self.ap_profile_id if self.ap_profile_id else f"https://{current_app.config['SERVER_NAME']}/c/{self.name}" + user_role = db.Table('user_role', db.Column('user_id', db.Integer, db.ForeignKey('user.id')), @@ -232,18 +259,30 @@ class User(UserMixin, db.Model): def avatar_image(self) -> str: if self.avatar_id is not None: if self.avatar.file_path is not None: - return self.avatar.file_path + if self.avatar.file_path.startswith('app/'): + return self.avatar.file_path.replace('app/', '/') + else: + return self.avatar.file_path if self.avatar.source_url is not None: - return self.avatar.source_url + if self.avatar.source_url.startswith('app/'): + return self.avatar.source_url.replace('app/', '/') + else: + return self.avatar.source_url return '' @cache.memoize(timeout=500) def cover_image(self) -> str: if self.cover_id is not None: if self.cover.file_path is not None: - return self.cover.file_path + if self.cover.file_path.startswith('app/'): + return self.cover.file_path.replace('app/', '/') + else: + return self.cover.file_path if self.cover.source_url is not None: - return self.cover.source_url + if self.cover.source_url.startswith('app/'): + return self.cover.source_url.replace('app/', '/') + else: + return self.cover.source_url return '' def link(self) -> str: @@ -302,7 +341,7 @@ class User(UserMixin, db.Model): join(CommunityMember).filter(CommunityMember.is_banned == False).all() def profile_id(self): - return self.ap_profile_id if self.ap_profile_id else f"{self.user_name}@{current_app.config['SERVER_NAME']}" + return self.ap_profile_id if self.ap_profile_id else f"https://{current_app.config['SERVER_NAME']}/u/{self.user_name}" def created_recently(self): return self.created and self.created > datetime.utcnow() - timedelta(days=7) @@ -413,6 +452,9 @@ class Post(db.Model): if vpos != -1: return self.url[vpos + 2:vpos + 13] + def profile_id(self): + return f"https://{current_app.config['SERVER_NAME']}/post/{self.id}" + class PostReply(db.Model): query_class = FullTextSearchQuery @@ -452,6 +494,9 @@ class PostReply(db.Model): def get_by_ap_id(cls, ap_id): return cls.query.filter_by(ap_id=ap_id).first() + def profile_id(self): + return f"https://{current_app.config['SERVER_NAME']}/comment/{self.id}" + class Domain(db.Model): id = db.Column(db.Integer, primary_key=True) diff --git a/app/static/structure.css b/app/static/structure.css index db796065..250d6aa0 100644 --- a/app/static/structure.css +++ b/app/static/structure.css @@ -569,6 +569,12 @@ fieldset legend { border-top: solid 1px #ddd; } +#add_local_community_form #url { + width: 297px; + display: inline-block; + padding-left: 3px; +} + .table tr th { vertical-align: middle; } diff --git a/app/static/structure.scss b/app/static/structure.scss index 891e6df3..33b6653f 100644 --- a/app/static/structure.scss +++ b/app/static/structure.scss @@ -317,6 +317,14 @@ nav, etc which are used site-wide */ } } +#add_local_community_form { + #url { + width: 297px; + display: inline-block; + padding-left: 3px; + } +} + .table { tr th { vertical-align: middle; diff --git a/app/static/styles.css b/app/static/styles.css index ccd1645a..4e10378b 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -470,6 +470,12 @@ nav.navbar { margin-bottom: 15px; } +.field_hint { + margin-top: -15px; + display: block; + margin-bottom: 10px; +} + @media (prefers-color-scheme: dark) { body { background-color: #777; diff --git a/app/static/styles.scss b/app/static/styles.scss index 7b3e785c..53c3c585 100644 --- a/app/static/styles.scss +++ b/app/static/styles.scss @@ -191,6 +191,12 @@ nav.navbar { } } +.field_hint { + margin-top: -15px; + display: block; + margin-bottom: 10px; +} + @media (prefers-color-scheme: dark) { body { background-color: $dark-grey; diff --git a/app/templates/community/add_local.html b/app/templates/community/add_local.html index c9a69b4a..5399fa0d 100644 --- a/app/templates/community/add_local.html +++ b/app/templates/community/add_local.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% from 'bootstrap/form.html' import render_form %} +{% from 'bootstrap/form.html' import render_field %} {% block app_content %}