diff --git a/contrib/frontends/django/nntpchan/manage.py b/contrib/frontends/django/nntpchan/manage.py index 13dbd5b..6612c45 100755 --- a/contrib/frontends/django/nntpchan/manage.py +++ b/contrib/frontends/django/nntpchan/manage.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 import os import sys diff --git a/contrib/frontends/django/nntpchan/nntpchan/admin.py b/contrib/frontends/django/nntpchan/nntpchan/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/contrib/frontends/django/nntpchan/nntpchan/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/contrib/frontends/django/nntpchan/nntpchan/frontend/apps.py b/contrib/frontends/django/nntpchan/nntpchan/frontend/apps.py index 33ae5ca..e625426 100644 --- a/contrib/frontends/django/nntpchan/nntpchan/frontend/apps.py +++ b/contrib/frontends/django/nntpchan/nntpchan/frontend/apps.py @@ -2,4 +2,4 @@ from django.apps import AppConfig class FrontendConfig(AppConfig): - name = 'frontend' + name = 'nntpchan.frontend' diff --git a/contrib/frontends/django/nntpchan/nntpchan/frontend/migrations/0001_initial.py b/contrib/frontends/django/nntpchan/nntpchan/frontend/migrations/0001_initial.py new file mode 100644 index 0000000..9bb8d02 --- /dev/null +++ b/contrib/frontends/django/nntpchan/nntpchan/frontend/migrations/0001_initial.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Attachment', + fields=[ + ('id', models.AutoField(serialize=False, verbose_name='ID', primary_key=True, auto_created=True)), + ('filehash', models.CharField(editable=False, max_length=256)), + ('filename', models.CharField(max_length=256)), + ('mimetype', models.CharField(max_length=256, default='text/plain')), + ('width', models.IntegerField(default=0)), + ('height', models.IntegerField(default=0)), + ('banned', models.BooleanField(default=False)), + ], + ), + migrations.CreateModel( + name='Newsgroup', + fields=[ + ('name', models.CharField(editable=False, max_length=256, serialize=False, primary_key=True)), + ('posts_per_page', models.IntegerField(default=10)), + ('max_pages', models.IntegerField(default=10)), + ('banned', models.BooleanField(default=False)), + ], + ), + migrations.CreateModel( + name='Post', + fields=[ + ('msgid', models.CharField(editable=False, max_length=256, serialize=False, primary_key=True)), + ('posthash', models.CharField(editable=False, max_length=256)), + ('reference', models.CharField(max_length=256, default='')), + ('message', models.TextField(default='')), + ('subject', models.CharField(max_length=256, default='None')), + ('name', models.CharField(max_length=256, default='Anonymous')), + ('pubkey', models.CharField(max_length=64, default='')), + ('signature', models.CharField(max_length=64, default='')), + ('posted', models.DateTimeField()), + ('placeholder', models.BooleanField(default=False)), + ('attachments', models.ManyToManyField(to='frontend.Attachment')), + ('newsgroup', models.ForeignKey(to='frontend.Newsgroup')), + ], + ), + ] diff --git a/contrib/frontends/django/nntpchan/nntpchan/frontend/models.py b/contrib/frontends/django/nntpchan/nntpchan/frontend/models.py index 989e012..b3dda9a 100644 --- a/contrib/frontends/django/nntpchan/nntpchan/frontend/models.py +++ b/contrib/frontends/django/nntpchan/nntpchan/frontend/models.py @@ -3,27 +3,59 @@ from django.db import models from . import util class Attachment(models.Model): + """ + a file attachment assiciated with a post + a post may have many attachments + """ + filehash = models.CharField(max_length=256, editable=False) filename = models.CharField(max_length=256) - filepath = models.CharField(max_length=256) - width = models.IntegerField() - height = models.IntegerField() + mimetype = models.CharField(max_length=256, default='text/plain') + width = models.IntegerField(default=0) + height = models.IntegerField(default=0) + banned = models.BooleanField(default=False) + + +class Newsgroup(models.Model): + """ + synonym for board + """ + name = models.CharField(max_length=256, primary_key=True, editable=False) + posts_per_page = models.IntegerField(default=10) + max_pages = models.IntegerField(default=10) + banned = models.BooleanField(default=False) + + def get_absolute_url(self): + from django.urls import reverse + return reverse('nntpchan.frontend.views.boardpage', args=[self.name, '0']) + +class Post(models.Model): + """ + a post made + """ + + msgid = models.CharField(max_length=256, primary_key=True, editable=False) + posthash = models.CharField(max_length=256, editable=False) + reference = models.CharField(max_length=256, default='') + message = models.TextField(default='') + subject = models.CharField(max_length=256, default='None') + name = models.CharField(max_length=256, default='Anonymous') + pubkey = models.CharField(max_length=64, default='') + signature = models.CharField(max_length=64, default='') + newsgroup = models.ForeignKey(Newsgroup, on_delete=models.CASCADE) + attachments = models.ManyToManyField(Attachment) + posted = models.DateTimeField() + placeholder = models.BooleanField(default=False) + + def is_op(self): + return self.reference is None -class Post(models.Model): - msgid = models.CharField(max_length=256, primary_key=True) - reference = models.CharField(max_length=256) - message = models.TextField() - subject = models.CharField(max_length=256) - name = models.CharField(max_length=256) - pubkey = models.CharField(max_length=64) - signature = models.CharField(max_length=64) - def get_absolute_url(self): from django.urls import reverse - op = self.msgid - if self.reference != self.msgid: - op = self.reference - return reverse('frontend.views.threadpage', args=[util.hashid(op)]) - -class Board(models.Model): - pass + if self.is_op(): + op = util.hashid(self.msgid) + return reverse('nntpchan.frontend.views.threadpage', args[op]) + else: + op = util.hashid(self.reference.msgid) + frag = util.hashid(self.msgid) + return reverse('nntpchan.frontend.views.threadpage', args=[op]) + '#{}'.format(frag) diff --git a/contrib/frontends/django/nntpchan/nntpchan/frontend/static/style.css b/contrib/frontends/django/nntpchan/nntpchan/frontend/static/style.css new file mode 100644 index 0000000..e69de29 diff --git a/contrib/frontends/django/nntpchan/nntpchan/frontend/urls.py b/contrib/frontends/django/nntpchan/nntpchan/frontend/urls.py index 8b53534..ea5e5b1 100644 --- a/contrib/frontends/django/nntpchan/nntpchan/frontend/urls.py +++ b/contrib/frontends/django/nntpchan/nntpchan/frontend/urls.py @@ -3,12 +3,11 @@ from django.conf.urls import url from . import views urlpatterns = [ - url(r'^ctl-(?P[0-9])\.html$', views.modlog, name='old-modlog'), - url(r'^ctl/((?P[0-9])/)?$', views.modlog, name='modlog'), - url(r'^overchan\.(?P[a-zA-z0-9\.]+)-(?P[0-9])\.html$', views.boardpage, name='old-board'), - url(r'^(?P[a-zA-z0-9\.]+)/((?P[0-9])/)?$', views.boardpage, name='board'), - url(r'^thread-(?P[a-fA-F0-9\.]{40})\.html$', views.threadpage, name='old-thread'), - url(r'^t/(?P[a-fA-F0-9\.]{40})\.html$', views.redirect_thread, name='redirect-thread'), - url(r'^t/(?P[a-fA-F0-9\.]{40})/$', views.threadpage, name='thread'), - url(r'^$', views.frontpage, name='index'), + url(r'^ctl-(?P[0-9]+)\.html$', views.modlog, name='old-modlog'), + url(r'^ctl/((?P[0-9]+)/)?$', views.modlog, name='modlog'), + url(r'^overchan\.(?P[a-zA-z0-9\.]+)-(?P[0-9]+)\.html$', views.BoardView.as_view(), name='old-board'), + url(r'^overchan\.(?P[a-zA-z0-9\.]+)/((?P[0-9]+)/)?$', views.BoardView.as_view(), name='board'), + url(r'^thread-(?P[a-fA-F0-9\.]{40})\.html$', views.ThreadView.as_view(), name='old-thread'), + url(r'^t/(?P[a-fA-F0-9\.]{40})/$', views.ThreadView.as_view(), name='thread'), + url(r'^$', views.FrontPageView.as_view(), name='index'), ] diff --git a/contrib/frontends/django/nntpchan/nntpchan/frontend/util.py b/contrib/frontends/django/nntpchan/nntpchan/frontend/util.py index 53c2d05..d58aff6 100644 --- a/contrib/frontends/django/nntpchan/nntpchan/frontend/util.py +++ b/contrib/frontends/django/nntpchan/nntpchan/frontend/util.py @@ -1,5 +1,26 @@ +import base64 import hashlib - +import re def hashid(msgid): - return hashlib.sha1().hexdigest('%s' % msgid).decode('ascii') + h = hashlib.sha1() + m = '{}'.format(msgid).encode('ascii') + h.update(m) + return h.hexdigest() + +def newsgroup_valid(name): + return re.match('overchan\.[a-zA-Z0-9\.]+[a-zA-Z0-9]$', name) is not None + +def hashfile(data): + h = hashlib.sha512() + h.update(data) + return base64.b32encode(h.digest()).decode('ascii') + +def msgid_valid(msgid): + return re.match("<[a-zA-Z0-9\$\._\-\|]+@[a-zA-Z0-9\$\._\-\|]+>$", msgid) is not None + +def save_part(part): + """ + save a mime part to disk + """ + diff --git a/contrib/frontends/django/nntpchan/nntpchan/frontend/views.py b/contrib/frontends/django/nntpchan/nntpchan/frontend/views.py index cd3a277..f1c2851 100644 --- a/contrib/frontends/django/nntpchan/nntpchan/frontend/views.py +++ b/contrib/frontends/django/nntpchan/nntpchan/frontend/views.py @@ -1,33 +1,43 @@ -from django.http import HttpResponse -from django.shortcuts import render +from django.http import HttpResponse, Http404 +from django.shortcuts import render, get_object_or_404 from django.views import generic -from .models import Post, Board - -class IndexView(generic.DetailView): - template_name = 'frontend/index.html' +from .models import Post, Newsgroup class BoardView(generic.ListView): template_name = 'frontend/board.html' + model = Post + def get_queryset(self): + newsgroup = self.kwargs['name'] + page = int(self.kwargs['page'] or "0") + try: + group = Newsgroup.objects.get(name=newsgroup) + except Newsgroup.DoesNotExist: + raise Http404("no such board") + else: + begin = page * group.posts_per_page + end = begin + group.posts_per_page + return get_object_or_404(self.model, newsgroup=group)[begin:end] + + class ThreadView(generic.ListView): template_name = 'frontend/thread.html' + model = Post + + def get_queryset(self): + return get_object_or_404(self.model, posthash=self.kwargs['op']) -def frontpage(request): - return HttpResponse('ayyyy') -def boardpage(request, name, page): - if page is None: - page = 0 - name = 'overchan.{}'.format(name) - return HttpResponse('{} page {}'.format(name, page)) -def threadpage(request, op): - return HttpResponse('thread {}'.format(op)) - -def redirect_thread(request, op): - pass +class FrontPageView(generic.ListView): + template_name = 'frontend/frontpage.html' + model = Post + def get_queryset(self): + return self.model.objects.order_by('posted')[:10] + + def modlog(request, page): if page is None: page = 0 diff --git a/contrib/frontends/django/nntpchan/nntpchan/settings.py b/contrib/frontends/django/nntpchan/nntpchan/settings.py index b48b1a6..728b692 100644 --- a/contrib/frontends/django/nntpchan/nntpchan/settings.py +++ b/contrib/frontends/django/nntpchan/nntpchan/settings.py @@ -55,7 +55,7 @@ ROOT_URLCONF = 'nntpchan.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], + 'DIRS': ['nntpchan/templates'], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ @@ -76,8 +76,11 @@ WSGI_APPLICATION = 'nntpchan.wsgi.application' DATABASES = { 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'HOST': 'localhost', + 'PASSWORD': 'jeff', + 'USER': 'jeff', + 'NAME': 'jeff', } } @@ -119,3 +122,5 @@ USE_TZ = True # https://docs.djangoproject.com/en/1.10/howto/static-files/ STATIC_URL = '/static/' + +MEDIA_ROOT = '/tmp/' diff --git a/contrib/frontends/django/nntpchan/nntpchan/templates/frontend/base.html b/contrib/frontends/django/nntpchan/nntpchan/templates/frontend/base.html new file mode 100644 index 0000000..2da6a97 --- /dev/null +++ b/contrib/frontends/django/nntpchan/nntpchan/templates/frontend/base.html @@ -0,0 +1,29 @@ +{% load static %} + + + + {% block title %} nntpchan {% endblock %} + + + + +
+ +
+ {% block content %} + {% endblock %} +
+
+
+

