From 6f2f28a3a752cc47d9dc96bda862ed67cd75c9af Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Thu, 1 Dec 2022 18:46:49 -0700 Subject: Image attachment uploads --- activities/admin.py | 5 + .../migrations/0003_postattachment_null_thumb.py | 54 ++++++ activities/models/post.py | 22 ++- activities/models/post_attachment.py | 43 ++++- activities/views/compose.py | 182 +++++++++++++++++++++ activities/views/posts.py | 140 +--------------- core/files.py | 24 +++ requirements.txt | 1 + static/css/style.css | 31 +++- takahe/urls.py | 11 +- templates/activities/_image_upload.html | 15 ++ templates/activities/_image_uploaded.html | 19 +++ templates/activities/_post.html | 2 +- templates/activities/compose.html | 18 +- templates/forms/_field.html | 2 +- users/views/settings/profile.py | 15 +- 16 files changed, 418 insertions(+), 166 deletions(-) create mode 100644 activities/migrations/0003_postattachment_null_thumb.py create mode 100644 activities/views/compose.py create mode 100644 core/files.py create mode 100644 templates/activities/_image_upload.html create mode 100644 templates/activities/_image_uploaded.html diff --git a/activities/admin.py b/activities/admin.py index c4875ca..fe7b52f 100644 --- a/activities/admin.py +++ b/activities/admin.py @@ -25,6 +25,11 @@ class HashtagAdmin(admin.ModelAdmin): instance.transition_perform("outdated") +@admin.register(PostAttachment) +class PostAttachmentAdmin(admin.ModelAdmin): + list_display = ["id", "post", "created"] + + class PostAttachmentInline(admin.StackedInline): model = PostAttachment extra = 0 diff --git a/activities/migrations/0003_postattachment_null_thumb.py b/activities/migrations/0003_postattachment_null_thumb.py new file mode 100644 index 0000000..f999150 --- /dev/null +++ b/activities/migrations/0003_postattachment_null_thumb.py @@ -0,0 +1,54 @@ +# Generated by Django 4.1.3 on 2022-12-01 23:42 + +import functools + +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + +import core.uploads + + +class Migration(migrations.Migration): + + dependencies = [ + ("activities", "0002_hashtag"), + ] + + operations = [ + migrations.AddField( + model_name="postattachment", + name="created", + field=models.DateTimeField( + auto_now_add=True, default=django.utils.timezone.now + ), + preserve_default=False, + ), + migrations.AddField( + model_name="postattachment", + name="thumbnail", + field=models.ImageField( + blank=True, + null=True, + upload_to=functools.partial( + core.uploads.upload_namer, *("attachment_thumbnails",), **{} + ), + ), + ), + migrations.AddField( + model_name="postattachment", + name="updated", + field=models.DateTimeField(auto_now=True), + ), + migrations.AlterField( + model_name="postattachment", + name="post", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="attachments", + to="activities.post", + ), + ), + ] diff --git a/activities/models/post.py b/activities/models/post.py index eecce04..d3365e7 100644 --- a/activities/models/post.py +++ b/activities/models/post.py @@ -1,5 +1,5 @@ import re -from typing import Dict, Iterable, Optional, Set +from typing import Dict, Iterable, List, Optional, Set import httpx import urlman @@ -312,7 +312,7 @@ class Post(StatorModel): """ return ( await Post.objects.select_related("author", "author__domain") - .prefetch_related("mentions", "mentions__domain") + .prefetch_related("mentions", "mentions__domain", "attachments") .aget(pk=self.pk) ) @@ -326,6 +326,7 @@ class Post(StatorModel): summary: Optional[str] = None, visibility: int = Visibilities.public, reply_to: Optional["Post"] = None, + attachments: Optional[List] = None, ) -> "Post": with transaction.atomic(): # Find mentions in this post @@ -353,6 +354,8 @@ class Post(StatorModel): post.object_uri = post.urls.object_uri post.url = post.absolute_object_uri() post.mentions.set(mentions) + if attachments: + post.attachments.set(attachments) post.save() return post @@ -361,6 +364,7 @@ class Post(StatorModel): content: str, summary: Optional[str] = None, visibility: int = Visibilities.public, + attachments: Optional[List] = None, ): with transaction.atomic(): # Strip all HTML and apply linebreaks filter @@ -371,6 +375,7 @@ class Post(StatorModel): self.edited = timezone.now() self.hashtags = Hashtag.hashtags_from_content(content) or None self.mentions.set(self.mentions_from_content(content, self.author)) + self.attachments.set(attachments or []) self.save() @classmethod @@ -421,6 +426,7 @@ class Post(StatorModel): "as:sensitive": self.sensitive, "url": self.absolute_object_uri(), "tag": [], + "attachment": [], } if self.summary: value["summary"] = self.summary @@ -438,11 +444,13 @@ class Post(StatorModel): } ) value["cc"].append(mention.actor_uri) - # Remove tag and cc if they're empty - if not value["cc"]: - del value["cc"] - if not value["tag"]: - del value["tag"] + # Attachments + for attachment in self.attachments.all(): + value["attachment"].append(attachment.to_ap()) + # Remove fields if they're empty + for field in ["cc", "tag", "attachment"]: + if not value[field]: + del value[field] return value def to_create_ap(self): diff --git a/activities/models/post_attachment.py b/activities/models/post_attachment.py index 6ccea08..7feaba5 100644 --- a/activities/models/post_attachment.py +++ b/activities/models/post_attachment.py @@ -27,15 +27,24 @@ class PostAttachment(StatorModel): "activities.post", on_delete=models.CASCADE, related_name="attachments", + blank=True, + null=True, ) state = StateField(graph=PostAttachmentStates) mimetype = models.CharField(max_length=200) - # File may not be populated if it's remote and not cached on our side yet + # Files may not be populated if it's remote and not cached on our side yet file = models.FileField( - upload_to=partial(upload_namer, "attachments"), null=True, blank=True + upload_to=partial(upload_namer, "attachments"), + null=True, + blank=True, + ) + thumbnail = models.ImageField( + upload_to=partial(upload_namer, "attachment_thumbnails"), + null=True, + blank=True, ) remote_url = models.CharField(max_length=500, null=True, blank=True) @@ -49,6 +58,9 @@ class PostAttachment(StatorModel): focal_y = models.IntegerField(null=True, blank=True) blurhash = models.TextField(null=True, blank=True) + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + def is_image(self): return self.mimetype in [ "image/apng", @@ -58,3 +70,30 @@ class PostAttachment(StatorModel): "image/png", "image/webp", ] + + def thumbnail_url(self): + if self.thumbnail: + return self.thumbnail.url + elif self.file: + return self.file.url + else: + return self.remote_url + + def full_url(self): + if self.file: + return self.file.url + else: + return self.remote_url + + ### ActivityPub ### + + def to_ap(self): + return { + "url": self.file.url, + "name": self.name, + "type": "Document", + "width": self.width, + "height": self.height, + "mediaType": self.mimetype, + "http://joinmastodon.org/ns#focalPoint": [0.5, 0.5], + } diff --git a/activities/views/compose.py b/activities/views/compose.py new file mode 100644 index 0000000..58dc7fc --- /dev/null +++ b/activities/views/compose.py @@ -0,0 +1,182 @@ +from django import forms +from django.core.exceptions import PermissionDenied +from django.shortcuts import get_object_or_404, redirect, render +from django.utils.decorators import method_decorator +from django.views.generic import FormView + +from activities.models import ( + Post, + PostAttachment, + PostAttachmentStates, + PostStates, + TimelineEvent, +) +from core.files import blurhash_image, resize_image +from core.html import html_to_plaintext +from core.models import Config +from users.decorators import identity_required + + +@method_decorator(identity_required, name="dispatch") +class Compose(FormView): + + template_name = "activities/compose.html" + + class form_class(forms.Form): + text = forms.CharField( + widget=forms.Textarea( + attrs={ + "placeholder": "What's on your mind?", + }, + ) + ) + visibility = forms.ChoiceField( + choices=[ + (Post.Visibilities.public, "Public"), + (Post.Visibilities.local_only, "Local Only"), + (Post.Visibilities.unlisted, "Unlisted"), + (Post.Visibilities.followers, "Followers & Mentioned Only"), + (Post.Visibilities.mentioned, "Mentioned Only"), + ], + ) + content_warning = forms.CharField( + required=False, + label=Config.lazy_system_value("content_warning_text"), + widget=forms.TextInput( + attrs={ + "placeholder": Config.lazy_system_value("content_warning_text"), + }, + ), + help_text="Optional - Post will be hidden behind this text until clicked", + ) + reply_to = forms.CharField(widget=forms.HiddenInput(), required=False) + + def clean_text(self): + text = self.cleaned_data.get("text") + if not text: + return text + length = len(text) + if length > Config.system.post_length: + raise forms.ValidationError( + f"Maximum post length is {Config.system.post_length} characters (you have {length})" + ) + return text + + def get_initial(self): + initial = super().get_initial() + if self.post_obj: + initial.update( + { + "reply_to": self.reply_to.pk if self.reply_to else "", + "visibility": self.post_obj.visibility, + "text": html_to_plaintext(self.post_obj.content), + "content_warning": self.post_obj.summary, + } + ) + else: + initial[ + "visibility" + ] = self.request.identity.config_identity.default_post_visibility + if self.reply_to: + initial["reply_to"] = self.reply_to.pk + if self.reply_to.visibility == Post.Visibilities.public: + initial["visibility"] = Post.Visibilities.unlisted + else: + initial["visibility"] = self.reply_to.visibility + initial["text"] = f"@{self.reply_to.author.handle} " + return initial + + def form_valid(self, form): + # Gather any attachment objects now, they're not in the form proper + attachments = [] + if "attachment" in self.request.POST: + attachments = PostAttachment.objects.filter( + pk__in=self.request.POST.getlist("attachment", []) + ) + # Dispatch based on edit or not + if self.post_obj: + self.post_obj.edit_local( + content=form.cleaned_data["text"], + summary=form.cleaned_data.get("content_warning"), + visibility=form.cleaned_data["visibility"], + attachments=attachments, + ) + self.post_obj.transition_perform(PostStates.edited) + else: + post = Post.create_local( + author=self.request.identity, + content=form.cleaned_data["text"], + summary=form.cleaned_data.get("content_warning"), + visibility=form.cleaned_data["visibility"], + reply_to=self.reply_to, + attachments=attachments, + ) + # Add their own timeline event for immediate visibility + TimelineEvent.add_post(self.request.identity, post) + return redirect("/") + + def dispatch(self, request, handle=None, post_id=None, *args, **kwargs): + self.post_obj = None + if handle and post_id: + # Make sure the request identity owns the post! + if handle != request.identity.handle: + raise PermissionDenied("Post author is not requestor") + + self.post_obj = get_object_or_404(request.identity.posts, pk=post_id) + + # Grab the reply-to post info now + self.reply_to = None + reply_to_id = request.POST.get("reply_to") or request.GET.get("reply_to") + if reply_to_id: + try: + self.reply_to = Post.objects.get(pk=reply_to_id) + except Post.DoesNotExist: + pass + # Keep going with normal rendering + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["reply_to"] = self.reply_to + if self.post_obj: + context["post"] = self.post_obj + return context + + +@method_decorator(identity_required, name="dispatch") +class ImageUpload(FormView): + """ + Handles image upload - returns a new input type hidden to embed in + the main form that references an orphaned PostAttachment + """ + + template_name = "activities/_image_upload.html" + + class form_class(forms.Form): + image = forms.ImageField() + description = forms.CharField(required=False) + + def form_valid(self, form): + # Make a PostAttachment + thumbnail_file = resize_image(form.cleaned_data["image"], size=(400, 225)) + attachment = PostAttachment.objects.create( + blurhash=blurhash_image(thumbnail_file), + mimetype=form.cleaned_data["image"].image.get_format_mimetype(), + width=form.cleaned_data["image"].image.width, + height=form.cleaned_data["image"].image.height, + name=form.cleaned_data.get("description"), + state=PostAttachmentStates.fetched, + ) + attachment.file.save( + form.cleaned_data["image"].name, + form.cleaned_data["image"], + ) + attachment.thumbnail.save( + form.cleaned_data["image"].name, + thumbnail_file, + ) + attachment.save() + # Return the response, with a hidden input plus a note + return render( + self.request, "activities/_image_uploaded.html", {"attachment": attachment} + ) diff --git a/activities/views/posts.py b/activities/views/posts.py index 6dbfc8e..8b0ff33 100644 --- a/activities/views/posts.py +++ b/activities/views/posts.py @@ -1,21 +1,12 @@ -from django import forms from django.core.exceptions import PermissionDenied from django.db import models from django.http import JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.utils.decorators import method_decorator -from django.views.generic import FormView, TemplateView, View +from django.views.generic import TemplateView, View -from activities.models import ( - Post, - PostInteraction, - PostInteractionStates, - PostStates, - TimelineEvent, -) -from core.html import html_to_plaintext +from activities.models import Post, PostInteraction, PostInteractionStates, PostStates from core.ld import canonicalise -from core.models import Config from users.decorators import identity_required from users.shortcuts import by_handle_or_404 @@ -188,130 +179,3 @@ class Delete(TemplateView): def post(self, request): self.post_obj.transition_perform(PostStates.deleted) return redirect("/") - - -@method_decorator(identity_required, name="dispatch") -class Compose(FormView): - - template_name = "activities/compose.html" - - class form_class(forms.Form): - id = forms.IntegerField( - required=False, - widget=forms.HiddenInput(), - ) - - text = forms.CharField( - widget=forms.Textarea( - attrs={ - "placeholder": "What's on your mind?", - }, - ) - ) - visibility = forms.ChoiceField( - choices=[ - (Post.Visibilities.public, "Public"), - (Post.Visibilities.local_only, "Local Only"), - (Post.Visibilities.unlisted, "Unlisted"), - (Post.Visibilities.followers, "Followers & Mentioned Only"), - (Post.Visibilities.mentioned, "Mentioned Only"), - ], - ) - content_warning = forms.CharField( - required=False, - label=Config.lazy_system_value("content_warning_text"), - widget=forms.TextInput( - attrs={ - "placeholder": Config.lazy_system_value("content_warning_text"), - }, - ), - help_text="Optional - Post will be hidden behind this text until clicked", - ) - reply_to = forms.CharField(widget=forms.HiddenInput(), required=False) - - def clean_text(self): - text = self.cleaned_data.get("text") - if not text: - return text - length = len(text) - if length > Config.system.post_length: - raise forms.ValidationError( - f"Maximum post length is {Config.system.post_length} characters (you have {length})" - ) - return text - - def get_initial(self): - initial = super().get_initial() - if self.post_obj: - initial.update( - { - "id": self.post_obj.id, - "reply_to": self.reply_to.pk if self.reply_to else "", - "visibility": self.post_obj.visibility, - "text": html_to_plaintext(self.post_obj.content), - "content_warning": self.post_obj.summary, - } - ) - else: - initial[ - "visibility" - ] = self.request.identity.config_identity.default_post_visibility - if self.reply_to: - initial["reply_to"] = self.reply_to.pk - if self.reply_to.visibility == Post.Visibilities.public: - initial["visibility"] = Post.Visibilities.unlisted - else: - initial["visibility"] = self.reply_to.visibility - initial["text"] = f"@{self.reply_to.author.handle} " - return initial - - def form_valid(self, form): - post_id = form.cleaned_data.get("id") - if post_id: - post = get_object_or_404(self.request.identity.posts, pk=post_id) - post.edit_local( - content=form.cleaned_data["text"], - summary=form.cleaned_data.get("content_warning"), - visibility=form.cleaned_data["visibility"], - ) - - # Should there be a timeline event for edits? - # E.g. "@user edited #123" - - post.transition_perform(PostStates.edited) - else: - post = Post.create_local( - author=self.request.identity, - content=form.cleaned_data["text"], - summary=form.cleaned_data.get("content_warning"), - visibility=form.cleaned_data["visibility"], - reply_to=self.reply_to, - ) - # Add their own timeline event for immediate visibility - TimelineEvent.add_post(self.request.identity, post) - return redirect("/") - - def dispatch(self, request, handle=None, post_id=None, *args, **kwargs): - self.post_obj = None - if handle and post_id: - # Make sure the request identity owns the post! - if handle != request.identity.handle: - raise PermissionDenied("Post author is not requestor") - - self.post_obj = get_object_or_404(request.identity.posts, pk=post_id) - - # Grab the reply-to post info now - self.reply_to = None - reply_to_id = request.POST.get("reply_to") or request.GET.get("reply_to") - if reply_to_id: - try: - self.reply_to = Post.objects.get(pk=reply_to_id) - except Post.DoesNotExist: - pass - # Keep going with normal rendering - return super().dispatch(request, *args, **kwargs) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["reply_to"] = self.reply_to - return context diff --git a/core/files.py b/core/files.py new file mode 100644 index 0000000..c4f4355 --- /dev/null +++ b/core/files.py @@ -0,0 +1,24 @@ +import io + +import blurhash +from django.core.files import File +from PIL import Image, ImageOps + + +def resize_image(image: File, *, size: tuple[int, int]) -> File: + """ + Resizes an image to fit insize the given size (cropping one dimension + to fit if needed) + """ + with Image.open(image) as img: + resized_image = ImageOps.fit(img, size) + new_image_bytes = io.BytesIO() + resized_image.save(new_image_bytes, format=img.format) + return File(new_image_bytes) + + +def blurhash_image(image) -> str: + """ + Returns the blurhash for an image + """ + return blurhash.encode(image, 4, 4) diff --git a/requirements.txt b/requirements.txt index bd32530..1fff2ca 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,3 +17,4 @@ sentry-sdk~=1.11.0 dj_database_url~=1.0.0 python-dotenv~=0.21.0 email-validator~=1.3.0 +blurhash-python~=1.1.3 diff --git a/static/css/style.css b/static/css/style.css index 9df9c59..637fc2f 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -586,6 +586,25 @@ form img.preview { align-self: center; } +form .uploaded-image { + margin: 0 0 10px 0; + overflow: hidden; +} + +form .uploaded-image img { + max-width: 200px; + max-height: 200px; + float: left; +} + +form .uploaded-image p { + margin-left: 220px; +} + +form .uploaded-image .buttons { + margin-left: 220px; +} + form .buttons { text-align: right; margin: -20px 0 15px 0; @@ -595,6 +614,15 @@ form p+.buttons { margin-top: 0; } +form .button.add-image { + margin: 10px 0 10px 0; +} + +form progress { + display: none; + width: 100%; +} + .right-column form .buttons { margin: 5px 10px 5px 0; } @@ -1062,7 +1090,8 @@ table.metadata td.name { cursor: pointer; } -.copied, .copied:hover { +.copied, +.copied:hover { color: var(--color-highlight); transition: 0.2s; } diff --git a/takahe/urls.py b/takahe/urls.py index dc3946f..9aa67a2 100644 --- a/takahe/urls.py +++ b/takahe/urls.py @@ -3,7 +3,7 @@ from django.contrib import admin as djadmin from django.urls import path, re_path from django.views.static import serve -from activities.views import explore, posts, search, timelines +from activities.views import compose, explore, posts, search, timelines from core import views as core from stator import views as stator from users.views import activitypub, admin, auth, follows, identity, settings @@ -120,14 +120,19 @@ urlpatterns = [ path("@/inbox/", activitypub.Inbox.as_view()), path("@/action/", identity.ActionIdentity.as_view()), # Posts - path("compose/", posts.Compose.as_view(), name="compose"), + path("compose/", compose.Compose.as_view(), name="compose"), + path( + "compose/image_upload/", + compose.ImageUpload.as_view(), + name="compose_image_upload", + ), path("@/posts//", posts.Individual.as_view()), path("@/posts//like/", posts.Like.as_view()), path("@/posts//unlike/", posts.Like.as_view(undo=True)), path("@/posts//boost/", posts.Boost.as_view()), path("@/posts//unboost/", posts.Boost.as_view(undo=True)), path("@/posts//delete/", posts.Delete.as_view()), - path("@/posts//edit/", posts.Compose.as_view()), + path("@/posts//edit/", compose.Compose.as_view()), # Authentication path("auth/login/", auth.Login.as_view(), name="login"), path("auth/logout/", auth.Logout.as_view(), name="logout"), diff --git a/templates/activities/_image_upload.html b/templates/activities/_image_upload.html new file mode 100644 index 0000000..974884a --- /dev/null +++ b/templates/activities/_image_upload.html @@ -0,0 +1,15 @@ +
+ {% csrf_token %} + {% include "forms/_field.html" with field=form.image %} + {% include "forms/_field.html" with field=form.description %} +
+ + +
+
diff --git a/templates/activities/_image_uploaded.html b/templates/activities/_image_uploaded.html new file mode 100644 index 0000000..ad03120 --- /dev/null +++ b/templates/activities/_image_uploaded.html @@ -0,0 +1,19 @@ +
+ + +

