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.
.idea/
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_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('/<actor>/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/<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.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 += '/'

View file

@ -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):

View file

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

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

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

View file

@ -1,6 +1,6 @@
<div class="row">
{% if post.image_id %}
<div class="col-8">
{% if post.type == POST_TYPE_IMAGE %}
<div class="col post_type_image">
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
<ol class="breadcrumb">
<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) }}
{% if post.edited_at %} edited {{ moment(post.edited_at).fromNow() }}{% endif %}
</small></p>
</div>
<div class="col-4">
{% if post.url %}
<a href="post.url" rel="nofollow ugc"><img src="{{ post.image.source_url }}" alt="{{ post.image.alt_text }}"
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 class="post_image">
<a href="{{ post.image.view_url() }}"><img src="{{ post.image.view_url() }}" alt="{{ post.image.alt_text }}"
width="{{ post.image.width }}" height="{{ post.image.height }}" /></a>
</div>
</div>
{% else %}
<div class="col">
@ -54,6 +49,12 @@
{{ render_username(post.author) }}
{% if post.edited_at %} edited {{ moment(post.edited_at).fromNow() }}{% endif %}
</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>
{% endif %}
</div>

View file

@ -4,9 +4,10 @@
<div class="row main_row">
<div class="col{% if post.image_id %}-8{% endif %}">
<h3>
<a href="{{ url_for('community.show_post', post_id=post.id) }}">{{ post.title }}</a>
{% if post.type == post_type_link and post.domain_id %}
<a href="{{ post.url }}" rel="nofollow ugc">
<a href="{{ url_for('community.show_post', post_id=post.id) }}">{{ post.title }}
{% if post.type == POST_TYPE_IMAGE %}<span class="fe fe-camera"> </span>{% endif %}</a>
{% 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" />
</a>
<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>
</div>
{% if post.image_id %}
<div class="col-4">
<img src="{{ post.image.source_url}}" alt="{{ post.image.alt_text }}"
width="100" />
<div class="col-4 text-right">
<a href="{{ url_for('community.show_post', post_id=post.id) }}"><img src="{{ post.image.thumbnail_url() }}" alt="{{ post.image.alt_text }}"
width="100" /></a>
</div>
{% endif %}
</div>

View file

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

View file

@ -4,11 +4,11 @@ import math
from urllib.parse import urlparse
import requests
from functools import wraps
import flask
from bs4 import BeautifulSoup
import requests
import os
import imghdr
from flask import current_app, json, redirect, url_for
from flask_login import current_user
from sqlalchemy import text
@ -207,6 +207,15 @@ def retrieve_block_list():
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):
@wraps(func)
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
beautifulsoup4==4.12.2
flask-caching==2.0.2
Pillow
pillow-heif