legal notice goes here

+
+ + + diff --git a/contrib/frontends/django/nntpchan/nntpchan/templates/frontend/board.html b/contrib/frontends/django/nntpchan/nntpchan/templates/frontend/board.html new file mode 100644 index 0000000..88cb596 --- /dev/null +++ b/contrib/frontends/django/nntpchan/nntpchan/templates/frontend/board.html @@ -0,0 +1 @@ +{% extends "frontend/base.html" %} diff --git a/contrib/frontends/django/nntpchan/nntpchan/templates/frontend/frontpage.html b/contrib/frontends/django/nntpchan/nntpchan/templates/frontend/frontpage.html new file mode 100644 index 0000000..88cb596 --- /dev/null +++ b/contrib/frontends/django/nntpchan/nntpchan/templates/frontend/frontpage.html @@ -0,0 +1 @@ +{% extends "frontend/base.html" %} diff --git a/contrib/frontends/django/nntpchan/nntpchan/templates/frontend/index.html b/contrib/frontends/django/nntpchan/nntpchan/templates/frontend/index.html new file mode 100644 index 0000000..e69de29 diff --git a/contrib/frontends/django/nntpchan/nntpchan/templates/frontend/thread.html b/contrib/frontends/django/nntpchan/nntpchan/templates/frontend/thread.html new file mode 100644 index 0000000..e69de29 diff --git a/contrib/frontends/django/nntpchan/nntpchan/urls.py b/contrib/frontends/django/nntpchan/nntpchan/urls.py index b0942ed..4ae3946 100644 --- a/contrib/frontends/django/nntpchan/nntpchan/urls.py +++ b/contrib/frontends/django/nntpchan/nntpchan/urls.py @@ -15,6 +15,9 @@ Including another URLconf """ from django.conf.urls import url, include +from . import views + urlpatterns = [ - url(r'^nntpchan/', include('nntpchan.frontend.urls')) + url(r'^webhook$', views.webhook), + url(r'', include('nntpchan.frontend.urls')) ] diff --git a/contrib/frontends/django/nntpchan/nntpchan/views.py b/contrib/frontends/django/nntpchan/nntpchan/views.py index 2eef837..8fc86fe 100644 --- a/contrib/frontends/django/nntpchan/nntpchan/views.py +++ b/contrib/frontends/django/nntpchan/nntpchan/views.py @@ -1,4 +1,96 @@ -from django.http import HttpResponse +from django.conf import settings +from django.http import HttpResponse, HttpResponseNotAllowed, JsonResponse +from django.views.decorators.csrf import csrf_exempt -def index(request): - return HttpResponse('ayyyyy') +from .frontend.models import Post, Attachment, Newsgroup +from .frontend import util + +import email +import traceback +from datetime import datetime +import mimetypes +import os + +@csrf_exempt +def webhook(request): + """ + endpoint for nntpchan daemon webhook + """ + if request.method != 'POST': + return HttpResponseNotAllowed(['POST']) + + try: + msg = email.message_from_bytes(request.body) + newsgroup = msg.get('Newsgroups') + + if newsgroup is None: + raise Exception("no newsgroup specified") + + if not util.newsgroup_valid(newsgroup): + raise Exception("invalid newsgroup name") + + + group, created = Newsgroup.objects.get_or_create(name=newsgroup) + + if group.banned: + raise Exception("newsgroup is banned") + + msgid = None + for h in ('Message-ID', 'Message-Id', 'MessageId', 'MessageID'): + if h in msg: + msgid = msg[h] + break + + if msgid is None: + raise Exception("no message id specified") + elif not util.msgid_valid(msgid): + raise Exception("invalid message id format: {}".format(msgid)) + + h = util.hashid(msgid) + atts = list() + ref = msg['References'] or '' + posted = email.utils.parsedate_to_datetime(msg['Date']) + + f = msg['From'] or 'anon ' + name = email.utils.parseaddr(f)[0] + post, created = Post.objects.get_or_create(defaults={ + 'posthash': h, + 'reference': ref, + 'posted': posted, + 'name': name, + 'subject': msg["Subject"] or '', + 'newsgroup': group}, msgid=msgid) + m = '' + + for part in msg.walk(): + ctype = part.get_content_type() + if ctype.startswith("text/plain"): + m += '{} '.format(part.get_payload(decode=True).decode('utf-8')) + else: + print(part.get_content_type()) + payload = part.get_payload(decode=True) + if payload is None: + continue + mtype = part.get_content_type() + ext = mimetypes.guess_extension(mtype) or '' + fh = util.hashfile(bytes(payload)) + fname = os.path.join(settings.MEDIA_ROOT, fh+ext) + if not os.path.exists(fname): + with open(fname, 'wb') as f: + f.write(payload) + + att = Attachment(filehash=fh) + att.mimetype = mtype + att.filename = part.get_filename() + att.save() + atts.append(att) + post.message = m + post.save() + + for att in atts: + post.attachments.add(att) + except Exception as ex: + traceback.print_exc() + return JsonResponse({ 'error': '{}'.format(ex) }) + else: + return JsonResponse({'posted': True})