+ {{ attachment.name|default:"(no description)" }} +

+
+ Remove +
+
+{% if request.htmx %} + + Add Image + +{% endif %} diff --git a/templates/activities/_post.html b/templates/activities/_post.html index 1c3008b..2c84a02 100644 --- a/templates/activities/_post.html +++ b/templates/activities/_post.html @@ -73,7 +73,7 @@
{% for attachment in post.attachments.all %} {% if attachment.is_image %} - + {% endif %} {% endfor %}
diff --git a/templates/activities/compose.html b/templates/activities/compose.html index 4809177..7065863 100644 --- a/templates/activities/compose.html +++ b/templates/activities/compose.html @@ -17,8 +17,24 @@ {% include "forms/_field.html" with field=form.content_warning %} {% include "forms/_field.html" with field=form.visibility %} +
+ Images + {% if post %} + {% for attachment in post.attachments.all %} + {% include "activities/_image_uploaded.html" %} + {% endfor %} + {% endif %} + {% if not post or post.attachments.count < 4 %} + + Add Image + + {% endif %} +
- +
{% endblock %} diff --git a/templates/forms/_field.html b/templates/forms/_field.html index 99db819..41f59d8 100644 --- a/templates/forms/_field.html +++ b/templates/forms/_field.html @@ -17,7 +17,7 @@ {% endif %} {{ field }} - {% if field.field.widget.input_type == "file" %} + {% if field.field.widget.input_type == "file" and field.value%} {% endif %} diff --git a/users/views/settings/profile.py b/users/views/settings/profile.py index 288badd..518b916 100644 --- a/users/views/settings/profile.py +++ b/users/views/settings/profile.py @@ -1,12 +1,10 @@ -import io - from django import forms from django.core.files import File from django.shortcuts import redirect from django.utils.decorators import method_decorator from django.views.generic import FormView -from PIL import Image, ImageOps +from core.files import resize_image from users.decorators import identity_required @@ -51,13 +49,6 @@ class ProfilePage(FormView): "discoverable": identity.discoverable, } - def resize_image(self, image: File, *, size: tuple[int, int]) -> File: - with Image.open(image) as img: - resized_image = ImageOps.fit(img, size) - new_image_bytes = io.BytesIO() - resized_image.save(new_image_bytes, format=img.format) - return File(new_image_bytes) - def form_valid(self, form): # Update basic info identity = self.request.identity @@ -70,12 +61,12 @@ class ProfilePage(FormView): if isinstance(icon, File): identity.icon.save( icon.name, - self.resize_image(icon, size=(400, 400)), + resize_image(icon, size=(400, 400)), ) if isinstance(image, File): identity.image.save( image.name, - self.resize_image(image, size=(1500, 500)), + resize_image(image, size=(1500, 500)), ) identity.save() return redirect(".") -- cgit v1.2.3