diff --git a/.gitignore b/.gitignore index 7e6251fb..32ce9f95 100644 --- a/.gitignore +++ b/.gitignore @@ -160,3 +160,4 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. .idea/ app/static/*.css.map +/app/static/media/ diff --git a/app/community/routes.py b/app/community/routes.py index 3a55da08..6c189a9e 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -3,19 +3,22 @@ from datetime import date, datetime, timedelta from flask import redirect, url_for, flash, request, make_response, session, Markup, current_app, abort from flask_login import login_user, logout_user, current_user, login_required from flask_babel import _ +from pillow_heif import register_heif_opener from sqlalchemy import or_, desc from app import db, constants from app.activitypub.signature import RsaKeys, HttpSignature from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePost, NewReplyForm from app.community.util import search_for_community, community_url_exists, actor_to_community, post_replies, \ - get_comment_branch, post_reply_count + get_comment_branch, post_reply_count, ensure_directory_exists from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, PostReply, \ - PostReplyVote, PostVote + PostReplyVote, PostVote, File 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 + shorten_string, markdown_to_text, domain_from_url, validate_image, gibberish +import os +from PIL import Image, ImageOps @bp.route('/add_local', methods=['GET', 'POST']) @@ -95,7 +98,7 @@ def show_community(community: Community): return render_template('community/community.html', community=community, title=community.title, is_moderator=is_moderator, is_owner=is_owner, mods=mod_list, posts=posts, description=description, - og_image=og_image) + og_image=og_image, POST_TYPE_IMAGE=POST_TYPE_IMAGE, POST_TYPE_LINK=POST_TYPE_LINK) @bp.route('//subscribe', methods=['GET']) @@ -209,9 +212,48 @@ def add_post(actor): domain.post_count += 1 post.domain = domain elif form.type.data == 'image': + allowed_extensions = ['.gif', '.jpg', '.jpeg', '.png', '.webp', '.heic'] post.title = form.image_title.data post.type = POST_TYPE_IMAGE - # todo: handle file upload + uploaded_file = request.files['image_file'] + if uploaded_file.filename != '': + 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) + + directory = 'app/static/media/posts/' + new_filename[0:2] + '/' + new_filename[2:4] + ensure_directory_exists(directory) + + 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) + + if file_ext.lower() == '.heic': + register_heif_opener() + + # resize if necessary + img = Image.open(final_place) + 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 + img.thumbnail((256, 256)) + 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=form.image_title.data, + width=img_width, height=img_height, thumbnail_width=thumbnail_width, + thumbnail_height=thumbnail_height, thumbnail_path=final_place_thumbnail) + post.image = file + db.session.add(file) + elif form.type.data == 'poll': ... else: @@ -262,7 +304,7 @@ def show_post(post_id: int): return render_template('community/post.html', title=post.title, post=post, is_moderator=is_moderator, canonical=post.ap_id, form=form, replies=replies, THREAD_CUTOFF_DEPTH=constants.THREAD_CUTOFF_DEPTH, - description=description, og_image=og_image) + description=description, og_image=og_image, POST_TYPE_IMAGE=constants.POST_TYPE_IMAGE) @bp.route('/post//', methods=['GET', 'POST']) diff --git a/app/community/util.py b/app/community/util.py index cddfb829..d94f1545 100644 --- a/app/community/util.py +++ b/app/community/util.py @@ -5,6 +5,7 @@ from app import db from app.models import Community, File, BannedInstances, PostReply from app.utils import get_request from sqlalchemy import desc, text +import os def search_for_community(address: str): @@ -132,3 +133,13 @@ def get_comment_branch(post_id: int, comment_id: int, sort_by: str) -> List[Post def post_reply_count(post_id) -> int: return db.session.execute(text('SELECT COUNT(id) as c FROM "post_reply" WHERE post_id = :post_id'), {'post_id': post_id}).scalar() + + +def ensure_directory_exists(directory): + parts = directory.split('/') + rebuild_directory = '' + for part in parts: + rebuild_directory += part + if not os.path.isdir(rebuild_directory): + os.mkdir(rebuild_directory) + rebuild_directory += '/' diff --git a/app/models.py b/app/models.py index 40b87056..2ef3a8a7 100644 --- a/app/models.py +++ b/app/models.py @@ -31,6 +31,22 @@ class File(db.Model): height = db.Column(db.Integer) alt_text = db.Column(db.String(256)) source_url = db.Column(db.String(256)) + thumbnail_path = db.Column(db.String(255)) + thumbnail_width = db.Column(db.Integer) + thumbnail_height = db.Column(db.Integer) + + def view_url(self): + if self.source_url: + return self.source_url + elif self.file_path: + file_path = self.file_path[4:] if self.file_path.startswith('app/') else self.file_path + return f"https://{current_app.config['SERVER_NAME']}/{file_path}" + else: + return '' + + def thumbnail_url(self): + thumbnail_path = self.thumbnail_path[4:] if self.thumbnail_path.startswith('app/') else self.thumbnail_path + return f"https://{current_app.config['SERVER_NAME']}/{thumbnail_path}" class Community(db.Model): diff --git a/app/static/scss/_typography.scss b/app/static/scss/_typography.scss index 70321ebc..6bdc268b 100644 --- a/app/static/scss/_typography.scss +++ b/app/static/scss/_typography.scss @@ -166,6 +166,10 @@ content: "\e935"; } +.fe-camera::before { + content: "\e928"; +} + a.no-underline { text-decoration: none; &:hover { diff --git a/app/static/structure.css b/app/static/structure.css index e0951942..9514039d 100644 --- a/app/static/structure.css +++ b/app/static/structure.css @@ -169,6 +169,10 @@ nav, etc which are used site-wide */ content: "\e935"; } +.fe-camera::before { + content: "\e928"; +} + a.no-underline { text-decoration: none; } @@ -385,6 +389,11 @@ fieldset legend { padding-right: 5px; } +.post_type_image .post_image img { + max-width: 100%; + height: auto; +} + .voting_buttons { float: right; display: block; diff --git a/app/static/structure.scss b/app/static/structure.scss index 3a8627c0..00499498 100644 --- a/app/static/structure.scss +++ b/app/static/structure.scss @@ -156,6 +156,15 @@ nav, etc which are used site-wide */ padding-right: 5px; } +.post_type_image { + .post_image { + img { + max-width: 100%; + height: auto; + } + } +} + .voting_buttons { float: right; display: block; diff --git a/app/static/styles.css b/app/static/styles.css index 390faecb..3229fc03 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -168,6 +168,10 @@ content: "\e935"; } +.fe-camera::before { + content: "\e928"; +} + a.no-underline { text-decoration: none; } @@ -399,6 +403,10 @@ nav.navbar { padding-top: 8px; } +.text-right { + text-align: right; +} + @media (prefers-color-scheme: dark) { body { background-color: #777; diff --git a/app/static/styles.scss b/app/static/styles.scss index 00343192..28ef2963 100644 --- a/app/static/styles.scss +++ b/app/static/styles.scss @@ -174,6 +174,10 @@ nav.navbar { } } +.text-right { + text-align: right; +} + @media (prefers-color-scheme: dark) { body { background-color: $dark-grey; diff --git a/app/templates/community/_post_full.html b/app/templates/community/_post_full.html index cc1a87b5..b1849f75 100644 --- a/app/templates/community/_post_full.html +++ b/app/templates/community/_post_full.html @@ -1,6 +1,6 @@
- {% if post.image_id %} -
+ {% if post.type == POST_TYPE_IMAGE %} +
-
- {% if post.url %} - {{ post.image.alt_text }} - {% else %} - {{ post.image.alt_text }} - {% endif %} +
+ {{ post.image.alt_text }} +
{% else %}
@@ -54,6 +49,12 @@ {{ render_username(post.author) }} {% if post.edited_at %} edited {{ moment(post.edited_at).fromNow() }}{% endif %}

+ {% if post.image_id %} +
+ {{ post.image.alt_text }} +
+ {% endif %}
{% endif %}
diff --git a/app/templates/community/_post_teaser.html b/app/templates/community/_post_teaser.html index 6df06a75..7ed094cc 100644 --- a/app/templates/community/_post_teaser.html +++ b/app/templates/community/_post_teaser.html @@ -4,9 +4,10 @@

- {{ post.title }} - {% if post.type == post_type_link and post.domain_id %} - + {{ post.title }} + {% if post.type == POST_TYPE_IMAGE %} {% endif %} + {% if post.type == POST_TYPE_LINK and post.domain_id %} + External link ({{ post.domain.name }}) @@ -15,9 +16,9 @@ {{ render_username(post.author) }} ยท {{ moment(post.posted_at).fromNow() }}

{% if post.image_id %} -
- {{ post.image.alt_text }} +
+ {{ post.image.alt_text }}
{% endif %}
diff --git a/app/templates/community/add_post.html b/app/templates/community/add_post.html index 31894f3e..8bdb1d4f 100644 --- a/app/templates/community/add_post.html +++ b/app/templates/community/add_post.html @@ -5,7 +5,7 @@

{{ _('Create post') }}

-
+ {{ form.csrf_token() }} {{ render_field(form.communities) }}