create and view image posts

This commit is contained in:
rimu 2023-11-27 22:05:35 +13:00
parent e2b86f3caf
commit a17b8785d3
15 changed files with 178 additions and 25 deletions

1
.gitignore vendored
View file

@ -160,3 +160,4 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/ .idea/
app/static/*.css.map app/static/*.css.map
/app/static/media/

View file

@ -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 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_login import login_user, logout_user, current_user, login_required
from flask_babel import _ from flask_babel import _
from pillow_heif import register_heif_opener
from sqlalchemy import or_, desc from sqlalchemy import or_, desc
from app import db, constants from app import db, constants
from app.activitypub.signature import RsaKeys, HttpSignature from app.activitypub.signature import RsaKeys, HttpSignature
from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePost, NewReplyForm 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, \ 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.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, \ from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, PostReply, \
PostReplyVote, PostVote PostReplyVote, PostVote, File
from app.community import bp from app.community import bp
from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \ 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']) @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, 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, 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('/<actor>/subscribe', methods=['GET']) @bp.route('/<actor>/subscribe', methods=['GET'])
@ -209,9 +212,48 @@ def add_post(actor):
domain.post_count += 1 domain.post_count += 1
post.domain = domain post.domain = domain
elif form.type.data == 'image': elif form.type.data == 'image':
allowed_extensions = ['.gif', '.jpg', '.jpeg', '.png', '.webp', '.heic']
post.title = form.image_title.data post.title = form.image_title.data
post.type = POST_TYPE_IMAGE 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': elif form.type.data == 'poll':
... ...
else: 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, 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, 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/<int:post_id>/<vote_direction>', methods=['GET', 'POST']) @bp.route('/post/<int:post_id>/<vote_direction>', methods=['GET', 'POST'])

View file

@ -5,6 +5,7 @@ from app import db
from app.models import Community, File, BannedInstances, PostReply from app.models import Community, File, BannedInstances, PostReply
from app.utils import get_request from app.utils import get_request
from sqlalchemy import desc, text from sqlalchemy import desc, text
import os
def search_for_community(address: str): 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: 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'), return db.session.execute(text('SELECT COUNT(id) as c FROM "post_reply" WHERE post_id = :post_id'),
{'post_id': post_id}).scalar() {'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 += '/'

View file

@ -31,6 +31,22 @@ class File(db.Model):
height = db.Column(db.Integer) height = db.Column(db.Integer)
alt_text = db.Column(db.String(256)) alt_text = db.Column(db.String(256))
source_url = 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): class Community(db.Model):

View file

@ -166,6 +166,10 @@
content: "\e935"; content: "\e935";
} }
.fe-camera::before {
content: "\e928";
}
a.no-underline { a.no-underline {
text-decoration: none; text-decoration: none;
&:hover { &:hover {

View file

@ -169,6 +169,10 @@ nav, etc which are used site-wide */
content: "\e935"; content: "\e935";
} }
.fe-camera::before {
content: "\e928";
}
a.no-underline { a.no-underline {
text-decoration: none; text-decoration: none;
} }
@ -385,6 +389,11 @@ fieldset legend {
padding-right: 5px; padding-right: 5px;
} }
.post_type_image .post_image img {
max-width: 100%;
height: auto;
}
.voting_buttons { .voting_buttons {
float: right; float: right;
display: block; display: block;

View file

@ -156,6 +156,15 @@ nav, etc which are used site-wide */
padding-right: 5px; padding-right: 5px;
} }
.post_type_image {
.post_image {
img {
max-width: 100%;
height: auto;
}
}
}
.voting_buttons { .voting_buttons {
float: right; float: right;
display: block; display: block;

View file

@ -168,6 +168,10 @@
content: "\e935"; content: "\e935";
} }
.fe-camera::before {
content: "\e928";
}
a.no-underline { a.no-underline {
text-decoration: none; text-decoration: none;
} }
@ -399,6 +403,10 @@ nav.navbar {
padding-top: 8px; padding-top: 8px;
} }
.text-right {
text-align: right;
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
body { body {
background-color: #777; background-color: #777;

View file

@ -174,6 +174,10 @@ nav.navbar {
} }
} }
.text-right {
text-align: right;
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
body { body {
background-color: $dark-grey; background-color: $dark-grey;

View file

@ -1,6 +1,6 @@
<div class="row"> <div class="row">
{% if post.image_id %} {% if post.type == POST_TYPE_IMAGE %}
<div class="col-8"> <div class="col post_type_image">
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation"> <nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
<ol class="breadcrumb"> <ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li> <li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
@ -21,15 +21,10 @@
<p><small>submitted {{ moment(post.posted_at).fromNow() }} by {{ render_username(post.author) }} <p><small>submitted {{ moment(post.posted_at).fromNow() }} by {{ render_username(post.author) }}
{% if post.edited_at %} edited {{ moment(post.edited_at).fromNow() }}{% endif %} {% if post.edited_at %} edited {{ moment(post.edited_at).fromNow() }}{% endif %}
</small></p> </small></p>
</div> <div class="post_image">
<div class="col-4"> <a href="{{ post.image.view_url() }}"><img src="{{ post.image.view_url() }}" alt="{{ post.image.alt_text }}"
{% if post.url %} width="{{ post.image.width }}" height="{{ post.image.height }}" /></a>
<a href="post.url" rel="nofollow ugc"><img src="{{ post.image.source_url }}" alt="{{ post.image.alt_text }}" </div>
width="100" /></a>
{% else %}
<a href="post.image.source_url"><img src="{{ post.image.source_url }}" alt="{{ post.image.alt_text }}"
width="100" /></a>
{% endif %}
</div> </div>
{% else %} {% else %}
<div class="col"> <div class="col">
@ -54,6 +49,12 @@
{{ render_username(post.author) }} {{ render_username(post.author) }}
{% if post.edited_at %} edited {{ moment(post.edited_at).fromNow() }}{% endif %} {% if post.edited_at %} edited {{ moment(post.edited_at).fromNow() }}{% endif %}
</p> </p>
{% if post.image_id %}
<div class="post_image">
<a href="{{ post.image.view_url() }}"><img src="{{ post.image.thumbnail_url() }}" alt="{{ post.image.alt_text }}"
width="{{ post.image.thumbnail_width }}" height="{{ post.image.thumbnail_height }}" /></a>
</div>
{% endif %}
</div> </div>
{% endif %} {% endif %}
</div> </div>

View file

@ -4,9 +4,10 @@
<div class="row main_row"> <div class="row main_row">
<div class="col{% if post.image_id %}-8{% endif %}"> <div class="col{% if post.image_id %}-8{% endif %}">
<h3> <h3>
<a href="{{ url_for('community.show_post', post_id=post.id) }}">{{ post.title }}</a> <a href="{{ url_for('community.show_post', post_id=post.id) }}">{{ post.title }}
{% if post.type == post_type_link and post.domain_id %} {% if post.type == POST_TYPE_IMAGE %}<span class="fe fe-camera"> </span>{% endif %}</a>
<a href="{{ post.url }}" rel="nofollow ugc"> {% if post.type == POST_TYPE_LINK and post.domain_id %}
<a href="{{ post.url }}" rel="nofollow ugc" target="_blank">
<img src="/static/images/external_link_black.svg" class="external_link_icon" alt="External link" /> <img src="/static/images/external_link_black.svg" class="external_link_icon" alt="External link" />
</a> </a>
<span class="domain_link">(<a href="/d/{{ post.domain_id }}">{{ post.domain.name }}</a>)</span> <span class="domain_link">(<a href="/d/{{ post.domain_id }}">{{ post.domain.name }}</a>)</span>
@ -15,9 +16,9 @@
<span class="small">{{ render_username(post.author) }} · {{ moment(post.posted_at).fromNow() }}</span> <span class="small">{{ render_username(post.author) }} · {{ moment(post.posted_at).fromNow() }}</span>
</div> </div>
{% if post.image_id %} {% if post.image_id %}
<div class="col-4"> <div class="col-4 text-right">
<img src="{{ post.image.source_url}}" alt="{{ post.image.alt_text }}" <a href="{{ url_for('community.show_post', post_id=post.id) }}"><img src="{{ post.image.thumbnail_url() }}" alt="{{ post.image.alt_text }}"
width="100" /> width="100" /></a>
</div> </div>
{% endif %} {% endif %}
</div> </div>

View file

@ -5,7 +5,7 @@
<div class="row"> <div class="row">
<div class="col-8 position-relative main_pane"> <div class="col-8 position-relative main_pane">
<h1>{{ _('Create post') }}</h1> <h1>{{ _('Create post') }}</h1>
<form method="post"> <form method="post" enctype="multipart/form-data">
{{ form.csrf_token() }} {{ form.csrf_token() }}
{{ render_field(form.communities) }} {{ render_field(form.communities) }}
<nav id="post_type_chooser"> <nav id="post_type_chooser">

View file

@ -4,11 +4,11 @@ import math
from urllib.parse import urlparse from urllib.parse import urlparse
import requests import requests
from functools import wraps from functools import wraps
import flask import flask
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
import requests import requests
import os import os
import imghdr
from flask import current_app, json, redirect, url_for from flask import current_app, json, redirect, url_for
from flask_login import current_user from flask_login import current_user
from sqlalchemy import text from sqlalchemy import text
@ -207,6 +207,15 @@ def retrieve_block_list():
return response.text return response.text
def validate_image(stream):
header = stream.read(512)
stream.seek(0)
format = imghdr.what(None, header)
if not format:
return None
return '.' + (format if format != 'jpeg' else 'jpg')
def validation_required(func): def validation_required(func):
@wraps(func) @wraps(func)
def decorated_view(*args, **kwargs): def decorated_view(*args, **kwargs):

View file

@ -0,0 +1,36 @@
"""image-thumbnails
Revision ID: ee45b9ea4a0c
Revises: 4a3ca1701711
Create Date: 2023-11-27 20:53:29.624833
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'ee45b9ea4a0c'
down_revision = '4a3ca1701711'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('file', schema=None) as batch_op:
batch_op.add_column(sa.Column('thumbnail_path', sa.String(length=255), nullable=True))
batch_op.add_column(sa.Column('thumbnail_width', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('thumbnail_height', sa.Integer(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('file', schema=None) as batch_op:
batch_op.drop_column('thumbnail_height')
batch_op.drop_column('thumbnail_width')
batch_op.drop_column('thumbnail_path')
# ### end Alembic commands ###

View file

@ -22,3 +22,5 @@ boto3==1.28.35
markdown2==2.4.10 markdown2==2.4.10
beautifulsoup4==4.12.2 beautifulsoup4==4.12.2
flask-caching==2.0.2 flask-caching==2.0.2
Pillow
pillow-heif