Archived
1
0
This commit is contained in:
Jeff Becker 2016-11-04 16:51:25 -04:00
parent 3aa87e07f2
commit a19d36f883
No known key found for this signature in database
GPG Key ID: AB950234D6EA286B
17 changed files with 303 additions and 56 deletions

View File

@ -1,4 +1,4 @@
#!/usr/bin/env python #!/usr/bin/env python3
import os import os
import sys import sys

View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@ -2,4 +2,4 @@ from django.apps import AppConfig
class FrontendConfig(AppConfig): class FrontendConfig(AppConfig):
name = 'frontend' name = 'nntpchan.frontend'

View File

@ -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')),
],
),
]

View File

@ -3,27 +3,59 @@ from django.db import models
from . import util from . import util
class Attachment(models.Model): 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) filename = models.CharField(max_length=256)
filepath = models.CharField(max_length=256) mimetype = models.CharField(max_length=256, default='text/plain')
width = models.IntegerField() width = models.IntegerField(default=0)
height = models.IntegerField() height = models.IntegerField(default=0)
banned = models.BooleanField(default=False)
class Post(models.Model):
msgid = models.CharField(max_length=256, primary_key=True) class Newsgroup(models.Model):
reference = models.CharField(max_length=256) """
message = models.TextField() synonym for board
subject = models.CharField(max_length=256) """
name = models.CharField(max_length=256) name = models.CharField(max_length=256, primary_key=True, editable=False)
pubkey = models.CharField(max_length=64) posts_per_page = models.IntegerField(default=10)
signature = models.CharField(max_length=64) max_pages = models.IntegerField(default=10)
banned = models.BooleanField(default=False)
def get_absolute_url(self): def get_absolute_url(self):
from django.urls import reverse from django.urls import reverse
op = self.msgid return reverse('nntpchan.frontend.views.boardpage', args=[self.name, '0'])
if self.reference != self.msgid:
op = self.reference
return reverse('frontend.views.threadpage', args=[util.hashid(op)]) class Post(models.Model):
"""
a post made
"""
class Board(models.Model): msgid = models.CharField(max_length=256, primary_key=True, editable=False)
pass 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
def get_absolute_url(self):
from django.urls import reverse
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)

View File

@ -3,12 +3,11 @@ from django.conf.urls import url
from . import views from . import views
urlpatterns = [ urlpatterns = [
url(r'^ctl-(?P<page>[0-9])\.html$', views.modlog, name='old-modlog'), url(r'^ctl-(?P<page>[0-9]+)\.html$', views.modlog, name='old-modlog'),
url(r'^ctl/((?P<page>[0-9])/)?$', views.modlog, name='modlog'), url(r'^ctl/((?P<page>[0-9]+)/)?$', views.modlog, name='modlog'),
url(r'^overchan\.(?P<name>[a-zA-z0-9\.]+)-(?P<page>[0-9])\.html$', views.boardpage, name='old-board'), url(r'^overchan\.(?P<name>[a-zA-z0-9\.]+)-(?P<page>[0-9]+)\.html$', views.BoardView.as_view(), name='old-board'),
url(r'^(?P<name>[a-zA-z0-9\.]+)/((?P<page>[0-9])/)?$', views.boardpage, name='board'), url(r'^overchan\.(?P<name>[a-zA-z0-9\.]+)/((?P<page>[0-9]+)/)?$', views.BoardView.as_view(), name='board'),
url(r'^thread-(?P<op>[a-fA-F0-9\.]{40})\.html$', views.threadpage, name='old-thread'), url(r'^thread-(?P<op>[a-fA-F0-9\.]{40})\.html$', views.ThreadView.as_view(), name='old-thread'),
url(r'^t/(?P<op>[a-fA-F0-9\.]{40})\.html$', views.redirect_thread, name='redirect-thread'), url(r'^t/(?P<op>[a-fA-F0-9\.]{40})/$', views.ThreadView.as_view(), name='thread'),
url(r'^t/(?P<op>[a-fA-F0-9\.]{40})/$', views.threadpage, name='thread'), url(r'^$', views.FrontPageView.as_view(), name='index'),
url(r'^$', views.frontpage, name='index'),
] ]

View File

@ -1,5 +1,26 @@
import base64
import hashlib import hashlib
import re
def hashid(msgid): 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
"""

View File

@ -1,32 +1,42 @@
from django.http import HttpResponse from django.http import HttpResponse, Http404
from django.shortcuts import render from django.shortcuts import render, get_object_or_404
from django.views import generic from django.views import generic
from .models import Post, Board from .models import Post, Newsgroup
class IndexView(generic.DetailView):
template_name = 'frontend/index.html'
class BoardView(generic.ListView): class BoardView(generic.ListView):
template_name = 'frontend/board.html' 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): class ThreadView(generic.ListView):
template_name = 'frontend/thread.html' template_name = 'frontend/thread.html'
model = Post
def frontpage(request): def get_queryset(self):
return HttpResponse('ayyyy') return get_object_or_404(self.model, posthash=self.kwargs['op'])
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): class FrontPageView(generic.ListView):
pass template_name = 'frontend/frontpage.html'
model = Post
def get_queryset(self):
return self.model.objects.order_by('posted')[:10]
def modlog(request, page): def modlog(request, page):
if page is None: if page is None:

View File

@ -55,7 +55,7 @@ ROOT_URLCONF = 'nntpchan.urls'
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', 'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [], 'DIRS': ['nntpchan/templates'],
'APP_DIRS': True, 'APP_DIRS': True,
'OPTIONS': { 'OPTIONS': {
'context_processors': [ 'context_processors': [
@ -76,8 +76,11 @@ WSGI_APPLICATION = 'nntpchan.wsgi.application'
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.sqlite3', 'ENGINE': 'django.db.backends.postgresql_psycopg2',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), '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/ # https://docs.djangoproject.com/en/1.10/howto/static-files/
STATIC_URL = '/static/' STATIC_URL = '/static/'
MEDIA_ROOT = '/tmp/'

View File

@ -0,0 +1,29 @@
{% load static %}
<!doctype html>
<html lang="en">
<head>
<title>{% block title %} nntpchan {% endblock %}</title>
<link rel="stylesheet" type="text/css" href="{% static 'style.css' %}" />
<script type="text/javascript" href="{% static 'nntpchan.js' %}">
</script>
</head>
<body>
<div id="wrapper">
<div id="navbar">
<span id="navbar_title">{% block navbar_title %}nntpchan{% endblock %}</span> |
<span id="navbar_links">{% block navbar_links %}{% endblock %}</span> |
<span id="navbar_left">{% block navbar_left %}<a href="#" onclick="nntpchan_toggle_settings()">settings</a>{% endblock %}</span>
</div>
<div id="content">
{% block content %}
{% endblock %}
</div>
</div>
<footer>
<p> legal notice goes here </p>
</footer>
<script>
ready();
</script>
</body>
</html>

View File

@ -0,0 +1 @@
{% extends "frontend/base.html" %}

View File

@ -0,0 +1 @@
{% extends "frontend/base.html" %}

View File

@ -15,6 +15,9 @@ Including another URLconf
""" """
from django.conf.urls import url, include from django.conf.urls import url, include
from . import views
urlpatterns = [ urlpatterns = [
url(r'^nntpchan/', include('nntpchan.frontend.urls')) url(r'^webhook$', views.webhook),
url(r'', include('nntpchan.frontend.urls'))
] ]

View File

@ -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): from .frontend.models import Post, Attachment, Newsgroup
return HttpResponse('ayyyyy') 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 <anon@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})