From aa41643172a32dc81cf6d214289e1dedf998953d Mon Sep 17 00:00:00 2001 From: Bobby Date: Wed, 31 May 2023 18:00:28 -0400 Subject: Allow Anonymous Commenting --- blog/admin.py | 3 +- ...ymouscommentuser_alter_comment_user_and_more.py | 53 +++++++ blog/models.py | 25 ++++ blog/urls.py | 3 + blog/views.py | 119 ++++++++++++++- middleware/tz.py | 32 ++++ middleware/uuidmiddleware.py | 4 +- static/css/phone_compatibility.css | 10 ++ static/css/styles.css | 14 ++ templates/blog/post.html | 161 ++++++++++++++++++++- thatcomputerscientist/settings.py | 2 + thatcomputerscientist/templatetags/sha256.py | 9 ++ 12 files changed, 423 insertions(+), 12 deletions(-) create mode 100644 blog/migrations/0014_anonymouscommentuser_alter_comment_user_and_more.py create mode 100644 middleware/tz.py create mode 100644 thatcomputerscientist/templatetags/sha256.py diff --git a/blog/admin.py b/blog/admin.py index e81f0b56..dd35e8cb 100644 --- a/blog/admin.py +++ b/blog/admin.py @@ -1,9 +1,10 @@ from django.contrib import admin # Register your models here. -from .models import Category, Comment, Post, Tag +from .models import AnonymousCommentUser, Category, Comment, Post, Tag admin.site.register(Post) admin.site.register(Comment) admin.site.register(Category) admin.site.register(Tag) +admin.site.register(AnonymousCommentUser) diff --git a/blog/migrations/0014_anonymouscommentuser_alter_comment_user_and_more.py b/blog/migrations/0014_anonymouscommentuser_alter_comment_user_and_more.py new file mode 100644 index 00000000..f05252b8 --- /dev/null +++ b/blog/migrations/0014_anonymouscommentuser_alter_comment_user_and_more.py @@ -0,0 +1,53 @@ +# Generated by Django 4.1.4 on 2023-05-31 18:34 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("blog", "0013_post_views"), + ] + + operations = [ + migrations.CreateModel( + name="AnonymousCommentUser", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=32)), + ("email", models.CharField(max_length=32)), + ("token", models.CharField(max_length=128, unique=True)), + ("avatar", models.CharField(blank=True, max_length=128)), + ], + ), + migrations.AlterField( + model_name="comment", + name="user", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="comment", + name="anonymous_user", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="blog.anonymouscommentuser", + ), + ), + ] diff --git a/blog/models.py b/blog/models.py index 771a9561..e8c0eb10 100644 --- a/blog/models.py +++ b/blog/models.py @@ -1,3 +1,5 @@ +import hashlib + from django.conf import settings from django.db import models from django.utils.text import slugify @@ -58,6 +60,21 @@ class Post(models.Model): def __str__(self): return str(self.title) + +class AnonymousCommentUser(models.Model): + name = models.CharField(max_length=32) + email = models.CharField(max_length=32) + token = models.CharField(max_length=128, unique=True) + avatar = models.CharField(max_length=128, blank=True) + + @classmethod + def get_or_create(cls, email, token, avatar=''): + email_hash = hashlib.md5(email.encode('utf-8')).hexdigest() + token_hash = hashlib.sha256(token.encode('utf-8')).hexdigest() + return cls(email=email_hash, token=token_hash, avatar=avatar) + + def __str__(self): + return self.name class Comment(models.Model): post = models.ForeignKey( @@ -67,6 +84,14 @@ class Comment(models.Model): user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, + blank=True, + null=True, + ) + anonymous_user = models.ForeignKey( + 'AnonymousCommentUser', + on_delete=models.CASCADE, + blank=True, + null=True, ) body = models.TextField() created_at = models.DateTimeField(auto_now_add=True) diff --git a/blog/urls.py b/blog/urls.py index d07ef152..eda17e20 100644 --- a/blog/urls.py +++ b/blog/urls.py @@ -12,8 +12,11 @@ urlpatterns = [ path('weblog', views.articles, name='articles'), path('weblog/', views.post, name='post'), path('weblog//comment', views.comment, name='comment'), + path('weblog//anon_comment', views.anon_comment, name='anon_comment'), path('weblog//edit_comment', views.edit_comment, name='edit_comment'), + path('weblog//anon_edit_comment', views.anon_edit_comment, name='anon_edit_comment'), path('weblog//delete_comment/', views.delete_comment, name='delete_comment'), + path('weblog//anon_delete_comment/', views.anon_delete_comment, name='anon_delete_comment'), path('archives', views.archives, name='archives'), path('archives/', views.articles, name='archive_posts'), path('categories', views.categories, name='categories'), diff --git a/blog/views.py b/blog/views.py index 1c2b9ca2..c5d9db5e 100644 --- a/blog/views.py +++ b/blog/views.py @@ -1,5 +1,8 @@ +import hashlib import os +import random import re +import string from datetime import datetime from random import choice from string import ascii_letters, digits @@ -9,7 +12,7 @@ from django.contrib import messages from django.contrib.auth.models import User from django.core.cache import cache from django.core.paginator import Paginator -from django.http import Http404, HttpResponse +from django.http import Http404, HttpResponse, HttpResponseRedirect from django.shortcuts import redirect, render, reverse from dotenv import load_dotenv from haystack.query import SearchQuerySet @@ -23,11 +26,10 @@ from users.tokens import CaptchaTokenGenerator from .context_processors import (add_excerpt, add_num_comments, avatar_list, comment_processor, highlight_code_blocks, recent_posts) -from .models import Category, Comment, Post +from .models import AnonymousCommentUser, Category, Comment, Post load_dotenv() - def atoi(text): return int(text) if text.isdigit() else text @@ -156,9 +158,15 @@ def post(request, slug): tags = post.tags.all() comments = Comment.objects.filter(post=post) for comment in comments: - user_profile = UserProfile.objects.get(user=comment.user) - comment.avatar_url = user_profile.avatar_url - comment.processed_body = comment_processor(comment.body) + if comment.user: + user_profile = UserProfile.objects.get(user=comment.user) + comment.avatar_url = user_profile.avatar_url + comment.processed_body = comment_processor(comment.body) + + if comment.anonymous_user: + user_profile = comment.anonymous_user + comment.avatar_url = user_profile.avatar + comment.processed_body = comment_processor(comment.body) if post.is_public: # modify request.meta description (only text) and image @@ -195,6 +203,63 @@ def comment(request, slug): return redirect('blog:home') else: return redirect('blog:home') + +def anon_comment(request, slug): + if request.method == 'POST': + if request.user.is_authenticated: + # not allowed this is anonymous comment form + return redirect(reverse('blog:post', kwargs={'slug': slug})) + else: + anonymous_user = request.POST.get('anonymous-name') + anonymous_email = request.POST.get('anonymous-email') + anonymous_token, at = request.POST.get('anonymous-token'), request.POST.get('anonymous-token') + new_anonymous_token = request.POST.get('new-anonymous-token') + anonymous_comment = request.POST.get('anonymous-comment') + if not anonymous_user: + messages.error(request, 'Please enter a name!') + return redirect(reverse('blog:post', kwargs={'slug': slug})) + if not anonymous_comment: + messages.error(request, 'Please enter a comment!') + return redirect(reverse('blog:post', kwargs={'slug': slug})) + if not anonymous_email: + anonymous_email = ''.join(random.choice(string.ascii_lowercase) for i in range(10)) + '@anonymous.thatcomputerscientist.com' + if not anonymous_token: + anonymous_token = ''.join(random.choice(string.ascii_lowercase) for i in range(10)) + at = anonymous_token + + # generate a random avatar for the anonymous user + avatarlist = avatar_list() + for key in avatarlist: + avatarlist[key] = [re.sub(r'\.gif$', '', string) for string in avatarlist[key]] + avatarlist[key].sort(key=natural_keys) + avatarlist = {k: avatarlist[k] for k in sorted(avatarlist)} + avatar_dir = choice(list(avatarlist.keys())) + avatar_file = choice(avatarlist[avatar_dir]) + anonymous_avatar = avatar_dir + '/' + avatar_file + anonymous_token = hashlib.sha256(anonymous_token.encode('utf-8')).hexdigest() + try: + anonymous_user = AnonymousCommentUser.objects.get(name=anonymous_user, email=anonymous_email, token=anonymous_token) + except AnonymousCommentUser.DoesNotExist: + anonymous_user = AnonymousCommentUser.objects.create(name=anonymous_user, email=anonymous_email, token=anonymous_token, + avatar=anonymous_avatar) + if new_anonymous_token: + at = new_anonymous_token + new_anonymous_token = hashlib.sha256(new_anonymous_token.encode('utf-8')).hexdigest() + anonymous_user.token = new_anonymous_token + anonymous_user.save() + + comment = Comment.objects.create(anonymous_user=anonymous_user, post=Post.objects.get(slug=slug), body=anonymous_comment) + + # redirect to the post with the comment but set the anonymous user cookie + response = redirect(reverse('blog:post', kwargs={'slug': slug}) + '#comment-' + str(comment.id)) + response.set_cookie('anonymous_name', anonymous_user.name, max_age=60*60*24*365) + response.set_cookie('anonymous_email', anonymous_user.email, max_age=60*60*24*365) + response.set_cookie('anonymous_token', at, max_age=60*60*24*365) + + return response + + else: + return redirect('blog:home') def edit_comment(request, slug): if request.method == 'POST': @@ -215,6 +280,31 @@ def edit_comment(request, slug): return redirect('blog:home') else: return redirect('blog:home') + +def anon_edit_comment(request, slug): + if request.method == 'POST': + if request.user.is_authenticated: + # not allowed this is anonymous comment form + return redirect(reverse('blog:post', kwargs={'slug': slug})) + else: + anonymous_token = request.COOKIES.get('anonymous_token') + if not anonymous_token: + return HttpResponse('Unauthorized!', status=401) + try: + anonymous_token = hashlib.sha256(anonymous_token.encode('utf-8')).hexdigest() + comment = Comment.objects.get(id=request.POST.get('comment_id')) + if comment.anonymous_user.token == anonymous_token: + comment.body = request.POST.get('body') + comment.edited = True + comment.edited_at = datetime.now() + comment.save() + return redirect(reverse('blog:post', kwargs={'slug': slug}) + '#comment-' + str(comment.id)) + else: + return HttpResponse('Unauthorized!', status=401) + except Comment.DoesNotExist: + return HttpResponse('Comment not found!', status=404) + else: + return redirect('blog:home') def delete_comment(request, slug, comment_id): if request.user.is_authenticated: @@ -228,8 +318,23 @@ def delete_comment(request, slug, comment_id): except Comment.DoesNotExist: return HttpResponse('Comment not found!', status=404) else: - return redirect('blog:home') + return HttpResponseRedirect(request.META.get('HTTP_REFERER')) +def anon_delete_comment(request, slug, comment_id): + if request.user.is_authenticated: + # not allowed this is anonymous comment form + return HttpResponseRedirect(request.META.get('HTTP_REFERER')) + else: + anonymous_token = request.COOKIES.get('anonymous_token') + if not anonymous_token: + return HttpResponse('Unauthorized!', status=401) + anonymous_token = hashlib.sha256(anonymous_token.encode('utf-8')).hexdigest() + try: + comment = Comment.objects.get(id=comment_id, anonymous_user__token=anonymous_token) + comment.delete() + return redirect(reverse('blog:post', kwargs={'slug': slug}) + '#comments') + except Comment.DoesNotExist: + return HttpResponse('Comment not found!', status=404) def search(request): query = request.GET.get('q') diff --git a/middleware/tz.py b/middleware/tz.py new file mode 100644 index 00000000..4aa3087a --- /dev/null +++ b/middleware/tz.py @@ -0,0 +1,32 @@ +import requests + + +# make sure you add `TimezoneMiddleware` appropriately in settings.py +class TimezoneMiddleware(object): + """ + Middleware to properly handle the users timezone + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + # get the user's timezone from the cookie + user_timezone = request.COOKIES.get('user_timezone') + + if not user_timezone: + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + + if x_forwarded_for: + remote_ip = x_forwarded_for.split(',')[0] + else: + remote_ip = request.META.get('REMOTE_ADDR') + + geo_data = requests.get(f'http://ip-api.com/json/{remote_ip}').json() + user_timezone = geo_data['timezone'] + + if user_timezone: + response = self.get_response(request) + response.set_cookie('user_timezone', user_timezone, max_age=31536000) + return response + return self.get_response(request) diff --git a/middleware/uuidmiddleware.py b/middleware/uuidmiddleware.py index fc97cd58..bf920718 100644 --- a/middleware/uuidmiddleware.py +++ b/middleware/uuidmiddleware.py @@ -1,6 +1,8 @@ import uuid -from django.core.cache import cache + from django.conf import settings +from django.core.cache import cache + class UserUUIDMiddleware: # assign a uuid to the user if they don't have one diff --git a/static/css/phone_compatibility.css b/static/css/phone_compatibility.css index becbdf93..d973acbc 100644 --- a/static/css/phone_compatibility.css +++ b/static/css/phone_compatibility.css @@ -252,6 +252,16 @@ I am not sure yet. width: auto !important; float: none !important; } + + #anonymous-profile-info > div > label { + display: block; + } + + #anonymous-profile-info > div > input { + width: calc(100% - 20px); + display: block; + margin: 10px 0; + } } #ham { diff --git a/static/css/styles.css b/static/css/styles.css index 40f9e90a..7b0db28f 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -441,6 +441,20 @@ blockquote { z-index: 1; } +#anonymous-profile-info > div { + margin: 10px 0; +} + +#anonymous-profile-info > div > label { + width: 200px; + display: inline-block; +} + +#anonymous-profile-info > div > input { + width: 300px; + display: inline-block; +} + @media only screen and (min-width: 481px) { .post-body { line-height: 15px; diff --git a/templates/blog/post.html b/templates/blog/post.html index 4b60160d..0aac1ecd 100644 --- a/templates/blog/post.html +++ b/templates/blog/post.html @@ -1,6 +1,7 @@ {% extends 'blog/partials/base.html' %} {% block content %} {% load static %} {% load tz %} +{% load sha256 %}
Home Opinions @@ -62,7 +63,17 @@
- {{ comment.user.username }} on {{ comment.created_at | date:"M d, Y" }} + + {% if comment.user %} + {{ comment.user.username }} + {% else %} + {{ comment.anonymous_user.name }} + {% endif %} + on + {% timezone request.COOKIES.user_timezone %} + {{ comment.created_at | date:"M d, Y h:i A" }} + {% endtimezone %} + {% if comment.edited %} (Edited) {% endif %} @@ -72,6 +83,12 @@    Delete {% endif %} + {% if comment.anonymous_user.name and comment.anonymous_user.email and comment.anonymous_user.token and comment.anonymous_user.token == request.COOKIES.anonymous_token|sha256 %} +    + Edit +    + Delete + {% endif %}
{{ comment.processed_body|safe }} @@ -87,6 +104,17 @@
{% endif %} + {% if comment.anonymous_user.name and comment.anonymous_user.email and comment.anonymous_user.token and comment.anonymous_user.token == request.COOKIES.anonymous_token|sha256 %} + + {% endif %} @@ -104,7 +132,7 @@

