diff --git a/.gitignore b/.gitignore index 367533a..728554b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ venv *.swp __pycache__ db.sqlite3 +www/ +secret.txt +app/settings/local.py diff --git a/app/brake_backend.py b/app/brake_backend.py new file mode 100644 index 0000000..a6dd88d --- /dev/null +++ b/app/brake_backend.py @@ -0,0 +1,10 @@ +from brake.backends import cachebe + + +class BrakeBackend(cachebe.CacheBackend): + def get_ip(self, request): + ip = request.META.get("HTTP_X_FORWARDED_FOR", request.META.get("REMOTE_ADDR")) + if ip: + ip = ip.split(", ")[-1] + return ip + diff --git a/app/celery.py b/app/celery.py new file mode 100644 index 0000000..368d27a --- /dev/null +++ b/app/celery.py @@ -0,0 +1,16 @@ +import os + +from celery import Celery + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') + +app = Celery('app') + +# Using a string here means the worker doesn't have to serialize +# the configuration object to child processes. +# - namespace='CELERY' means all celery-related configuration keys +# should have a `CELERY_` prefix. +app.config_from_object('django.conf:settings', namespace='CELERY') + +# Load task modules from all registered Django app configs. +app.autodiscover_tasks() diff --git a/app/item/migrations/0003_alter_comment_options_alter_comment_user_and_more.py b/app/item/migrations/0003_alter_comment_options_alter_comment_user_and_more.py new file mode 100644 index 0000000..1166cde --- /dev/null +++ b/app/item/migrations/0003_alter_comment_options_alter_comment_user_and_more.py @@ -0,0 +1,47 @@ +# Generated by Django 4.2.3 on 2023-07-24 14:07 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("item", "0002_initial"), + ] + + operations = [ + migrations.AlterModelOptions( + name="comment", + options={ + "permissions": [ + ("can_post_comment", "Can post comments without moderation") + ] + }, + ), + migrations.AlterField( + model_name="comment", + name="user", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="comments", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="item", + name="announced", + field=models.DateTimeField(blank=True, default=None, null=True), + ), + migrations.AlterField( + model_name="item", + name="published", + field=models.DateTimeField( + blank=True, default=django.utils.timezone.now, null=True + ), + ), + ] diff --git a/app/item/migrations/0004_comment_session_key.py b/app/item/migrations/0004_comment_session_key.py new file mode 100644 index 0000000..781f549 --- /dev/null +++ b/app/item/migrations/0004_comment_session_key.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.3 on 2023-07-24 17:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("item", "0003_alter_comment_options_alter_comment_user_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="comment", + name="session_key", + field=models.CharField(blank=True, default=None, max_length=60, null=True), + ), + ] diff --git a/app/item/models.py b/app/item/models.py index 243ed3b..0ce73a2 100644 --- a/app/item/models.py +++ b/app/item/models.py @@ -13,6 +13,7 @@ from django.urls import reverse User = get_user_model() + class Settings(models.Model): created = models.DateTimeField(auto_now_add=True) modified = models.DateTimeField(auto_now=True) @@ -37,6 +38,7 @@ class Item(models.Model): if self.url and not self.data: self.update_data() super().save(*args, **kwargs) + def __str__(self): return '%s (%s)' % (self.title, self.url) @@ -60,14 +62,15 @@ class Item(models.Model): cal = now.date().isocalendar() monday = now.date() - timedelta(days=now.date().isocalendar().weekday - 1) monday = timezone.datetime(monday.year, monday.month, monday.day, tzinfo=now.tzinfo) - print(now.tzinfo) first_post = qs.filter(published__gt=monday).first() - print('!!', first_post.published, now, first_post.published > now) - if first_post.published < now: - print('only this week') + if first_post and first_post.published < now: + week = qs.filter(published__gt=monday) + elif not first_post: + while qs.exists() and not first_post: + monday = monday - timedelta(days=7) + first_post = qs.filter(published__gt=monday).first() week = qs.filter(published__gt=monday) else: - print('only last week') last_monday = monday - timedelta(days=7) week = qs.filter(published__gt=last_monday) archive = qs.exclude(id__in=week) @@ -102,6 +105,7 @@ class Comment(models.Model): item = models.ForeignKey(Item, related_name='comments', on_delete=models.CASCADE) user = models.ForeignKey(User, null=True, related_name='comments', on_delete=models.CASCADE, blank=True) + session_key = models.CharField(max_length=60, null=True, default=None, blank=True) name = models.CharField(max_length=1024) email = models.CharField(max_length=1024) @@ -109,6 +113,11 @@ class Comment(models.Model): data = models.JSONField(default=dict, editable=False) published = models.DateTimeField(null=True, default=None) + class Meta: + permissions = [ + ("can_post_comment", "Can post comments without moderation") + ] + @property def is_published(self): return bool(self.published) @@ -128,7 +137,11 @@ class Comment(models.Model): def json(self): data = {} - data['name'] = self.name + if not self.user: + data['name'] = '%s (guest)' % self.name + else: + data['name'] = self.name data['date'] = self.date data['text'] = self.text return data + diff --git a/app/item/tasks.py b/app/item/tasks.py new file mode 100644 index 0000000..3b063f8 --- /dev/null +++ b/app/item/tasks.py @@ -0,0 +1,28 @@ +from celery.schedules import crontab +from django.conf import settings + +from ..signalbot import rpc +from ..celery import app + +from . import models + +@app.task(queue="default") +def announce_items(): + pass + + +@app.task(queue="default") +def notify_moderators(id, link): + comment = models.Comment.objects.filter(id=id).first() + if comment: + message = "%s commnented on %s\n\n%s" % (comment.name, link, comment.text) + r = rpc.send(message, group=settings.SIGNAL_MODERATORS_GROUP) + if r and "timestamp" in r: + comment.data["moderator_ts"] = r["timestamp"] + comment.save() + else: + print("failed to notify", r) + +@app.on_after_finalize.connect +def setup_periodic_tasks(sender, **kwargs): + sender.add_periodic_task(crontab(minute="*/2"), announce_items.s()) diff --git a/app/item/utils.py b/app/item/utils.py new file mode 100644 index 0000000..fc89cfd --- /dev/null +++ b/app/item/utils.py @@ -0,0 +1,8 @@ +import json +from django.http import Http404, HttpResponse + + +def render_to_json(response): + content = json.dumps(response) + return HttpResponse(content, 'application/json; charset=utf-8') + diff --git a/app/item/views.py b/app/item/views.py index 7a28ef9..b2df7f9 100644 --- a/app/item/views.py +++ b/app/item/views.py @@ -1,7 +1,16 @@ from datetime import date, datetime, timedelta +import json + +from django.utils import timezone from django.shortcuts import render +from django.db.models import Q +from django.utils.html import mark_safe +from django.conf import settings from . import models +from . import tasks +from ..signalbot.rpc import send_reaction +from .utils import render_to_json def index(request): @@ -19,33 +28,74 @@ def archive(request): context['items'] = archive return render(request, 'archive.html', context) + def item(request, id): context = {} item = models.Item.objects.get(id=id) context['item'] = item + qs = item.comments.order_by('created') + if not request.user.is_staff: + q = ~Q(published=None) + if request.user.is_authenticated: + q |= Q(user=request.user) + if request.session and request.session.session_key: + q |= Q(session_key=request.session.session_key) + qs = qs.filter(q) + comments = [] + for comment in qs: + comments.append({ + "id": comment.id, + "name": comment.name, + "date": comment.date, + "text": comment.text, + "published": comment.is_published, + }) + context['comments'] = mark_safe(json.dumps(comments)) + user = {} + if request.user.is_staff: + user['is_moderator'] = True + if request.user.is_authenticated: + user['username'] = request.user.username + + context['user'] = mark_safe(json.dumps(user)) + request.session['item'] = id return render(request, 'item.html', context) -import json -from django.http import Http404, HttpResponse - -def render_to_json(response): - content = json.dumps(response) - return HttpResponse(content, 'application/json; charset=utf-8') - def comment(request): response = {} data = json.loads(request.body) - print(data) comment = models.Comment() comment.item = models.Item.objects.get(id=data['item']) if request.user.is_authenticated: comment.user = request.user - comment.published = datetime.now() + if comment.user.has_perm('app.item.can_post_comment'): + comment.published = timezone.now() else: comment.name = data['name'] comment.email = data['email'] + comment.session_key = request.session.session_key comment.text = data['text'] comment.save() + if not comment.published: + link = request.build_absolute_uri(comment.item.get_absolute_url()) + tasks.notify_moderators.delay(comment.id, link) response = comment.json() return render_to_json(response) + + +def publish_comment(request): + response = {} + data = json.loads(request.body) + if request.user.is_staff: + comment = models.Comment.objects.get(id=data['comment']) + comment.published = timezone.now() + comment.save() + if comment.data.get("moderator_ts"): + account = settings.SIGNAL_ACCOUNT + group = settings.SIGNAL_MODERATORS_GROUP + send_reaction(account, comment.data["moderator_ts"], "🎉", group=group) + response['status'] = 'ok' + else: + response['error'] = 'permission denied' + return render_to_json(response) diff --git a/app/settings/__init__.py b/app/settings/__init__.py new file mode 100644 index 0000000..76ba626 --- /dev/null +++ b/app/settings/__init__.py @@ -0,0 +1,4 @@ +try: + from .local import * +except ImportError: + from .common import * diff --git a/app/settings.py b/app/settings/common.py similarity index 80% rename from app/settings.py rename to app/settings/common.py index aade9f6..9e56ad1 100644 --- a/app/settings.py +++ b/app/settings/common.py @@ -13,14 +13,25 @@ https://docs.djangoproject.com/en/4.2/ref/settings/ from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. -BASE_DIR = Path(__file__).resolve().parent.parent +BASE_DIR = Path(__file__).resolve().parent.parent.parent # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = "django-insecure-()6&rdheil=iyz%36dl-fnb)a+*7*^cb%isz6x%fi+ong5#*zz" +SECRET_FILE = BASE_DIR / 'secret.txt' +try: + SECRET_KEY = open(SECRET_FILE).read().strip() +except IOError: + try: + from django.utils.crypto import get_random_string + chars = 'abcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*(-_=+)' + SECRET_KEY = get_random_string(50, chars) + secret = open(SECRET_FILE, 'w') + secret.write(SECRET_KEY) + secret.close() + except IOError: + raise Exception('Please create a %s file with random characters to generate your secret key!' % SECRET_FILE) # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -41,10 +52,13 @@ INSTALLED_APPS = [ 'compressor', 'sass_processor', + 'django_celery_results', + "brake", "app", "app.user", "app.item", + "app.signalbot", ] MIDDLEWARE = [ @@ -122,7 +136,7 @@ USE_TZ = True # https://docs.djangoproject.com/en/4.2/howto/static-files/ STATIC_URL = "static/" -STATIC_ROOT = "www/static" +STATIC_ROOT = "www/static/" STATICFILES_FINDERS = ( 'django.contrib.staticfiles.finders.FileSystemFinder', 'django.contrib.staticfiles.finders.AppDirectoriesFinder', @@ -137,3 +151,10 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" AUTH_USER_MODEL = 'user.User' + +CELERY_BROKER_URL = "redis://localhost:6379" +CELERY_RESULT_BACKEND = 'django-db' + +SIGNAL_MODERATORS = [] + +RATELIMIT_CACHE_BACKEND = "app.brake_backend.BrakeBackend" diff --git a/app/settings/local.py.dist b/app/settings/local.py.dist new file mode 100644 index 0000000..6e31d78 --- /dev/null +++ b/app/settings/local.py.dist @@ -0,0 +1,4 @@ +from .common import * + +SIGNAL_ACCOUNT = + diff --git a/app/signalbot/__init__.py b/app/signalbot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/signalbot/admin.py b/app/signalbot/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/app/signalbot/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/app/signalbot/apps.py b/app/signalbot/apps.py new file mode 100644 index 0000000..be3ddc8 --- /dev/null +++ b/app/signalbot/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SignalbotConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "app.signalbot" diff --git a/app/signalbot/cli.py b/app/signalbot/cli.py new file mode 100644 index 0000000..0d0dc97 --- /dev/null +++ b/app/signalbot/cli.py @@ -0,0 +1,54 @@ +import json +import subprocess + +from django.conf import settings + + +BASE_CMD = ['signal-cli', '-a', settings.SIGNAL_ACCOUNT, '--output=json'] + + +def send(msg, to=None, group=None): + cmd = BASE_CMD + [ + 'send', '--message-from-stdin' + ] + if group: + cmd += ['-g', group] + else: + cmd += [to] + r = subprocess.check_output(cmd, input=msg, encoding='utf-8') + response = [] + if r: + for row in r.strip().split('\n'): + response.append(json.loads(row)) + return response + + +def send_reaction(target_address, target_ts, emoji, to=None, group=None, remove=False): + cmd = BASE_CMD + [ + 'sendReaction', '-t', str(target_ts), '-e', emoji, + '-a', target_address + ] + if remove: + cmd += ['-r'] + if group: + cmd += ['-g', group] + else: + cmd += [to] + r = subprocess.check_output(cmd, encoding='utf-8') + response = [] + if r: + for row in r.strip().split('\n'): + response.append(json.loads(row)) + return response + + +def receive(timeout=1): + cmd = BASE_CMD + [ + 'receive', '--timeout', str(timeout), '--send-read-receipts' + ] + r = subprocess.check_output(cmd, encoding='utf-8') + response = [] + if r: + for row in r.strip().split('\n'): + response.append(json.loads(row)) + return response diff --git a/app/signalbot/daemon.py b/app/signalbot/daemon.py new file mode 100644 index 0000000..6d928d0 --- /dev/null +++ b/app/signalbot/daemon.py @@ -0,0 +1,41 @@ +import subprocess +import json +from django.conf import settings +from django.utils import timezone + +from . import cli +from . import rpc + + +def main(): + cmd = cli.BASE_CMD + [ + 'daemon', '--http', '--send-read-receipts' + ] + p = subprocess.Popen(cmd, stdout=subprocess.PIPE, bufsize=1, universal_newlines=True, start_new_session=True) + try: + for line in p.stdout: + try: + msg = json.loads(line) + except: + if settings.DEBUG: + print(">>", line) + continue + if settings.DEBUG: + print('==', msg) + source = msg.get('envelope', {}).get('sourceNumber') + reaction = msg.get('envelope', {}).get('dataMessage', {}).get('reaction', {}) + emoji = reaction.get('emoji') + target_author = reaction.get('targetAuthorNumber') + target_ts = reaction.get('targetSentTimestamp') + group = msg.get('envelope', {}).get('dataMessage', {}).get("groupInfo", {}).get("groupId") + if group == settings.SIGNAL_MODERATORS_GROUP: + if emoji == "👍" and target_author == settings.SIGNAL_ACCOUNT: + if source in settings.SIGNAL_MODERATORS: + from ..item import models + now = timezone.now() + models.Comment.objects.filter(data__moderator_ts=target_ts).update(published=now) + rpc.send_reaction(target_author, target_ts, "🎉", group=group) + else: + rpc.send("Ignoring your request, you are not a moderator", group=group) + except: + p.kill() diff --git a/app/signalbot/management/commands/signal-daemon.py b/app/signalbot/management/commands/signal-daemon.py new file mode 100644 index 0000000..1805d87 --- /dev/null +++ b/app/signalbot/management/commands/signal-daemon.py @@ -0,0 +1,14 @@ +from django.core.management.base import BaseCommand + +from ... import daemon + + +class Command(BaseCommand): + help = 'process incoming singal messages' + + def add_arguments(self, parser): + pass + + def handle(self, *args, **options): + daemon.main() + diff --git a/app/signalbot/migrations/__init__.py b/app/signalbot/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/signalbot/models.py b/app/signalbot/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/app/signalbot/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/app/signalbot/rpc.py b/app/signalbot/rpc.py new file mode 100644 index 0000000..f28ef6a --- /dev/null +++ b/app/signalbot/rpc.py @@ -0,0 +1,54 @@ +import json +import subprocess + +import requests + +from django.conf import settings + + +rpc_id = 1 + + +def api(method, params): + global rpc_id + rpc_id += 1 + url = "http://127.0.0.1:8080/api/v1/rpc" + msg = { + "jsonrpc": "2.0", + "id": str(rpc_id), + "method": method, + } + if params: + msg["params"] = params + if settings.DEBUG: + print("POST signal rpc:", msg) + response = requests.post(url, json=msg).json() + if "result" in response: + return response["result"] + else: + raise Exception("Error: %s", response) + + +def send(msg, to=None, group=None): + params = { + "message": msg + } + if group: + params["groupId"] = group + else: + params["recipient"] = to + return api("send", params) + + +def send_reaction(target_address, target_ts, emoji, to=None, group=None, remove=False): + params = { + "emoji": emoji, + "targetTimestamp": target_ts, + "targetAuthor": target_address, + } + if group: + params["groupId"] = group + else: + params["recipient"] = to + + return api("sendReaction", params) diff --git a/app/signalbot/tasks.py b/app/signalbot/tasks.py new file mode 100644 index 0000000..139597f --- /dev/null +++ b/app/signalbot/tasks.py @@ -0,0 +1,2 @@ + + diff --git a/app/signalbot/tests.py b/app/signalbot/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/app/signalbot/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/app/signalbot/views.py b/app/signalbot/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/app/signalbot/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/app/static/css/comments.scss b/app/static/css/comments.scss index 493ce13..d867f9d 100644 --- a/app/static/css/comments.scss +++ b/app/static/css/comments.scss @@ -88,3 +88,14 @@ .comments .meta { color: gray; } + +button.publish-comment { + border: 0; + margin: 0; + background: none; + cursor: pointer; + svg { + width: 16px; + height: 16px; + } +} diff --git a/app/static/js/VideoPlayer.js b/app/static/js/VideoPlayer.js index 829fc02..93e4a2f 100644 --- a/app/static/js/VideoPlayer.js +++ b/app/static/js/VideoPlayer.js @@ -71,8 +71,8 @@ window.VideoPlayer = function(options) { height: 64px; } .mx-controls .toggle .loading svg { - width: 32px; - height: 32px; + width: 64px; + height: 64px; } .mx-controls .controls .volume svg, .mx-controls .controls .fullscreen-btn svg { diff --git a/app/static/js/comments.js b/app/static/js/comments.js index dd15247..eef7a71 100644 --- a/app/static/js/comments.js +++ b/app/static/js/comments.js @@ -1,3 +1,48 @@ +async function publish_comment(id) { + var csrf + document.querySelector('.add-comment').querySelectorAll('input,textarea').forEach(input => { + if (input.name == 'csrfmiddlewaretoken') { + csrf = input.value.trim() + } + }) + var data = { + "comment": id + } + return fetch("/comment/publish/", { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrf + }, + body: JSON.stringify(data) + }).then(response => { + return response.json() + }) +} + +async function login(username, password) { + var csrf + document.querySelector('.add-comment').querySelectorAll('input,textarea').forEach(input => { + if (input.name == 'csrfmiddlewaretoken') { + csrf = input.value.trim() + } + }) + var data = { + "username": username, + "password": password, + } + return fetch("/login/", { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrf + }, + body: JSON.stringify(data) + }).then(response => { + return response.json() + }) +} + function renderComments(cdiv, data) { cdiv.innerHTML = `