From 6f1889073996dd1a75fc00de174f48ad45a6789e Mon Sep 17 00:00:00 2001 From: j Date: Mon, 24 Jul 2023 12:05:45 +0100 Subject: [PATCH] signal backend, app cleanup --- .gitignore | 3 + app/brake_backend.py | 10 +++ app/celery.py | 16 ++++ ...ent_options_alter_comment_user_and_more.py | 47 +++++++++++ .../migrations/0004_comment_session_key.py | 17 ++++ app/item/models.py | 25 ++++-- app/item/tasks.py | 28 +++++++ app/item/utils.py | 8 ++ app/item/views.py | 68 +++++++++++++--- app/settings/__init__.py | 4 + app/{settings.py => settings/common.py} | 29 ++++++- app/settings/local.py.dist | 4 + app/signalbot/__init__.py | 0 app/signalbot/admin.py | 3 + app/signalbot/apps.py | 6 ++ app/signalbot/cli.py | 54 +++++++++++++ app/signalbot/daemon.py | 41 ++++++++++ .../management/commands/signal-daemon.py | 14 ++++ app/signalbot/migrations/__init__.py | 0 app/signalbot/models.py | 3 + app/signalbot/rpc.py | 54 +++++++++++++ app/signalbot/tasks.py | 2 + app/signalbot/tests.py | 3 + app/signalbot/views.py | 3 + app/static/css/comments.scss | 11 +++ app/static/js/VideoPlayer.js | 4 +- app/static/js/comments.js | 63 +++++++++++++++ app/static/js/icons.js | 18 +++++ app/static/js/main.js | 4 +- app/templates/:u | 77 ------------------- app/templates/item.html | 14 +++- app/templates/item2.html | 16 ---- app/urls.py | 5 ++ app/user/admin.py | 4 +- app/user/views.py | 51 +++++++++++- bootstrap.sh | 4 + etc/systemd/system/phantasmobile-cron.service | 20 +++++ .../phantasmobile-signal-daemon.service | 15 ++++ .../system/phantasmobile-tasks.service | 19 +++++ etc/systemd/system/phantasmobile.service | 18 +++++ etc/tmpfiles.d/phantasmobile.conf | 1 + manage.py | 26 ++++++- requirements.txt | 7 ++ 43 files changed, 695 insertions(+), 124 deletions(-) create mode 100644 app/brake_backend.py create mode 100644 app/celery.py create mode 100644 app/item/migrations/0003_alter_comment_options_alter_comment_user_and_more.py create mode 100644 app/item/migrations/0004_comment_session_key.py create mode 100644 app/item/tasks.py create mode 100644 app/item/utils.py create mode 100644 app/settings/__init__.py rename app/{settings.py => settings/common.py} (80%) create mode 100644 app/settings/local.py.dist create mode 100644 app/signalbot/__init__.py create mode 100644 app/signalbot/admin.py create mode 100644 app/signalbot/apps.py create mode 100644 app/signalbot/cli.py create mode 100644 app/signalbot/daemon.py create mode 100644 app/signalbot/management/commands/signal-daemon.py create mode 100644 app/signalbot/migrations/__init__.py create mode 100644 app/signalbot/models.py create mode 100644 app/signalbot/rpc.py create mode 100644 app/signalbot/tasks.py create mode 100644 app/signalbot/tests.py create mode 100644 app/signalbot/views.py delete mode 100644 app/templates/:u delete mode 100644 app/templates/item2.html create mode 100755 bootstrap.sh create mode 100644 etc/systemd/system/phantasmobile-cron.service create mode 100644 etc/systemd/system/phantasmobile-signal-daemon.service create mode 100644 etc/systemd/system/phantasmobile-tasks.service create mode 100644 etc/systemd/system/phantasmobile.service create mode 100644 etc/tmpfiles.d/phantasmobile.conf 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 = `

@@ -11,6 +56,14 @@ function renderComments(cdiv, data) { ` const content = cdiv.querySelector('.comments-content') comments.forEach(comment => { + var extra = '' + if (!comment.published) { + if (user.is_moderator) { + extra += `` + } else { + extra += '(under review)' + } + } var c = document.createElement('div') c.className = 'comment' c.innerHTML = ` @@ -19,8 +72,18 @@ function renderComments(cdiv, data) { by ${comment.name} on ${comment.date} + ${extra} ` + c.querySelectorAll('button.publish-comment').forEach(button => { + button.title = "click to publish" + button.addEventListener("click", event => { + button.disabled = true + publish_comment(comment.id).then(response => { + button.remove() + }) + }) + }) content.append(c) }) var add = document.querySelector('.add-comment') diff --git a/app/static/js/icons.js b/app/static/js/icons.js index 295a12f..5011610 100644 --- a/app/static/js/icons.js +++ b/app/static/js/icons.js @@ -123,6 +123,15 @@ icon.pause = ` ` icon.loading = ` + + + + + + +` + +icon.loading_w = ` @@ -180,3 +189,12 @@ icon.down = ` ` + +icon.publishComment = ` + + + + + + +` diff --git a/app/static/js/main.js b/app/static/js/main.js index a9b368b..6cd27ef 100644 --- a/app/static/js/main.js +++ b/app/static/js/main.js @@ -86,8 +86,8 @@ function render() { var loadingScreen = `
${icon.loading}
diff --git a/app/templates/:u b/app/templates/:u deleted file mode 100644 index 4558b40..0000000 --- a/app/templates/:u +++ /dev/null @@ -1,77 +0,0 @@ -{% extends "base.html" %} -{% block head %} - - -{% endblock %} -{% block content %} - - -
- -
-
- {% for comment in item.public_comments %} -
-
{{ comment.name }}
-
{{ comment.date }}
-
{{ comment.text }}
-
- {% endfor %} -
-
- {% if request.user.is_anonymous %} - - -
- {% endif %} - {% csrf_token %} - - -
-{% endblock %} -{% block end %} - - - - - - - - - - - - -{% endblock %} diff --git a/app/templates/item.html b/app/templates/item.html index 19033b0..4c305a9 100644 --- a/app/templates/item.html +++ b/app/templates/item.html @@ -8,12 +8,11 @@ {% endblock %} {% block end %} {% compress js file m %} diff --git a/app/templates/item2.html b/app/templates/item2.html deleted file mode 100644 index f293282..0000000 --- a/app/templates/item2.html +++ /dev/null @@ -1,16 +0,0 @@ - - - -
- - - - - - - - - - - - diff --git a/app/urls.py b/app/urls.py index 5f1f4eb..ee205dc 100644 --- a/app/urls.py +++ b/app/urls.py @@ -18,10 +18,15 @@ from django.contrib import admin from django.urls import path, re_path from .item import views as item_views +from .user import views as user_views urlpatterns = [ path("admin/", admin.site.urls), + path('login/', user_views.login, name='login'), + path('logout/', user_views.logout, name='logout'), + path('register/', user_views.register, name='register'), path('archive/', item_views.archive, name='archive'), + path('comment/publish/', item_views.publish_comment, name='publish-comment'), path('comment/', item_views.comment, name='comment'), path('/', item_views.item, name='item'), path('', item_views.index, name='index'), diff --git a/app/user/admin.py b/app/user/admin.py index 8c38f3f..f91be8f 100644 --- a/app/user/admin.py +++ b/app/user/admin.py @@ -1,3 +1,5 @@ from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from .models import User -# Register your models here. +admin.site.register(User, UserAdmin) diff --git a/app/user/views.py b/app/user/views.py index 91ea44a..ececcf3 100644 --- a/app/user/views.py +++ b/app/user/views.py @@ -1,3 +1,50 @@ -from django.shortcuts import render +import json -# Create your views here. +from django.shortcuts import render +from django.shortcuts import redirect +import django.contrib.auth +from django.contrib.auth import get_user_model + +from ..item.utils import render_to_json + +from brake.decorators import ratelimit + +User = get_user_model() + + +@ratelimit(method="POST", block=True, rate="1/m") +def register(request): + response = {} + data = json.loads(request.body) + if User.objects.filter(username__iexact=data['username']).exists(): + response['error'] = 'username not allowed' + elif User.objects.filter(email__iexact=data['email']).exists(): + response['error'] = 'username not allowed' + elif not data['password']: + response['error'] = 'password too simple' + if not response: + user = User(username=data['username'], email=data['email'].lower()) + user.set_password(data['password']) + user.is_active = True + user.save() + user = django.contrib.auth.authenticate(username=data['username'], password=data['password']) + django.contrib.auth.login(request, user) + response['user'] = user.username + return render_to_json(response) + + +@ratelimit(method="POST", block=True, rate="1/m") +def login(request): + response = {} + data = json.loads(request.body) + user = django.contrib.auth.authenticate(username=data['username'], password=data['password']) + if user is not None and user.is_active: + django.contrib.auth.login(request, user) + response['user'] = user.username + return render_to_json(response) + + +def logout(request): + if request.user.is_authenticated: + django.contrib.auth.logout(request) + redirect('/') diff --git a/bootstrap.sh b/bootstrap.sh new file mode 100755 index 0000000..f623159 --- /dev/null +++ b/bootstrap.sh @@ -0,0 +1,4 @@ +#!/bin/sh +python3 -m venv venv +./venv/bin/pip install -U pip +./venv/bin/pip install -r requirements.txt diff --git a/etc/systemd/system/phantasmobile-cron.service b/etc/systemd/system/phantasmobile-cron.service new file mode 100644 index 0000000..cdde5f1 --- /dev/null +++ b/etc/systemd/system/phantasmobile-cron.service @@ -0,0 +1,20 @@ +[Unit] +Description=phantasmobile cron +After=phantasmobile-tasks.service + +[Service] +Type=simple +Restart=always +User=phantasmobile +Group=phantasmobile +PIDFile=/run/phantasmobile/phantasmobile-cron.pid +WorkingDirectory=/srv/phantasmobile +ExecStart=/srv/phantasmobile/venv/bin/celery \ + -A app beat -l info \ + --scheduler django_celery_beat.schedulers:DatabaseScheduler \ + --pidfile /run/phantasmobile/phantasmobile-cron.pid +ExecReload=/bin/kill -HUP $MAINPID + +[Install] +WantedBy=multi-user.target + diff --git a/etc/systemd/system/phantasmobile-signal-daemon.service b/etc/systemd/system/phantasmobile-signal-daemon.service new file mode 100644 index 0000000..e972b57 --- /dev/null +++ b/etc/systemd/system/phantasmobile-signal-daemon.service @@ -0,0 +1,15 @@ +[Unit] +Description=phantasmobile signal-daemon +After=postgresql.service + +[Service] +Type=simple +Restart=always +User=phantasmobile +Group=phantasmobile +WorkingDirectory=/srv/phantasmobile +ExecStart=/srv/phantasmobile/manage.py signal-daemon +ExecReload=/bin/kill -HUP $MAINPID + +[Install] +WantedBy=multi-user.target diff --git a/etc/systemd/system/phantasmobile-tasks.service b/etc/systemd/system/phantasmobile-tasks.service new file mode 100644 index 0000000..f851b2a --- /dev/null +++ b/etc/systemd/system/phantasmobile-tasks.service @@ -0,0 +1,19 @@ +[Unit] +Description=phantasmobile tasks +After=postgresql.service + +[Service] +Type=simple +Restart=always +User=phantasmobile +Group=phantasmobile +PIDFile=/run/phantasmobile/phantasmobile-tasks.pid +WorkingDirectory=/srv/phantasmobile +ExecStart=/srv/phantasmobile/venv/bin/celery \ + -A app worker -l info \ + -Q default -c 4 \ + --pidfile /run/phantasmobile/phantasmobile-tasks.pid +ExecReload=/bin/kill -HUP $MAINPID + +[Install] +WantedBy=multi-user.target diff --git a/etc/systemd/system/phantasmobile.service b/etc/systemd/system/phantasmobile.service new file mode 100644 index 0000000..f6e4265 --- /dev/null +++ b/etc/systemd/system/phantasmobile.service @@ -0,0 +1,18 @@ +[Unit] +Description=phantasmobile daemon +After=postgresql.service + +[Service] +Type=simple +Restart=always +User=phantasmobile +Group=phantasmobile +WorkingDirectory=/srv/phantasmobile +ExecReload=/bin/kill -HUP $MAINPID +ExecStart=/srv/phantasmobile/venv/bin/gunicorn \ + app.wsgi:application \ + -c app/gunicorn_config.py \ + -p /run/phantasmobile/phantasmobile.pid + +[Install] +WantedBy=multi-user.target diff --git a/etc/tmpfiles.d/phantasmobile.conf b/etc/tmpfiles.d/phantasmobile.conf new file mode 100644 index 0000000..9cea7e6 --- /dev/null +++ b/etc/tmpfiles.d/phantasmobile.conf @@ -0,0 +1 @@ +d /run/phantasmobile 0755 phantasmobile phantasmobile - diff --git a/manage.py b/manage.py index 1a64b14..b3be7d6 100755 --- a/manage.py +++ b/manage.py @@ -1,11 +1,35 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 """Django's command-line utility for administrative tasks.""" import os import sys +def activate_venv(base): + if os.path.exists(base): + old_os_path = os.environ.get("PATH", "") + os.environ["PATH"] = os.path.join(base, "bin") + os.pathsep + old_os_path + version = "%s.%s" % (sys.version_info.major, sys.version_info.minor) + site_packages = os.path.join(base, "lib", "python%s" % version, "site-packages") + prev_sys_path = list(sys.path) + import site + + site.addsitedir(site_packages) + sys.real_prefix = sys.prefix + sys.prefix = base + # Move the added items to the front of the path: + new_sys_path = [] + for item in list(sys.path): + if item not in prev_sys_path: + new_sys_path.append(item) + sys.path.remove(item) + sys.path[:0] = new_sys_path + + def main(): """Run administrative tasks.""" + root_dir = os.path.normpath(os.path.abspath(os.path.dirname(__file__))) + activate_venv(os.path.normpath(os.path.join(root_dir, "venv"))) + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") try: from django.core.management import execute_from_command_line diff --git a/requirements.txt b/requirements.txt index 80ebda8..92a9dbb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,14 @@ django +Pillow libsass django-compressor django-sass-processor requests lxml cssselect +gunicorn +celery +django-celery-results +django-celery-beat +redis +django-brake