Leave a Comment

{% csrf_token %} - +

Leave a Comment

-

You must be logged in to leave a comment.

+

You must be logged in to leave a comment. Or, you can leave an anonymous comment.

+
+ + {% endif %} {% endblock %} {% block scripts %} @@ -183,10 +316,32 @@ document.getElementById('edit-form-' + id).style.display = 'none'; } + function cd() { + // we will clear the user cookies + document.cookie = 'anonymous_name=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; + document.cookie = 'anonymous_email=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; + document.cookie = 'anonymous_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; + window.location.reload(); + } + function toggleTips() { var tips = document.getElementById('tips'); $('#tips').slideToggle('fast'); } + + function toggleGotchas() { + var gotchas = document.getElementById('gotchas'); + $('#gotchas').slideToggle('fast'); + } + + function toggleAnon() { + $('#ancmClick').hide(); + $('#anonymous-comment-form').show(); + }; + + {% if request.COOKIES.anonymous_name %} + toggleAnon(); + {% endif %} {% include 'blog/partials/mathjax.html' %} diff --git a/thatcomputerscientist/settings.py b/thatcomputerscientist/settings.py index 00b2b9b1..ce55f931 100644 --- a/thatcomputerscientist/settings.py +++ b/thatcomputerscientist/settings.py @@ -87,6 +87,7 @@ MIDDLEWARE = [ 'middleware.oldbrowsermiddleware.OldBrowserMiddleware', 'middleware.globalmetamiddleware.GlobalMetaMiddleware', 'middleware.uuidmiddleware.UserUUIDMiddleware', + 'middleware.tz.TimezoneMiddleware', ] CONFIGURED_SUBDOMAINS = { @@ -171,6 +172,7 @@ CACHES = { } } from django.core.cache import cache + # clear the cache for key in cache.keys('presence_*'): cache.delete(key) diff --git a/thatcomputerscientist/templatetags/sha256.py b/thatcomputerscientist/templatetags/sha256.py new file mode 100644 index 00000000..cb820ece --- /dev/null +++ b/thatcomputerscientist/templatetags/sha256.py @@ -0,0 +1,9 @@ +import hashlib + +from django import template + +register = template.Library() + +@register.filter(name='sha256') +def sha256(value): + return hashlib.sha256(value.encode('utf-8')).hexdigest() \ No newline at end of file -- cgit v1.2.3