diff --git a/main/models.py b/main/models.py index 08ac115..7e24d61 100644 --- a/main/models.py +++ b/main/models.py @@ -1,8 +1,12 @@ import os import uuid -from django.db import models +import magic +from django.db import models +from django import forms +from django.utils.text import normalize_newlines +from django.core.exceptions import ValidationError class Thought(models.Model): text = models.CharField(max_length=140) @@ -18,3 +22,63 @@ class Thought(models.Model): return "" else: return os.path.splitext(self.media.path)[1][1:] + +# Honestly I'm so sick of this problem that writing out a comment here to explain why it is necessary is beyond me. +# I'm calling this CharField and not MySpecialLineNormalizingCharField +# because I think this should be the default behavior and I don't understand why it's not. +class CharField(forms.CharField): + def to_python(self, value): + # I am assuming that value is always a string and normalize_newlines isn't going to error on any string input + return super().to_python(normalize_newlines(value)) + +# A dict mapping allowed mime types to file extensions +ALLOWED_MEDIA_TYPES = { + "image/png": "png", + "image/jpeg": "jpeg", + "audio/x-m4a": "m4a", + "audio/mp3": "mp3", + "video/mp4": "mp4", + "video/quicktime": "mov", +} + +class ThoughtForm(forms.ModelForm): + def clean_media(self): + f = self.cleaned_data["media"] + if not f: + return f + + # 16 MB file size limit. + if f.size > 2**24: + raise ValidationError("Content too long", code="max_size") + + chunk = next(f.chunks(chunk_size=2048)) + media_type = magic.from_buffer(chunk, mime="True") + + if media_type not in ALLOWED_MEDIA_TYPES: + raise ValidationError("Invalid media type", code="invalid") + + return self.cleaned_data["media"] + + class Meta: + model = Thought + exclude = ["posted"] + widgets = { + "text": forms.Textarea(attrs={"class": "thought", "rows": 1, "placeholder": "What are you thinking?"}), + "extended_text": forms.Textarea(attrs={"class": "thought", "rows": 1, "placeholder": "Anything else?"}), + "timezone_offset": forms.HiddenInput, + "media_alt": forms.Textarea(attrs={"placeholder": "Media alternate text, please", "rows": 1}) + } + field_classes = { + "text": CharField, + "extended_text": CharField, + "media_alt": CharField, + } + + error_messages = { + "text": { + # It's debatable whether this is actually a "nice" error message + # but I've grown fond of it + "required": "Need some text.", + "max_length": "Text must be at most 140 characters." + }, + } diff --git a/main/static/main/main.css b/main/static/main/main.css index 6477259..e4977cf 100644 --- a/main/static/main/main.css +++ b/main/static/main/main.css @@ -91,6 +91,7 @@ textarea, input[type="text"], input[type="password"] { border-bottom: 1px solid var(--accent-color); border-radius: 0; overflow-x: hidden; + display: block; line-height: 1.5; } @@ -193,4 +194,3 @@ a { font-weight: inherit; display: inline; } - diff --git a/main/static/main/post.css b/main/static/main/post.css index b69f110..6cf2aab 100644 --- a/main/static/main/post.css +++ b/main/static/main/post.css @@ -1,5 +1,4 @@ textarea.thought { - display: block; width: calc(100% - 60px); } @@ -19,3 +18,23 @@ textarea.thought::placeholder { cursor: pointer; font-family: Georgia, Libre Baskerville, serif; } + +.media-wrapper { + padding: 8px; + margin: 20px 0; + border-left: 2px solid var(--accent-color); +} + +.media-wrapper [name="media"] { + /* Tries to make the media upload button have a cursor. Doesn't work but I blame WebKit */ + cursor: pointer; +} + +.media-wrapper textarea { + margin-top: 20px; +} + +.error { + font-style: italic; + color: var(--accent-color); +} diff --git a/main/templates/whispermaphone/post.html b/main/templates/whispermaphone/post.html index 6bffc70..d4668b8 100644 --- a/main/templates/whispermaphone/post.html +++ b/main/templates/whispermaphone/post.html @@ -14,42 +14,43 @@ {% endblock %} {% block main %} -
- {% csrf_token %} - - - -
+ +
{{ form_error }}
+ + {% csrf_token %} + {{ form.text }} + {{ form.extended_text }} + + {{ form.timezone_offset }} + +
+ {{ form.media }} + {{ form.media_alt }} +
+ + + {{ form.submit }} +
+ + - - {% endblock %} {% block scripts %} {% endblock %} diff --git a/main/views.py b/main/views.py index 607d7f1..32a6d92 100644 --- a/main/views.py +++ b/main/views.py @@ -10,17 +10,7 @@ from django.utils import timezone from django.utils.crypto import constant_time_compare from whispermaphone import settings -from .models import Thought - -# A dict mapping allowed mime types to file extensions -ALLOWED_MEDIA_TYPES = { - "image/png": "png", - "image/jpeg": "jpeg", - "audio/x-m4a": "m4a", - "audio/mp3": "mp3", - "video/mp4": "mp4", - "video/quicktime": "mov", -} +from .models import Thought, ThoughtForm, ALLOWED_MEDIA_TYPES def check_authenticated(request): authenticated = False @@ -62,35 +52,33 @@ def post(request): return render(request, "whispermaphone/login.html", status=401) if request.method == "POST": - if len(request.POST["text"].strip()) == 0: - return HttpResponse("Need some text.", status=400) + thought_form = ThoughtForm(request.POST, request.FILES, instance=Thought()) + if not thought_form.is_valid(): + errors = thought_form.errors.as_data(); + # Media formatting errors + try: + problem_field = list(errors.keys())[0] + message = list(errors.values())[0][0].messages[0] + # error_line = f"{problem_field[0].upper()}{problem_field[1:]}: {message}" + error_line = f"{message}" + except: + error_line = f"An unknown error occured processing your request: {errors}" - text = request.POST["text"].replace("\r\n", "\n") - if len(text) > 140: - return HttpResponse("Content too long", status=400) + return render(request, "whispermaphone/post.html", { + "form": thought_form, + "form_error": error_line + }, status=400) - thought = Thought( - text=text, - extended_text=request.POST["extended_text"].replace("\r\n", "\n"), - timezone_offset=request.POST["timezone_offset"] - ) + # Create a thought object we can work with + # But don't save it the DB yet + thought = thought_form.save(commit=False) + # Do media processing (already validated) if "media" in request.FILES: - f = request.FILES["media"] - - # 16 MB file size limit. - if f.size > 2**24: - return HttpResponse("Content too long", status=400) - - chunk = next(f.chunks(chunk_size=2048)) + chunk = next(thought.media.chunks(chunk_size=2048)) media_type = magic.from_buffer(chunk, mime="True") - if media_type not in ALLOWED_MEDIA_TYPES: - return HttpResponse("Invalid media type", status=400) - - f.name = f"{thought.uuid}.{ALLOWED_MEDIA_TYPES[media_type]}" - - thought.media = request.FILES["media"] + thought.media.name = f"{thought.uuid}.{ALLOWED_MEDIA_TYPES[media_type]}" if media_type == "audio/x-m4a": # This is a hack-fix because I want to be able to upload audio @@ -106,9 +94,10 @@ def post(request): os.remove(os.path.join(settings.MEDIA_ROOT, f"{thought.uuid}.m4a")) # Remove the original file thought.media.name = f"{thought.uuid}.aac" # Update the file in the DB + # Save for real thought.save() - return render(request, "whispermaphone/post.html", {}) + return render(request, "whispermaphone/post.html", {"form": ThoughtForm()}) def about(request):