--- /dev/null
+chat/settings_local.py
+*.dump
+.env
+.idea/
+*.mo
+__pycache__/
+.venv/
--- /dev/null
+# See https://pre-commit.com for more information
+# See https://pre-commit.com/hooks.html for more hooks
+repos:
+- repo: local
+ hooks:
+ - id: ruff-check
+ name: ruff check
+ description: "Run 'ruff check' for extremely fast Python linting"
+ entry: ruff check --force-exclude
+ language: python
+ types_or: [python, pyi, jupyter]
+ require_serial: true
+ - id: ruff-format
+ name: ruff format
+ description: "Run 'ruff format' for extremely fast Python formatting"
+ entry: ruff format --force-exclude
+ language: python
+ types_or: [python, pyi, jupyter]
+ require_serial: true
+ - id: beautysh
+ name: beautysh
+ description: |
+ A bash beautifier for the masses.
+ https://pypi.python.org/pypi/beautysh
+ entry: beautysh
+ language: python
+ types: [shell]
+ - id: jslint
+ name: jslint
+ description: "JSLint for the project js files"
+ entry: >
+ scripts/pyjslint.py -o for:true -o browser:true -o this:true
+ -g console -g WebSocket -g _
+ language: python
+ types: [javascript]
+ - id: sort_gitignore
+ name: sort_gitignore
+ description: "Deterministically sort gitignore"
+ language: unsupported
+ pass_filenames: false
+ entry: scripts/sort_gitignore.sh .gitignore
+ - id: makemessages
+ name: makemessages_all
+ description: "Make translation messages"
+ language: unsupported
+ pass_filenames: false
+ entry: ./manage.py makemessages --all --no-obsolete --pre-commit
+ - id: djlint-reformat-django
+ name: djLint formatting for Django
+ entry: djlint --reformat --profile=django
+ types_or: [ html ]
+ language: python
+ - id: djlint-django
+ name: djLint linting for Django
+ entry: djlint --profile=django
+ types_or: [html]
+ language: python
\ No newline at end of file
--- /dev/null
+from importlib import import_module
+
+from django.apps import AppConfig
+
+
+class ChannelConfig(AppConfig):
+ name = "channel"
+
+ def ready(self):
+ import_module("channel.signals")
--- /dev/null
+# Generated by Django 6.0.4 on 2026-04-24 11:37
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ initial = True
+
+ dependencies = []
+
+ operations = [
+ migrations.CreateModel(
+ name="Channel",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_ts", models.DateTimeField(auto_now_add=True)),
+ ("name", models.CharField(max_length=256, unique=True)),
+ ],
+ ),
+ migrations.CreateModel(
+ name="ChannelUser",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("added_ts", models.DateTimeField(auto_now_add=True)),
+ ],
+ ),
+ migrations.CreateModel(
+ name="PrivateMessage",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("posted_ts", models.DateTimeField(auto_now_add=True)),
+ ("edited_ts", models.DateTimeField(editable=False, null=True)),
+ ("text", models.TextField()),
+ ],
+ ),
+ migrations.CreateModel(
+ name="ChannelMessage",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("posted_ts", models.DateTimeField(auto_now_add=True)),
+ ("edited_ts", models.DateTimeField(editable=False, null=True)),
+ ("text", models.TextField()),
+ (
+ "channel",
+ models.ForeignKey(
+ editable=False,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="channel.channel",
+ ),
+ ),
+ ],
+ ),
+ ]
--- /dev/null
+# Generated by Django 6.0.4 on 2026-04-24 11:37
+
+import django.db.models.deletion
+import pgtrigger.compiler
+import pgtrigger.migrations
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ initial = True
+
+ dependencies = [
+ ("channel", "0001_initial"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="channelmessage",
+ name="user",
+ field=models.ForeignKey(
+ editable=False,
+ on_delete=django.db.models.deletion.CASCADE,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AddField(
+ model_name="channeluser",
+ name="channel",
+ field=models.ForeignKey(
+ editable=False,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="channel.channel",
+ ),
+ ),
+ migrations.AddField(
+ model_name="channeluser",
+ name="user",
+ field=models.ForeignKey(
+ editable=False,
+ on_delete=django.db.models.deletion.CASCADE,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AddField(
+ model_name="channel",
+ name="users",
+ field=models.ManyToManyField(
+ related_name="channels",
+ through="channel.ChannelUser",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AddField(
+ model_name="privatemessage",
+ name="recipient",
+ field=models.ForeignKey(
+ editable=False,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="pns_recieved",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AddField(
+ model_name="privatemessage",
+ name="sender",
+ field=models.ForeignKey(
+ editable=False,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="pms_sent",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ pgtrigger.migrations.AddTrigger(
+ model_name="channelmessage",
+ trigger=pgtrigger.compiler.Trigger(
+ name="chat_channel_delete",
+ sql=pgtrigger.compiler.UpsertTriggerSql(
+ func='\n PERFORM pg_notify(\n \'chat_channel\',\n \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n \'","obj":{"id":\' || OLD."id" || \',"user_id":\' || OLD."user_id" || \',"channel_id":\' || OLD."channel_id" || \'}}\'\n );\n RETURN NULL;\n ',
+ hash="dee2cb1106ec730d60f5af5665fee20ddc1db488",
+ operation="DELETE",
+ pgid="pgtrigger_chat_channel_delete_e3727",
+ table="channel_channelmessage",
+ when="AFTER",
+ ),
+ ),
+ ),
+ pgtrigger.migrations.AddTrigger(
+ model_name="channelmessage",
+ trigger=pgtrigger.compiler.Trigger(
+ name="chat_channel_insert_update",
+ sql=pgtrigger.compiler.UpsertTriggerSql(
+ func='\n PERFORM pg_notify(\n \'chat_channel\',\n \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n \'","obj":{"id":\' || NEW."id" || \',"user_id":\' || NEW."user_id" || \',"channel_id":\' || NEW."channel_id" || \'}}\'\n );\n RETURN NULL;\n ',
+ hash="24860285f07bfec230d8fe91e1e5f1e27dbf4ab5",
+ operation="INSERT OR UPDATE",
+ pgid="pgtrigger_chat_channel_insert_update_a5e85",
+ table="channel_channelmessage",
+ when="AFTER",
+ ),
+ ),
+ ),
+ pgtrigger.migrations.AddTrigger(
+ model_name="channelmessage",
+ trigger=pgtrigger.compiler.Trigger(
+ name="chat_channel_truncate",
+ sql=pgtrigger.compiler.UpsertTriggerSql(
+ func="\n PERFORM pg_notify(\n 'chat_channel',\n '{\"op\":\"' || TG_OP || '\",\"table\":\"' || TG_TABLE_NAME || '\"}'\n );\n RETURN NULL;\n ",
+ hash="2a95850c0c6614d80da1343422459dcf122d13b0",
+ level="STATEMENT",
+ operation="TRUNCATE",
+ pgid="pgtrigger_chat_channel_truncate_ab388",
+ table="channel_channelmessage",
+ when="AFTER",
+ ),
+ ),
+ ),
+ pgtrigger.migrations.AddTrigger(
+ model_name="channeluser",
+ trigger=pgtrigger.compiler.Trigger(
+ name="chat_channel_delete",
+ sql=pgtrigger.compiler.UpsertTriggerSql(
+ func='\n PERFORM pg_notify(\n \'chat_channel\',\n \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n \'","obj":{"id":\' || OLD."id" || \',"user_id":\' || OLD."user_id" || \',"channel_id":\' || OLD."channel_id" || \'}}\'\n );\n RETURN NULL;\n ',
+ hash="2fe0eac7d74107a4b22294668b0a01c0fd4b9c5b",
+ operation="DELETE",
+ pgid="pgtrigger_chat_channel_delete_d1dad",
+ table="channel_channeluser",
+ when="AFTER",
+ ),
+ ),
+ ),
+ pgtrigger.migrations.AddTrigger(
+ model_name="channeluser",
+ trigger=pgtrigger.compiler.Trigger(
+ name="chat_channel_insert_update",
+ sql=pgtrigger.compiler.UpsertTriggerSql(
+ func='\n PERFORM pg_notify(\n \'chat_channel\',\n \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n \'","obj":{"id":\' || NEW."id" || \',"user_id":\' || NEW."user_id" || \',"channel_id":\' || NEW."channel_id" || \'}}\'\n );\n RETURN NULL;\n ',
+ hash="5122e638478843f6f34a2c7af368ee2f7734efdd",
+ operation="INSERT OR UPDATE",
+ pgid="pgtrigger_chat_channel_insert_update_4abda",
+ table="channel_channeluser",
+ when="AFTER",
+ ),
+ ),
+ ),
+ pgtrigger.migrations.AddTrigger(
+ model_name="channeluser",
+ trigger=pgtrigger.compiler.Trigger(
+ name="chat_channel_truncate",
+ sql=pgtrigger.compiler.UpsertTriggerSql(
+ func="\n PERFORM pg_notify(\n 'chat_channel',\n '{\"op\":\"' || TG_OP || '\",\"table\":\"' || TG_TABLE_NAME || '\"}'\n );\n RETURN NULL;\n ",
+ hash="38f70e9cefd5f5687f702110ced17fc7d875eb1b",
+ level="STATEMENT",
+ operation="TRUNCATE",
+ pgid="pgtrigger_chat_channel_truncate_be185",
+ table="channel_channeluser",
+ when="AFTER",
+ ),
+ ),
+ ),
+ pgtrigger.migrations.AddTrigger(
+ model_name="channel",
+ trigger=pgtrigger.compiler.Trigger(
+ name="chat_channel_delete",
+ sql=pgtrigger.compiler.UpsertTriggerSql(
+ func='\n PERFORM pg_notify(\n \'chat_channel\',\n \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n \'","obj":{"id":\' || OLD."id" || \',"name":\' || OLD."name" || \'}}\'\n );\n RETURN NULL;\n ',
+ hash="dd1c4a527a14046c19cd2d260336f61c954091bc",
+ operation="DELETE",
+ pgid="pgtrigger_chat_channel_delete_71f70",
+ table="channel_channel",
+ when="AFTER",
+ ),
+ ),
+ ),
+ pgtrigger.migrations.AddTrigger(
+ model_name="channel",
+ trigger=pgtrigger.compiler.Trigger(
+ name="chat_channel_insert_update",
+ sql=pgtrigger.compiler.UpsertTriggerSql(
+ func='\n PERFORM pg_notify(\n \'chat_channel\',\n \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n \'","obj":{"id":\' || NEW."id" || \',"name":\' || NEW."name" || \'}}\'\n );\n RETURN NULL;\n ',
+ hash="80f6d071b8557fbda0cc1bc2483b8876186fbe94",
+ operation="INSERT OR UPDATE",
+ pgid="pgtrigger_chat_channel_insert_update_0f4bc",
+ table="channel_channel",
+ when="AFTER",
+ ),
+ ),
+ ),
+ pgtrigger.migrations.AddTrigger(
+ model_name="channel",
+ trigger=pgtrigger.compiler.Trigger(
+ name="chat_channel_truncate",
+ sql=pgtrigger.compiler.UpsertTriggerSql(
+ func="\n PERFORM pg_notify(\n 'chat_channel',\n '{\"op\":\"' || TG_OP || '\",\"table\":\"' || TG_TABLE_NAME || '\"}'\n );\n RETURN NULL;\n ",
+ hash="570f8232ef884343064eb3240fdc36e61052297e",
+ level="STATEMENT",
+ operation="TRUNCATE",
+ pgid="pgtrigger_chat_channel_truncate_2f496",
+ table="channel_channel",
+ when="AFTER",
+ ),
+ ),
+ ),
+ pgtrigger.migrations.AddTrigger(
+ model_name="privatemessage",
+ trigger=pgtrigger.compiler.Trigger(
+ name="chat_channel_delete",
+ sql=pgtrigger.compiler.UpsertTriggerSql(
+ func='\n PERFORM pg_notify(\n \'chat_channel\',\n \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n \'","obj":{"id":\' || OLD."id" || \',"sender_id":\' || OLD."sender_id" || \',"recipient_id":\' || OLD."recipient_id" || \'}}\'\n );\n RETURN NULL;\n ',
+ hash="54f02ee6495116653d2d634032e42e3ac3d01712",
+ operation="DELETE",
+ pgid="pgtrigger_chat_channel_delete_5cdfa",
+ table="channel_privatemessage",
+ when="AFTER",
+ ),
+ ),
+ ),
+ pgtrigger.migrations.AddTrigger(
+ model_name="privatemessage",
+ trigger=pgtrigger.compiler.Trigger(
+ name="chat_channel_insert_update",
+ sql=pgtrigger.compiler.UpsertTriggerSql(
+ func='\n PERFORM pg_notify(\n \'chat_channel\',\n \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n \'","obj":{"id":\' || NEW."id" || \',"sender_id":\' || NEW."sender_id" || \',"recipient_id":\' || NEW."recipient_id" || \'}}\'\n );\n RETURN NULL;\n ',
+ hash="c49996d1cd9f313f657e55f87b3588d2b68ba81b",
+ operation="INSERT OR UPDATE",
+ pgid="pgtrigger_chat_channel_insert_update_4c85f",
+ table="channel_privatemessage",
+ when="AFTER",
+ ),
+ ),
+ ),
+ pgtrigger.migrations.AddTrigger(
+ model_name="privatemessage",
+ trigger=pgtrigger.compiler.Trigger(
+ name="chat_channel_truncate",
+ sql=pgtrigger.compiler.UpsertTriggerSql(
+ func="\n PERFORM pg_notify(\n 'chat_channel',\n '{\"op\":\"' || TG_OP || '\",\"table\":\"' || TG_TABLE_NAME || '\"}'\n );\n RETURN NULL;\n ",
+ hash="5adc21d8be79bfa8f0253ad28f0cef04b28c4752",
+ level="STATEMENT",
+ operation="TRUNCATE",
+ pgid="pgtrigger_chat_channel_truncate_d9c27",
+ table="channel_privatemessage",
+ when="AFTER",
+ ),
+ ),
+ ),
+ ]
--- /dev/null
+from django.db.models import (
+ CASCADE,
+ CharField,
+ DateTimeField,
+ ForeignKey,
+ ManyToManyField,
+ Model,
+ TextField,
+)
+
+from chat.triggers import chat_channel
+from user.models import User
+
+
+class PrivateMessage(Model):
+ posted_ts = DateTimeField(auto_now_add=True)
+ edited_ts = DateTimeField(null=True, editable=False)
+ sender = ForeignKey(
+ User, related_name="pms_sent", on_delete=CASCADE, editable=False
+ )
+ recipient = ForeignKey(
+ User, related_name="pns_recieved", on_delete=CASCADE, editable=False
+ )
+ text = TextField()
+
+ class Meta:
+ triggers = [*chat_channel(("id", "sender_id", "recipient_id"))]
+
+
+class Channel(Model):
+ created_ts = DateTimeField(auto_now_add=True)
+ name = CharField(max_length=256, unique=True)
+ users = ManyToManyField(User, related_name="channels", through="ChannelUser")
+
+ class Meta:
+ triggers = [*chat_channel(("id", "name"))]
+
+
+class ChannelUser(Model):
+ added_ts = DateTimeField(auto_now_add=True)
+ user = ForeignKey(User, on_delete=CASCADE, editable=False)
+ channel = ForeignKey(Channel, on_delete=CASCADE, editable=False)
+
+ class Meta:
+ triggers = [*chat_channel(("id", "user_id", "channel_id"))]
+
+
+class ChannelMessage(Model):
+ posted_ts = DateTimeField(auto_now_add=True)
+ edited_ts = DateTimeField(null=True, editable=False)
+ user = ForeignKey(User, on_delete=CASCADE, editable=False)
+ channel = ForeignKey(Channel, on_delete=CASCADE, editable=False)
+ text = TextField()
+
+ class Meta:
+ triggers = [*chat_channel(("id", "user_id", "channel_id"))]
--- /dev/null
+from django.db.models.signals import pre_save
+from django.dispatch import receiver
+from django.utils.timezone import now
+
+
+@receiver(pre_save)
+def update_edited_at_timestamp(signal, instance, **__):
+ if hasattr(instance, "edited_at") and instance.pk:
+ instance.edited_at = now()
--- /dev/null
+{% extends "chat/base.html" %}
+{% block main %}
+ <h1>{{ title }}</h1>
+ {% if messages %}
+ <ul class="messages">
+ {% for message in messages %}
+ <li class="{{ message.tags|default:'' }}">{{ message }}</li>
+ {% endfor %}
+ </ul>
+ {% endif %}
+ <form action="{% url 'user-logout' %}">
+ <button>Log Out</button>
+ </form>
+{% endblock main %}
--- /dev/null
+from django.urls import path
+
+from .views import ChannelMainView
+
+urlpatterns = [
+ path("", ChannelMainView.as_view(), name="channel-main"),
+]
--- /dev/null
+from django.views.generic import TemplateView
+
+
+class ChannelMainView(TemplateView):
+ template_name = "channel/main.html"
--- /dev/null
+from django.apps import AppConfig
+
+
+class ChatConfig(AppConfig):
+ name = "chat"
--- /dev/null
+"""
+ASGI config for chat project.
+
+It exposes the ASGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
+"""
+
+import os
+
+from django.core.asgi import get_asgi_application
+
+from .bridge import bridge
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "chat.settings")
+
+django_app = get_asgi_application()
+
+
+async def application(scope, receive, send):
+ if scope["type"] == "lifespan":
+ await bridge.listen(receive, send)
+ else:
+ await django_app(scope, receive, send)
--- /dev/null
+from asgiref.sync import async_to_sync
+from asyncio import get_running_loop, run_coroutine_threadsafe
+from functools import partial
+from inspect import iscoroutine, iscoroutinefunction
+from logging import getLogger
+
+from django.conf import settings
+
+logger = getLogger("django")
+
+
+class AsyncBridge:
+ def __init__(self):
+ self.running = True
+ self.loop = None
+
+ async def handle_startup(self, send):
+ try:
+ self.loop = get_running_loop()
+ await send({"type": "lifespan.startup.complete"})
+ except Exception as exc:
+ await send({"type": "lifespan.startup.failed", "message": str(exc)})
+
+ async def handle_shutdown(self, send):
+ await send({"type": "lifespan.shutdown.complete"})
+ self.running = False
+
+ async def listen(self, receive, send):
+ while self.running:
+ if callback := {
+ "lifespan.startup": self.handle_startup,
+ "lifespan.shutdown": self.handle_shutdown,
+ }.get((await receive())["type"]):
+ await callback(send)
+
+ @staticmethod
+ def _log_errors(future):
+ try:
+ future.result()
+ except Exception:
+ logger.exception("Background task failed:")
+
+ def call_async(self, func_or_coro, *args, **kwargs):
+ if iscoroutinefunction(func_or_coro):
+ func_or_coro = func_or_coro(*args, **kwargs)
+ if self.loop:
+ if iscoroutine(func_or_coro):
+ future = run_coroutine_threadsafe(func_or_coro, self.loop)
+ else:
+ future = self.loop.run_in_executor(
+ None, partial(func_or_coro, *args, **kwargs)
+ )
+ future.add_done_callback(self._log_errors)
+ elif settings.DEBUG:
+ if iscoroutine(func_or_coro):
+ async_to_sync(lambda: func_or_coro)()
+ else:
+ func_or_coro(*args, **kwargs)
+ else:
+ raise RuntimeError(
+ "AsyncBridge loop not initialized (Uvicorn not running)."
+ )
+ return None
+
+
+bridge = AsyncBridge()
--- /dev/null
+import os
+from datetime import date
+
+from django.core.management import BaseCommand
+from django.core.management.base import CommandError
+from django.db import connections
+from django.db.backends.base.base import BaseDatabaseWrapper
+from django.db.backends.postgresql.client import DatabaseClient
+
+
+class DumpClient(DatabaseClient):
+ executable_name = "pg_dump"
+
+ @classmethod
+ def settings_to_cmd_args_env(cls, settings_dict, parameters):
+ args, env = super().settings_to_cmd_args_env(settings_dict, parameters)
+ args.append("-Fc")
+ return args, env
+
+
+class RestoreClient(DatabaseClient):
+ executable_name = "pg_restore"
+
+ def settings_to_cmd_args_env(cls, settings_dict, parameters):
+ args, env = super().settings_to_cmd_args_env(settings_dict, parameters)
+ if args[-1] == settings_dict["NAME"]:
+ args.insert(-1, "-d")
+ args.append("-O")
+ return args, env
+
+
+class Command(BaseCommand):
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "action", nargs="?", default="dump", choices=("dump", "restore")
+ )
+ parser.add_argument("--stdout", action="store_true")
+ parser.add_argument("--filename")
+ parser.add_argument("--connection", default="default")
+
+ def generate_filename(self):
+ filename = f"backup-{date.today().strftime('%Y%m%d')}.dump"
+ if not os.path.exists(filename):
+ return filename
+ filename_base = filename[:-5]
+ i = 2
+ filename = f"{filename_base}_{i}.dump"
+ while os.path.exists(filename):
+ i += 1
+ filename = f"{filename_base}_{i}.dump"
+ return filename
+
+ def handle_dump(self, connection: BaseDatabaseWrapper, **options):
+ parameters = []
+ if not options["stdout"]:
+ if options["filename"]:
+ filename = options["filename"]
+ else:
+ filename = self.generate_filename()
+ parameters.extend(("--file", filename))
+ DumpClient(connection).runshell(parameters)
+
+ def handle_restore(self, connection: BaseDatabaseWrapper, **options):
+ filename = options["filename"]
+ if not filename:
+ raise CommandError("Error: filename required")
+ db_name = connection.settings_dict["NAME"]
+ db_user = connection.settings_dict["USER"]
+ try:
+ connection.settings_dict["NAME"] = "postgres"
+ connection.client.runshell(["-q", "-c", f'DROP DATABASE "{db_name}"'])
+ connection.client.runshell(
+ ["-q", "-c", f'CREATE DATABASE "{db_name}" OWNER "{db_user}"']
+ )
+ finally:
+ connection.settings_dict["NAME"] = db_name
+ RestoreClient(connection).runshell([filename])
+
+ def handle(self, **options):
+ {
+ "dump": self.handle_dump,
+ "restore": self.handle_restore,
+ }[options["action"]](connections[options["connection"]], **options)
--- /dev/null
+import re
+import sys
+from pathlib import Path
+from subprocess import DEVNULL, CalledProcessError, check_output
+
+from django.conf import settings
+from django.core.management.commands.makemessages import Command as MakeMessagesCommand
+
+CREATION_DATE_PATTERN = re.compile(
+ r'^("POT-Creation-Date: )[0-9]+(-[0-9]{2}){2} [0-9]{2}:[0-9]{2}\+[0-4]{4}\\n"\n$',
+ re.MULTILINE,
+)
+
+
+class Command(MakeMessagesCommand):
+ msgmerge_options = [*MakeMessagesCommand.msgmerge_options, "--no-fuzzy-matching"]
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.pre_commit = False
+ self.old_po_file_lines: dict[Path, list[str]] = {}
+
+ def add_arguments(self, parser):
+ super().add_arguments(parser)
+ parser.add_argument(
+ "--pre-commit",
+ action="store_true",
+ help="Prevents updating POT-Creation-Date to avoid VCS noise.",
+ )
+
+ def retain_old_po_file(self, po_file):
+ try:
+ self.old_po_file_lines[po_file] = check_output(
+ ["git", "show", f"HEAD:{po_file.relative_to(settings.BASE_DIR)}"],
+ stderr=DEVNULL,
+ text=True,
+ ).splitlines(keepends=True)
+ except CalledProcessError:
+ return
+
+ def write_po_file(self, potfile, locale):
+ if self.pre_commit:
+ self.retain_old_po_file(
+ Path(potfile).parent / locale / "LC_MESSAGES" / f"{self.domain}.po"
+ )
+ super().write_po_file(potfile, locale)
+
+ def restore_pot_creation_date_header(self, po_file, old_lines):
+ with po_file.open("rt") as fh:
+ new_lines = list(fh)
+ if old_lines == new_lines:
+ return
+ for old_line in old_lines:
+ if CREATION_DATE_PATTERN.fullmatch(old_line):
+ break
+ else:
+ return
+ for i, new_line in enumerate(new_lines):
+ if not CREATION_DATE_PATTERN.fullmatch(new_line):
+ continue
+ new_lines[i] = old_line
+ if new_lines == old_lines:
+ rel_path = po_file.relative_to(settings.BASE_DIR)
+ print(f'NOTE: undoing header change in "{rel_path}"', file=sys.stderr)
+ with po_file.open("wt") as fh:
+ fh.write("".join(old_lines))
+ break
+
+ def handle(self, **options):
+ self.pre_commit = options["pre_commit"]
+ super().handle(**options)
+ if self.pre_commit:
+ for po_file, old_lines in self.old_po_file_lines.items():
+ self.restore_pot_creation_date_header(po_file, old_lines)
--- /dev/null
+import uvicorn
+from django.core.management.base import BaseCommand
+
+
+class Command(BaseCommand):
+ help = "Runs the project with Uvicorn"
+
+ def add_arguments(self, parser):
+ parser.add_argument("--host", default="127.0.0.1")
+ parser.add_argument("--port", type=int, default=8000)
+ parser.add_argument("--reload", action="store_true", default=True)
+ parser.add_argument("--no-reload", dest="reload", action="store_false")
+
+ def handle(self, **options):
+ uvicorn.run(
+ "chat.asgi:application",
+ host=options["host"],
+ port=options["port"],
+ reload=options["reload"],
+ )
--- /dev/null
+import os
+from pathlib import Path
+
+BASE_DIR = Path(__file__).resolve().parents[1]
+SECRET_KEY = os.environ["SECRET_KEY"]
+DEBUG = bool(int(os.environ.get("DEBUG", "1")))
+ALLOWED_HOSTS = [os.environ.get("ALLOWED_HOST") or "localhost"]
+ADMINS = [os.environ["ADMIN_EMAIL"]]
+
+INSTALLED_APPS = [
+ "django.contrib.admin",
+ "django.contrib.auth",
+ "django.contrib.contenttypes",
+ "django.contrib.sessions",
+ "django.contrib.messages",
+ "django.contrib.staticfiles",
+ "pgtrigger",
+ "channel",
+ "chat",
+ "user",
+]
+MIDDLEWARE = [
+ "django.middleware.security.SecurityMiddleware",
+ "django.contrib.sessions.middleware.SessionMiddleware",
+ "django.middleware.common.CommonMiddleware",
+ "django.middleware.csrf.CsrfViewMiddleware",
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
+ "django.contrib.messages.middleware.MessageMiddleware",
+ "django.middleware.clickjacking.XFrameOptionsMiddleware",
+ "django.contrib.auth.middleware.LoginRequiredMiddleware",
+]
+
+ROOT_URLCONF = "chat.urls"
+
+TEMPLATES = [
+ {
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
+ "DIRS": ["chat/templates"],
+ "APP_DIRS": True,
+ "OPTIONS": {
+ "context_processors": [
+ "django.template.context_processors.request",
+ "django.contrib.auth.context_processors.auth",
+ "django.contrib.messages.context_processors.messages",
+ ],
+ },
+ },
+]
+
+ASGI_APPLICATION = "chat.asgi.application"
+
+DATABASES = {
+ "default": {
+ "ENGINE": "django.db.backends.postgresql",
+ "HOST": os.environ["PGHOST"],
+ "PORT": os.environ["PGPORT"],
+ "USER": os.environ["PGUSER"],
+ "PASSWORD": os.environ["PGPASS"],
+ "NAME": os.environ["PGDATABASE"],
+ }
+}
+
+AUTH_USER_MODEL = "user.User"
+AUTH_PASSWORD_VALIDATORS = [
+ {
+ "NAME": (
+ "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
+ ),
+ },
+ {
+ "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
+ },
+ {
+ "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
+ },
+ {
+ "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
+ },
+ {
+ "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
+ "OPTIONS": {"min_length": 9},
+ },
+]
+
+LANGUAGE_CODE = "en-us"
+TIME_ZONE = "UTC"
+USE_I18N = True
+USE_TZ = True
+LOCALE_PATHS = [BASE_DIR / "locales"]
+
+STATIC_URL = "static/"
+STATIC_ROOT = BASE_DIR / "static"
+MEDIA_URL = "media/"
+MEDIA_ROOT = BASE_DIR / "media"
+LOGIN_URL = "/user/login"
+
+DEFAULT_FROM_EMAIL = os.environ["DEFAULT_FROM_EMAIL"]
+EMAIL_HOST = os.environ.get("SMTP_HOST") or "localhost"
+EMAIL_PORT = int(os.environ.get("SMTP_PORT") or "25")
+EMAIL_HOST_USER = os.environ.get("SMTP_USER") or ""
+EMAIL_HOST_PASSWORD = os.environ.get("SMTP_PASSWORD") or ""
+EMAIL_USE_TLS = os.environ.get("SMTP_CONN") == "SMTP_SSL"
+
+LOGGING = {
+ "version": 1,
+ "disable_existing_loggers": False,
+ "handlers": {
+ "console": {
+ "class": "logging.StreamHandler",
+ },
+ },
+ "root": {
+ "handlers": ["console"],
+ "level": "INFO",
+ },
+}
+
+try:
+ from .settings_local import * # noqa: F403
+except ImportError:
+ pass
--- /dev/null
+(function () {
+ function foreach(obj, callback, start) {
+ var i;
+ var o;
+ var c;
+ if (!Array.isArray(obj)) {
+ o = Object.keys(obj);
+ c = function (key) {
+ return callback(key, obj[key]);
+ };
+ } else {
+ o = obj;
+ c = callback;
+ }
+ for (i = start || 0; i < o.length; i += 1) {
+ if (c(o[i]) === true) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ function left_pad(s, length, fill) {
+ var i;
+ if (typeof s !== "string") {
+ s = s.toString();
+ }
+ for (i = 0; s.length < length; i += 1) {
+ s = fill[i % fill.length] + s;
+ }
+ return s;
+ }
+
+ function intl_dateformat(date, locale, obj) {
+ return new Intl.DateTimeFormat(locale || "en-US", obj).format(date);
+ }
+
+ function get_timezone_offset(date) {
+ var tzo = date.getTimezoneOffset();
+ var sign;
+ if (tzo < 0) {
+ sign = "-";
+ tzo *= -1;
+ } else {
+ sign = "+";
+ }
+ return [
+ sign,
+ left_pad(Math.floor(tzo / 60), 2, "0"),
+ left_pad(Math.floor(tzo % 60), 2, "0")
+ ].join("");
+ }
+
+ function get_timezone(date, locale) {
+ var s = intl_dateformat(date, locale, {"timeZoneName": "short"});
+ var pos = s.indexOf(",");
+ if (pos !== -1) {
+ s = s.substring(pos + 1).trim();
+ }
+ return s;
+ }
+
+ window.chatutils = {
+ "foreach": foreach,
+ "select": function (selector) {
+ if (selector.substring(0, 1) === "#") {
+ return document.getElementById(selector.substring(1));
+ }
+ if (selector.substring(0, 1) === ".") {
+ return document.getElementsByClassName(selector.substring(1));
+ }
+ },
+ "strftime": function (date, s, locale) {
+ var pos;
+ var out = [];
+ while (true) {
+ pos = s.indexOf("%");
+ if (pos === -1) {
+ break;
+ }
+ out.push(s.substring(0, pos));
+ switch (s[pos + 1]) {
+ case "a":
+ out.push(
+ intl_dateformat(date, locale, {"weekday": "short"})
+ );
+ break;
+ case "A":
+ out.push(
+ intl_dateformat(date, locale, {"weekday": "long"})
+ );
+ break;
+ case "b":
+ out.push(intl_dateformat(date, locale, {"month": "short"}));
+ break;
+ case "B":
+ out.push(intl_dateformat(date, locale, {"month": "long"}));
+ break;
+ case "c":
+ out.push(date.toLocaleString(date));
+ break;
+ case "d":
+ out.push(left_pad(date.getDate(), 2, "0"));
+ break;
+ case "f":
+ out.push(left_pad(date.getMilliseconds(), 3, "0"));
+ break;
+ case "H":
+ out.push(left_pad(date.getHours(), 2, "0"));
+ break;
+ case "I":
+ if (date.getHours() % 12 === 0) {
+ out.push("12");
+ } else {
+ out.push(left_pad(date.getHours() % 12, 2, "0"));
+ }
+ break;
+ // missing: "j"
+ case "m":
+ out.push(left_pad(date.getMonth() + 1, 2, "0"));
+ break;
+ case "M":
+ out.push(left_pad(date.getMinutes(), 2, "0"));
+ break;
+ case "p":
+ if (date.getHours() < 12) {
+ out.push("AM");
+ } else {
+ out.push("PM");
+ }
+ break;
+ case "S":
+ out.push(left_pad(date.getSeconds(), 2, "0"));
+ break;
+ // missing: "U"
+ case "w":
+ out.push(date.getDay().toString());
+ break;
+ // missing: "W"
+ case "x":
+ out.push(date.toLocaleDateString());
+ break;
+ case "X":
+ out.push(date.toLocaleTimeString());
+ break;
+ case "y":
+ out.push(left_pad(date.getFullYear() % 100, 2, "0"));
+ break;
+ case "Y":
+ out.push(left_pad(date.getFullYear(), 4, "0"));
+ break;
+ case "z":
+ out.push(get_timezone_offset(date));
+ break;
+ case "Z":
+ out.push(get_timezone(date, locale));
+ break;
+ case "%":
+ out.push(s[pos + 1]);
+ break;
+ default:
+ out.push(s.substring(pos, pos + 2));
+ }
+ s = s.substring(pos + 2);
+ }
+ out.push(s);
+ return out.join("");
+ },
+ "xhr": function (method, url, callbacks, data) {
+ var request = new XMLHttpRequest();
+ if (!callbacks.hasOwnProperty("error")) {
+ request.addEventListener(
+ "error",
+ function (msg) {
+ console.log("xhr_error", msg);
+ }
+ );
+ }
+ if (!callbacks.hasOwnProperty("abort")) {
+ request.addEventListener(
+ "abort",
+ function (msg) {
+ console.log("xhr_abort", msg);
+ }
+ );
+ }
+ foreach(
+ callbacks,
+ function (event_name, callback) {
+ request.addEventListener(event_name, callback);
+ }
+ );
+ request.open(method, url);
+ if (
+ method.toLowerCase() === "put"
+ || method.toLowerCase() === "post"
+ ) {
+ request.send(data);
+ } else {
+ request.send();
+ }
+ return request;
+ }
+ };
+}());
--- /dev/null
+{% load static %}
+<!DOCTYPE html>
+<html lang="{% block lang %}en{% endblock lang %}">
+ <head>
+ {% block head %}
+ {% block meta %}
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <meta http-equiv="X-UA-Compatible" content="ie=edge">
+ <meta name="description" content="{{ meta_description }}">
+ <meta name="keywords" content="{{ meta_keywords }}">
+ {% endblock meta %}
+ {# <link rel="stylesheet" href="{% static 'chat/style.css' %}"> #}
+ <title>
+ {% block title %}
+ Title
+ {% endblock title %}
+ </title>
+ {% endblock head %}
+ </head>
+ <body>
+ {% block header %}
+ {% endblock header %}
+ <main>
+ {% block main %}
+ {% endblock main %}
+ </main>
+ {% block footer %}
+ {% endblock footer %}
+ </body>
+</html>
--- /dev/null
+from pgtrigger import After, Delete, Insert, Row, Statement, Trigger, Truncate, Update
+
+
+def fields_to_json(t, fields):
+ return ",".join(f'"{field}":\' || {t}."{field}" || \'' for field in fields)
+
+
+class TriggerChannel:
+ def __init__(self, name):
+ self.name = name
+
+ def __call__(self, fields):
+ yield Trigger(
+ name=f"{self.name}_delete",
+ level=Row,
+ operation=Delete,
+ when=After,
+ func=f"""
+ PERFORM pg_notify(
+ '{self.name}',
+ '{{"op":"' || TG_OP || '","table":"' || TG_TABLE_NAME ||
+ '","obj":{{{fields_to_json("OLD", fields)}}}}}'
+ );
+ RETURN NULL;
+ """,
+ )
+ yield Trigger(
+ name=f"{self.name}_insert_update",
+ level=Row,
+ operation=Insert | Update,
+ when=After,
+ func=f"""
+ PERFORM pg_notify(
+ '{self.name}',
+ '{{"op":"' || TG_OP || '","table":"' || TG_TABLE_NAME ||
+ '","obj":{{{fields_to_json("NEW", fields)}}}}}'
+ );
+ RETURN NULL;
+ """,
+ )
+ yield Trigger(
+ name=f"{self.name}_truncate",
+ level=Statement,
+ operation=Truncate,
+ when=After,
+ func=f"""
+ PERFORM pg_notify(
+ '{self.name}',
+ '{{"op":"' || TG_OP || '","table":"' || TG_TABLE_NAME || '"}}'
+ );
+ RETURN NULL;
+ """,
+ )
+
+
+chat_channel = TriggerChannel("chat_channel")
--- /dev/null
+from django.conf import settings
+from django.contrib import admin
+from django.contrib.auth.decorators import login_not_required
+from django.contrib.staticfiles.urls import static
+from django.urls import include, path
+from django.views.generic import RedirectView
+
+urlpatterns = [
+ path(
+ "",
+ login_not_required(
+ RedirectView.as_view(pattern_name="channel-main", permanent=True)
+ ),
+ name="root-redirect",
+ ),
+ path("user/", include("user.urls")),
+ path("channel/", include("channel.urls")),
+ path("admin/", admin.site.urls),
+]
+
+if settings.DEBUG:
+ urlpatterns.extend(static(settings.STATIC_URL))
--- /dev/null
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2026-04-22 11:54+0000\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+#: user/email.py:26
+msgid "Password reset request"
+msgstr ""
+
+#: user/email.py:28
+msgid ""
+"Hello\n"
+"\n"
+"Someone that is hopefully you, has requested a password request link:\n"
+"\n"
+msgstr ""
+
+#: user/email.py:47
+msgid "Chat Registration"
+msgstr ""
+
+#: user/email.py:48
+#, python-brace-format
+msgid ""
+"username: {username}\n"
+"message: {message}\n"
+"\n"
+"{url}"
+msgstr ""
+
+#: user/forms.py:15 user/forms.py:74
+msgid "Username (or email)"
+msgstr ""
+
+#: user/forms.py:84
+msgid "Message to admins"
+msgstr ""
+
+#: user/views.py:60
+msgid "Registration email submitted."
+msgstr ""
+
+#: user/views.py:76
+msgid "If this user exists, an email is currently being sent."
+msgstr ""
+
+#: user/views.py:122
+msgid "User was already created."
+msgstr ""
+
+#: user/views.py:125
+msgid "User was successfully created."
+msgstr ""
--- /dev/null
+#!/usr/bin/env python
+"""Django's command-line utility for administrative tasks."""
+
+import os
+import sys
+
+
+def main():
+ """Run administrative tasks."""
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "chat.settings")
+ try:
+ from django.core.management import execute_from_command_line
+ except ImportError as exc:
+ raise ImportError(
+ "Couldn't import Django. Are you sure it's installed and "
+ "available on your PYTHONPATH environment variable? Did you "
+ "forget to activate a virtual environment?"
+ ) from exc
+ execute_from_command_line(sys.argv)
+
+
+if __name__ == "__main__":
+ main()
--- /dev/null
+[tool.djlint]
+max_line_length = 88
--- /dev/null
+# postgresql upgrade process
+
+```
+# pacman -Syu
+# mv /var/lib/postgres/{data,olddata}
+# mkdir /var/lib/postgres/{tmp,data}
+# chown postgres:postgres /var/lib/postgres/{tmp,data}
+# cd /var/lib/postgres/tmp/
+# su postgres -s/bin/bash
+[postgres]$ initdb -D /var/lib/postgres/data --locale=C.UTF-8 --encoding=UTF8 --data-checksums
+[postgres]$ pg_upgrade -b /opt/pgsql-17/bin -B /usr/bin -d ../olddata/ -D ../data
+[postgres]$ rm -rf ../olddata
+exit
+# rc-service postgresql start
+# su postgres -s/bin/bash
+[postgres]$ vacuumdb --all --analyze-in-stages
+```
--- /dev/null
+#!/usr/bin/env bash
+
+. .env
+
+{
+ sed -r -e 's@#!/usr/bin/env python3@exec sudo -u http python3@' \
+ -e "$(( $(wc -l<deploy_remote.py) - 1 )),\$d" scripts/deploy_remote.py
+ printf 'DATA = """\n'
+ tar cz $(git ls-files) | base64
+ printf '"""\n\n\nif __name__ == "__main__":\n main()\n'
+} | ssh -C ${SSH_REMOTE} bash
--- /dev/null
+#!/usr/bin/env python3
+
+import os
+import shlex
+import site
+import sys
+from base64 import b64decode
+from io import BytesIO
+from pathlib import Path
+from shutil import rmtree
+from signal import SIGHUP
+from subprocess import check_call, check_output
+from tarfile import TarFile
+from time import sleep
+
+DATA = ""
+SKIP_PATHS = (Path(".env"), Path("media"), Path(".venv"), Path("static"))
+BASE_DIR = "/srv/http/freiheit-einfa.ch"
+DJANGO_ENV = {
+ "VIRTUAL_ENV": f"{BASE_DIR}/.venv",
+ "DJANGO_SETTINGS_MODULE": "chat.settings",
+}
+
+
+def process_env(filepath):
+ with open(filepath, "r") as f:
+ for line in f:
+ line = line.rstrip()
+ if not line or line.startswith("#"):
+ continue
+ try:
+ key, value = line.split("=", 1)
+ os.environ[key] = shlex.split(value)[0]
+ except (ValueError, IndexError):
+ continue
+
+
+def setup_django():
+ for key, value in DJANGO_ENV.items():
+ os.environ.setdefault(key, value)
+
+ site_packages = (
+ Path(os.environ["VIRTUAL_ENV"])
+ / "lib"
+ / f"python{sys.version_info.major}.{sys.version_info.minor}"
+ / "site-packages"
+ )
+ if os.path.exists(site_packages):
+ site.addsitedir(site_packages)
+ from django import setup
+
+ setup()
+ from django.core.management import call_command
+
+ call_command("collectstatic", "--noinput")
+ call_command("migrate", "--noinput")
+ call_command("compilemessages", "--ignore", ".venv")
+
+
+def get_child_pids(pid):
+ for pid_path in Path("/proc").iterdir():
+ try:
+ child_pid = int(pid_path.name)
+ content = (pid_path / "stat").read_text()
+ r_paren = content.rfind(")")
+ if r_paren >= 0 and int(content[r_paren + 1 :].split(maxsplit=2)[1]) == pid:
+ yield child_pid
+ except (
+ FileNotFoundError,
+ IndexError,
+ PermissionError,
+ ProcessLookupError,
+ ValueError,
+ ):
+ continue
+
+
+def restart_gunicorn():
+ main_pid = int(check_output(["systemctl", "show", "-P", "MainPID", "uvicorn"]))
+ if main_pid == 0:
+ print("Gunicorn is not running. Never mind, then.")
+ return
+ print(f"Sending SIGHUP to PID {main_pid}")
+ orig = set(get_child_pids(main_pid))
+ os.kill(main_pid, SIGHUP)
+ while orig & (children := set(get_child_pids(main_pid))) or len(children) < len(
+ orig
+ ):
+ sleep(0.3)
+ print("Gunicorn restarted.")
+
+
+def main():
+ os.chdir(BASE_DIR)
+ print("Wiping existing setup...")
+ for entry in Path().iterdir():
+ if entry in SKIP_PATHS:
+ continue
+ if entry.is_dir():
+ rmtree(entry)
+ else:
+ entry.unlink()
+ print("Extracting new tarball...")
+ with TarFile.open(mode="r|gz", fileobj=BytesIO(b64decode(DATA))) as tf:
+ tf.extractall()
+ sys.stdout.flush()
+ print("Setting up virtual env...")
+ check_call(["bash", "./setup_venv.sh"])
+ process_env("./.env")
+ print("Setting up django...")
+ setup_django()
+ restart_gunicorn()
+
+
+if __name__ == "__main__":
+ main()
--- /dev/null
+#!/usr/bin/env python
+
+# Copyright (C) 2011 Alejandro Blanco <ablanco@yaco.es>
+# Copyright (C) 2024 mar77i <mar77i@protonmail.ch>
+
+import json
+import sys
+from argparse import ArgumentParser
+from contextlib import contextmanager
+from pathlib import Path
+from subprocess import call
+from tempfile import NamedTemporaryFile
+from urllib.request import urlopen
+
+node_script = r"""
+var jslint = require({jslint}).default.jslint;
+var read = require("fs").readFileSync;
+var result;
+
+function print_warning(warning) {
+ if (warning === null) {
+ return;
+ }
+ if (warning.evidence !== undefined) {
+ console.log(warning.evidence);
+ }
+ console.log(warning.formatted_message);
+}
+
+console.log("Analyzing file " + process.argv[2]);
+result = jslint(read(process.argv[2], "utf8"), {jsoptions}, {jsglobals});
+result.warnings.forEach(print_warning);
+if (result.warnings.length > 0) {
+ console.error(result.warnings.length + " Error(s) found.");
+ process.exit(1);
+} else {
+ console.log("No errors found.");
+ process.exit(0);
+}
+"""
+
+
+def try_json_loads(value):
+ try:
+ return json.loads(value)
+ except json.JSONDecodeError:
+ return value
+
+
+@contextmanager
+def get_writable_tmpfile(mode="w", **kwargs):
+ tmp_dir = Path("/dev/shm")
+ if not tmp_dir.exists():
+ tmp_dir = None
+ kwargs.setdefault("dir", tmp_dir)
+ kwargs.setdefault("delete_on_close", False)
+ with NamedTemporaryFile(mode, **kwargs) as fh:
+ yield fh
+
+
+@contextmanager
+def get_launcher(jslint, jsoptions, jsglobals):
+ jsoptions_dict = {
+ key: try_json_loads(value)
+ for key, value in (item.split(":", 1) for item in jsoptions)
+ }
+ with get_writable_tmpfile() as launcher:
+ launcher.write(
+ node_script.replace("{jslint}", json.dumps(str(jslint.absolute())))
+ .replace("{jsoptions}", json.dumps(jsoptions_dict))
+ .replace("{jsglobals}", json.dumps(jsglobals))
+ )
+ launcher.close()
+ yield launcher.name
+
+
+@contextmanager
+def get_jslint_js(path: Path | None, upgrade) -> str:
+ if path is not None and not upgrade:
+ yield path
+ return
+ with get_writable_tmpfile("wb") if path is None else path.open("wb") as fh:
+ with urlopen(
+ (
+ "https://raw.githubusercontent.com/jslint-org/"
+ "jslint/refs/heads/beta/jslint.mjs"
+ )
+ ) as response:
+ fh.write(response.read())
+ fh.close()
+ yield Path(fh.name)
+
+
+def main():
+ parser = ArgumentParser(f"Usage: {sys.argv[0]}")
+ parser.add_argument(
+ "-u",
+ "--upgrade",
+ dest="upgrade",
+ help="Upgrade JSLint",
+ action="store_true",
+ default=False,
+ )
+ parser.add_argument(
+ "--jslint",
+ "-j",
+ help="JSLint location",
+ type=Path,
+ default=None, # Path(__file__).parent / "jslint.js",
+ )
+ parser.add_argument(
+ "-o",
+ "--option",
+ dest="jsoptions",
+ help="JSLint options",
+ default=["maxerr: 100"],
+ action="append",
+ )
+ parser.add_argument(
+ "-g",
+ "--global",
+ dest="jsglobals",
+ help="JSLint globals",
+ default=[],
+ action="append",
+ )
+ parser.add_argument(
+ "-n", "--node", dest="node", help="Node location", default="node"
+ )
+ parser.add_argument("files", nargs="+")
+
+ print("p", sys.argv, file=sys.stderr)
+ args = parser.parse_args()
+ print("q", args, file=sys.stderr)
+ returncode = 0
+ with (
+ get_jslint_js(args.jslint, args.upgrade) as jslint,
+ get_launcher(jslint, args.jsoptions, args.jsglobals) as lint_name,
+ ):
+ for file in args.files:
+ ret = call([args.node, lint_name, file])
+ if ret:
+ returncode = ret
+ return returncode
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
--- /dev/null
+#!/usr/bin/env bash
+
+sorted="$(sort -df "${1}")"
+if ! diff -q <(printf '%s\n' "${sorted}") "${1}"; then
+ printf '%s\n' "${sorted}" > "${1}"
+ exit 1
+fi
--- /dev/null
+#!/usr/bin/env bash
+
+export PYTHON="${PYTHON:-python}"
+
+"${PYTHON}" -m venv .venv
+. .venv/bin/activate
+if [[ -r .env ]]; then
+ set -a
+ . .env
+ set +a
+fi
+export CFLAGS="$(
+ python -c 'import sysconfig; print(sysconfig.get_config_var("CFLAGS"))'
+) -O2"
+"${PYTHON}" -m pip install -U --no-binary \
+ beautysh coverage django-pgtrigger django-stubs djlint gunicorn \
+ pip pre-commit 'psycopg[c]' ruff 'uvicorn[standard]'
--- /dev/null
+from django.apps import AppConfig
+
+
+class UserConfig(AppConfig):
+ name = "user"
--- /dev/null
+from base64 import urlsafe_b64encode
+
+from django.conf import settings
+from django.contrib.auth.tokens import default_token_generator
+from django.core.mail import EmailMultiAlternatives, send_mail, get_connection
+from django.urls import reverse
+from django.utils.encoding import force_bytes
+from django.utils.translation import gettext_lazy as _
+
+from .models import User
+
+
+def send_password_reset_email(request, username_or_email):
+ try:
+ user = User.objects.get_by_natural_key(username_or_email)
+ except User.DoesNotExist:
+ return
+ relative_url = reverse(
+ "user-reset-password",
+ query={
+ "token": default_token_generator.make_token(user),
+ "uidb64": urlsafe_b64encode(force_bytes(user.pk)).decode(),
+ },
+ )
+ send_mail(
+ _("Password reset request"),
+ _(
+ "Hello\n\n"
+ "Someone that is hopefully you, has requested a password request link:\n\n"
+ f"{request.build_absolute_uri(relative_url)}\n\n"
+ "yours,\n"
+ f"{settings.ALLOWED_HOSTS[0]}"
+ ),
+ None,
+ [user.email],
+ )
+
+
+def send_register_email(request, data):
+ url = request.build_absolute_uri(
+ reverse(
+ "user-create",
+ query={key: value for key, value in data.items() if key != "message"},
+ )
+ )
+ return EmailMultiAlternatives(
+ _("Chat Registration"),
+ _("username: {username}\nmessage: {message}\n\n{url}").format(**data, url=url),
+ None,
+ settings.ADMINS,
+ connection=get_connection(),
+ reply_to=[data["email"]],
+ ).send()
--- /dev/null
+from django.contrib.auth import authenticate
+from django.core.exceptions import ValidationError
+from django.forms import CharField, Form, ModelForm, PasswordInput, Textarea, TextInput
+from django.utils.text import capfirst
+from django.utils.translation import gettext_lazy as _
+from django.views.decorators.debug import sensitive_variables
+
+from .models import User
+
+django_gettext_lazy = _
+
+
+class AuthenticationForm(Form):
+ username = CharField(
+ label=_("Username (or email)"),
+ widget=TextInput(attrs={"autofocus": True}),
+ )
+ password = CharField(
+ label=django_gettext_lazy("Password"),
+ strip=False,
+ widget=PasswordInput(attrs={"autocomplete": "current-password"}),
+ )
+
+ error_messages = {
+ "invalid_login": django_gettext_lazy(
+ "Please enter a correct %(username)s and password. Note that both "
+ "fields may be case-sensitive."
+ ),
+ }
+
+ def __init__(self, request=None, *args, **kwargs):
+ """
+ The 'request' parameter is set for custom auth use by subclasses.
+ The form data comes in via the standard 'data' kwarg.
+ """
+ self.request = request
+ self.user_cache = None
+ super().__init__(*args, **kwargs)
+
+ # Set the max length and label for the "username" field.
+ self.username_field = User._meta.get_field(User.USERNAME_FIELD)
+ username_max_length = max(
+ self.username_field.max_length or 254,
+ User._meta.get_field(User.EMAIL_FIELD).max_length or 254,
+ )
+ self.fields["username"].max_length = username_max_length
+ self.fields["username"].widget.attrs["maxlength"] = username_max_length
+ if self.fields["username"].label is None:
+ self.fields["username"].label = capfirst(self.username_field.verbose_name)
+
+ @sensitive_variables()
+ def clean(self):
+ username = self.cleaned_data.get("username")
+ password = self.cleaned_data.get("password")
+
+ if username is not None and password:
+ self.user_cache = authenticate(
+ self.request, username=username, password=password
+ )
+ if self.user_cache is None:
+ raise ValidationError(
+ self.error_messages["invalid_login"],
+ code="invalid_login",
+ params={"username": self.fields["username"].label},
+ )
+ return self.cleaned_data
+
+ def get_user(self):
+ return self.user_cache
+
+
+class ResetPasswordForm(Form):
+ username = CharField(
+ label=_("Username (or email)"),
+ widget=TextInput(attrs={"autofocus": True}),
+ )
+
+
+class RegisterForm(ModelForm):
+ class Meta:
+ model = User
+ fields = ["username", "email"]
+
+ message = CharField(label=_("Message to admins"), widget=Textarea())
--- /dev/null
+# Generated by Django 6.0.4 on 2026-04-24 11:37
+
+import django.contrib.auth.validators
+import django.utils.timezone
+import pgtrigger.compiler
+import pgtrigger.migrations
+import user.models
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ initial = True
+
+ dependencies = [
+ ("auth", "0012_alter_user_first_name_max_length"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="User",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("password", models.CharField(max_length=128, verbose_name="password")),
+ (
+ "last_login",
+ models.DateTimeField(
+ blank=True, null=True, verbose_name="last login"
+ ),
+ ),
+ (
+ "is_superuser",
+ models.BooleanField(
+ default=False,
+ help_text="Designates that this user has all permissions without explicitly assigning them.",
+ verbose_name="superuser status",
+ ),
+ ),
+ (
+ "username",
+ models.CharField(
+ error_messages={
+ "unique": "A user with that username already exists."
+ },
+ help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
+ max_length=150,
+ unique=True,
+ validators=[
+ django.contrib.auth.validators.UnicodeUsernameValidator()
+ ],
+ verbose_name="username",
+ ),
+ ),
+ (
+ "first_name",
+ models.CharField(
+ blank=True, max_length=150, verbose_name="first name"
+ ),
+ ),
+ (
+ "last_name",
+ models.CharField(
+ blank=True, max_length=150, verbose_name="last name"
+ ),
+ ),
+ (
+ "email",
+ models.EmailField(
+ blank=True, max_length=254, verbose_name="email address"
+ ),
+ ),
+ (
+ "is_staff",
+ models.BooleanField(
+ default=False,
+ help_text="Designates whether the user can log into this admin site.",
+ verbose_name="staff status",
+ ),
+ ),
+ (
+ "is_active",
+ models.BooleanField(
+ default=True,
+ help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
+ verbose_name="active",
+ ),
+ ),
+ (
+ "date_joined",
+ models.DateTimeField(
+ default=django.utils.timezone.now, verbose_name="date joined"
+ ),
+ ),
+ (
+ "groups",
+ models.ManyToManyField(
+ blank=True,
+ help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
+ related_name="user_set",
+ related_query_name="user",
+ to="auth.group",
+ verbose_name="groups",
+ ),
+ ),
+ (
+ "user_permissions",
+ models.ManyToManyField(
+ blank=True,
+ help_text="Specific permissions for this user.",
+ related_name="user_set",
+ related_query_name="user",
+ to="auth.permission",
+ verbose_name="user permissions",
+ ),
+ ),
+ ],
+ managers=[
+ ("objects", user.models.UserManager()),
+ ],
+ ),
+ pgtrigger.migrations.AddTrigger(
+ model_name="user",
+ trigger=pgtrigger.compiler.Trigger(
+ name="chat_channel_delete",
+ sql=pgtrigger.compiler.UpsertTriggerSql(
+ func='\n PERFORM pg_notify(\n \'chat_channel\',\n \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n \'","obj":{"id":\' || OLD."id" || \',"username":\' || OLD."username" || \',"email":\' || OLD."email" || \',"first_name":\' || OLD."first_name" || \',"last_name":\' || OLD."last_name" || \',"date_joined":\' || OLD."date_joined" || \',"channels":\' || OLD."channels" || \'}}\'\n );\n RETURN NULL;\n ',
+ hash="028118469da41b01d89a7189ba91aab95db1c53f",
+ operation="DELETE",
+ pgid="pgtrigger_chat_channel_delete_a66e5",
+ table="user_user",
+ when="AFTER",
+ ),
+ ),
+ ),
+ pgtrigger.migrations.AddTrigger(
+ model_name="user",
+ trigger=pgtrigger.compiler.Trigger(
+ name="chat_channel_insert_update",
+ sql=pgtrigger.compiler.UpsertTriggerSql(
+ func='\n PERFORM pg_notify(\n \'chat_channel\',\n \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n \'","obj":{"id":\' || NEW."id" || \',"username":\' || NEW."username" || \',"email":\' || NEW."email" || \',"first_name":\' || NEW."first_name" || \',"last_name":\' || NEW."last_name" || \',"date_joined":\' || NEW."date_joined" || \',"channels":\' || NEW."channels" || \'}}\'\n );\n RETURN NULL;\n ',
+ hash="225c79a6c8ca8556fcad9b1bd9826fd90172f63a",
+ operation="INSERT OR UPDATE",
+ pgid="pgtrigger_chat_channel_insert_update_72ed2",
+ table="user_user",
+ when="AFTER",
+ ),
+ ),
+ ),
+ pgtrigger.migrations.AddTrigger(
+ model_name="user",
+ trigger=pgtrigger.compiler.Trigger(
+ name="chat_channel_truncate",
+ sql=pgtrigger.compiler.UpsertTriggerSql(
+ func="\n PERFORM pg_notify(\n 'chat_channel',\n '{\"op\":\"' || TG_OP || '\",\"table\":\"' || TG_TABLE_NAME || '\"}'\n );\n RETURN NULL;\n ",
+ hash="967da6f33240942f142b2c1848e263089d958e83",
+ level="STATEMENT",
+ operation="TRUNCATE",
+ pgid="pgtrigger_chat_channel_truncate_8a96a",
+ table="user_user",
+ when="AFTER",
+ ),
+ ),
+ ),
+ ]
--- /dev/null
+from django.contrib.auth.models import AbstractUser, UserManager as DjangoUserManager
+from django.db.models import Q, QuerySet
+
+from chat.triggers import chat_channel
+
+
+class UserManager(DjangoUserManager):
+ def _get_by_natural_key(self, username, queryset_method):
+ return queryset_method(
+ self.filter(is_active=True),
+ Q(**{self.model.USERNAME_FIELD: username})
+ | Q(**{self.model.EMAIL_FIELD: username}),
+ )
+
+ def get_by_natural_key(self, username):
+ return self._get_by_natural_key(username, QuerySet.get)
+
+ async def aget_by_natural_key(self, username):
+ return await self._get_by_natural_key(username, QuerySet.aget)
+
+
+class User(AbstractUser):
+ objects = UserManager()
+
+ class Meta:
+ triggers = [
+ *chat_channel(
+ (
+ "id",
+ "username",
+ "email",
+ "first_name",
+ "last_name",
+ "date_joined",
+ "channels",
+ )
+ )
+ ]
+
+ def is_privileged(self):
+ return self.is_staff or self.is_superuser
--- /dev/null
+{% extends "chat/base.html" %}
+{% block title %}
+ {{ title }}
+{% endblock title %}
+{% block main %}
+ <h1>{{ title }}</h1>
+ {% if messages %}
+ <ul class="messages">
+ {% for message in messages %}
+ <li class="{{ message.tags|default:'' }}">{{ message }}</li>
+ {% endfor %}
+ </ul>
+ {% endif %}
+ {% if form %}
+ <form method="post">
+ {% csrf_token %}
+ {{ form.as_p }}
+ <p>
+ <input type="submit" value="Submit" />
+ </p>
+ </form>
+ {% endif %}
+ {% for label, url in links.items %}
+ <p>
+ <a href="{{ url }}">{{ label }}</a>
+ </p>
+ {% endfor %}
+{% endblock main %}
--- /dev/null
+from django.urls import path
+
+from .views import LoginView, LogoutView, RegisterView, ResetPasswordView, create_user
+
+urlpatterns = [
+ path("login", LoginView.as_view(), name="user-login"),
+ path("logout", LogoutView.as_view(), name="user-logout"),
+ path("reset-password", ResetPasswordView.as_view(), name="user-reset-password"),
+ path("register", RegisterView.as_view(), name="user-register"),
+ path("create", create_user, name="user-create"),
+]
--- /dev/null
+from base64 import urlsafe_b64decode
+
+from django.contrib.auth import logout
+from django.contrib.auth.decorators import login_not_required
+from django.contrib.auth.forms import SetPasswordForm
+from django.contrib.auth.tokens import default_token_generator
+from django.contrib.auth.views import (
+ INTERNAL_RESET_SESSION_TOKEN,
+ LoginView as DjangoLoginView,
+)
+from django.shortcuts import redirect
+from django.urls import reverse_lazy
+from django.utils.decorators import method_decorator
+from django.utils.translation import gettext_lazy as _
+from django.contrib import messages
+from django.views.decorators.http import require_GET
+from django.views.generic import FormView, RedirectView
+
+from chat.bridge import bridge
+from .email import send_password_reset_email, send_register_email
+from .forms import (
+ AuthenticationForm,
+ RegisterForm,
+ ResetPasswordForm,
+ django_gettext_lazy,
+)
+from .models import User
+
+
+class LoginView(DjangoLoginView):
+ next_page = reverse_lazy("channel-main")
+ form_class = AuthenticationForm
+ template_name = "user/user.html"
+ extra_context = {
+ "title": "Login",
+ "links": {
+ "Reset Password": reverse_lazy("user-reset-password"),
+ "Register": reverse_lazy("user-register"),
+ },
+ }
+
+
+class LogoutView(RedirectView):
+ url = reverse_lazy("user-login")
+
+ def get(self, request, *args, **kwargs):
+ logout(request)
+ return super().get(request)
+
+
+@method_decorator(login_not_required, name="dispatch")
+class RegisterView(FormView):
+ form_class = RegisterForm
+ template_name = "user/user.html"
+ extra_context = {"title": "Register"}
+ success_url = reverse_lazy("user-login")
+
+ def form_valid(self, form):
+ bridge.call_async(send_register_email, self.request, form.cleaned_data)
+ messages.info(self.request, _("Registration email submitted."))
+ return redirect("user-login")
+
+
+@method_decorator(login_not_required, name="dispatch")
+class ResetPasswordView(FormView):
+ form_class = ResetPasswordForm
+ template_name = "user/user.html"
+ extra_context = {"title": "Reset Password"}
+ success_url = reverse_lazy("user-login")
+
+ def send_password_reset_email(self, form):
+ bridge.call_async(
+ send_password_reset_email, self.request, form.cleaned_data["username"]
+ )
+ messages.info(
+ self.request, _("If this user exists, an email is currently being sent.")
+ )
+
+ def save_new_password(self, form):
+ form.save()
+ del self.request.session[INTERNAL_RESET_SESSION_TOKEN]
+ messages.info(self.request, django_gettext_lazy("Password reset complete"))
+
+ def form_valid(self, form):
+ if "username" in form.cleaned_data:
+ self.send_password_reset_email(form)
+ elif (
+ "new_password1" in form.cleaned_data
+ and "new_password2" in form.cleaned_data
+ ):
+ self.save_new_password(form)
+ return super().form_valid(form)
+
+ def get_form(self, form_class=None):
+ request = self.request
+ if (
+ request.method == "GET"
+ and "token" in request.GET
+ and "uidb64" in request.GET
+ ):
+ user = User.objects.get(pk=urlsafe_b64decode(request.GET["uidb64"]))
+ default_token_generator.check_token(user, request.GET["token"])
+ request.session[INTERNAL_RESET_SESSION_TOKEN] = user.pk
+ elif (
+ request.method == "POST"
+ and "new_password1" in request.POST
+ and "new_password2" in request.POST
+ and "token" in request.GET
+ ):
+ user = User.objects.get(pk=request.session[INTERNAL_RESET_SESSION_TOKEN])
+ default_token_generator.check_token(user, request.GET["token"])
+ else:
+ return super().get_form(form_class)
+ return SetPasswordForm(user, **self.get_form_kwargs())
+
+
+@require_GET
+def create_user(request):
+ if User.objects.filter(
+ username=request.GET["username"], email=request.GET["email"]
+ ):
+ messages.info(request, _("User was already created."))
+ else:
+ User.objects.create(**request.GET.dict(), is_active=True)
+ messages.info(request, _("User was successfully created."))
+ return redirect("channel-main")