From aa0cffe1fc55d448c0db6c41610e2300a7761b91 Mon Sep 17 00:00:00 2001 From: Magnus Hagander Date: Mon, 15 Jan 2018 20:34:18 +0200 Subject: [PATCH] Implement per-list permissions This assumes we sync subscribers over from the list server (using pglister), getting their community authentication usernames. Then, by requesting a community auth login, it's possible to restrict the session to view only those lists the user is subscribed to. To view emails, the user must be subscribed to *all* the lists that the thread the message belongs to has shown up. This means that messages can dissappear from a listing if somebody CCs in a higher security level list. NOTE! After installing this code, the PUBLIC_ARCHIVES setting must be set to True to retain previous behaviour! Reviewed by Stephen Frost --- django/archives/auth.py | 247 ++++++++++++++++++ django/archives/mailarchives/api.py | 6 + .../archives/mailarchives/redirecthandler.py | 10 + django/archives/mailarchives/views.py | 188 ++++++++++--- django/archives/settings.py | 26 +- django/archives/urls.py | 6 + django/archives/util.py | 15 ++ 7 files changed, 458 insertions(+), 40 deletions(-) create mode 100644 django/archives/auth.py create mode 100644 django/archives/mailarchives/redirecthandler.py create mode 100644 django/archives/util.py diff --git a/django/archives/auth.py b/django/archives/auth.py new file mode 100644 index 0000000..6cf2c80 --- /dev/null +++ b/django/archives/auth.py @@ -0,0 +1,247 @@ +# +# Django module to support postgresql.org community authentication 2.0 +# +# The main location for this module is the pgweb git repository hosted +# on git.postgresql.org - look there for updates. +# +# To integrate with django, you need the following: +# * Make sure the view "login" from this module is used for login +# * Map an url somwehere (typically /auth_receive/) to the auth_receive +# view. +# * In settings.py, set AUTHENTICATION_BACKENDS to point to the class +# AuthBackend in this module. +# * (And of course, register for a crypto key with the main authentication +# provider website) +# * If the application uses the django admin interface, the login screen +# has to be replaced with something similar to login.html in this +# directory (adjust urls, and name it admin/login.html in any template +# directory that's processed before the default django.contrib.admin) +# + +from django.http import HttpResponse, HttpResponseRedirect +from django.contrib.auth.models import User +from django.contrib.auth.backends import ModelBackend +from django.contrib.auth import login as django_login +from django.contrib.auth import logout as django_logout +from django.conf import settings + +import base64 +import json +import socket +import urlparse +import urllib +from Crypto.Cipher import AES +from Crypto.Hash import SHA +from Crypto import Random +import time + +class AuthBackend(ModelBackend): + # We declare a fake backend that always fails direct authentication - + # since we should never be using direct authentication in the first place! + def authenticate(self, username=None, password=None): + raise Exception("Direct authentication not supported") + + +#### +# Two regular django views to interact with the login system +#### + +# Handle login requests by sending them off to the main site +def login(request): + if not hasattr(settings, 'PGAUTH_REDIRECT'): + # No pgauth installed, so allow local installs. + from django.contrib.auth.views import login + return login(request, template_name='admin.html') + + if request.GET.has_key('next'): + # Put together an url-encoded dict of parameters we're getting back, + # including a small nonce at the beginning to make sure it doesn't + # encrypt the same way every time. + s = "t=%s&%s" % (int(time.time()), urllib.urlencode({'r': request.GET['next']})) + # Now encrypt it + r = Random.new() + iv = r.read(16) + encryptor = AES.new(SHA.new(settings.SECRET_KEY).digest()[:16], AES.MODE_CBC, iv) + cipher = encryptor.encrypt(s + ' ' * (16-(len(s) % 16))) # pad to 16 bytes + + return HttpResponseRedirect("%s?d=%s$%s" % ( + settings.PGAUTH_REDIRECT, + base64.b64encode(iv, "-_"), + base64.b64encode(cipher, "-_"), + )) + else: + return HttpResponseRedirect(settings.PGAUTH_REDIRECT) + +# Handle logout requests by logging out of this site and then +# redirecting to log out from the main site as well. +def logout(request): + if request.user.is_authenticated(): + django_logout(request) + return HttpResponseRedirect("%slogout/" % settings.PGAUTH_REDIRECT) + +# Receive an authentication response from the main website and try +# to log the user in. +def auth_receive(request): + if request.GET.has_key('s') and request.GET['s'] == "logout": + # This was a logout request + return HttpResponseRedirect('/') + + if not request.GET.has_key('i'): + return HttpResponse("Missing IV in url!", status=400) + if not request.GET.has_key('d'): + return HttpResponse("Missing data in url!", status=400) + + # Set up an AES object and decrypt the data we received + decryptor = AES.new(base64.b64decode(settings.PGAUTH_KEY), + AES.MODE_CBC, + base64.b64decode(str(request.GET['i']), "-_")) + s = decryptor.decrypt(base64.b64decode(str(request.GET['d']), "-_")).rstrip(' ') + + # Now un-urlencode it + try: + data = urlparse.parse_qs(s, strict_parsing=True) + except ValueError: + return HttpResponse("Invalid encrypted data received.", status=400) + + # Check the timestamp in the authentication + if (int(data['t'][0]) < time.time() - 10): + return HttpResponse("Authentication token too old.", status=400) + + # Update the user record (if any) + try: + user = User.objects.get(username=data['u'][0]) + # User found, let's see if any important fields have changed + changed = False + if user.first_name != data['f'][0]: + user.first_name = data['f'][0] + changed = True + if user.last_name != data['l'][0]: + user.last_name = data['l'][0] + changed = True + if user.email != data['e'][0]: + user.email = data['e'][0] + changed= True + if changed: + user.save() + except User.DoesNotExist: + # User not found, create it! + + # NOTE! We have some legacy users where there is a user in + # the database with a different userid. Instead of trying to + # somehow fix that live, give a proper error message and + # have somebody look at it manually. + if User.objects.filter(email=data['e'][0]).exists(): + return HttpResponse("""A user with email %s already exists, but with +a different username than %s. + +This is almost certainly caused by some legacy data in our database. +Please send an email to webmaster@postgresql.eu, indicating the username +and email address from above, and we'll manually merge the two accounts +for you. + +We apologize for the inconvenience. +""" % (data['e'][0], data['u'][0]), content_type='text/plain') + + if hasattr(settings, 'PGAUTH_CREATEUSER_CALLBACK'): + res = getattr(settings, 'PGAUTH_CREATEUSER_CALLBACK')( + data['u'][0], + data['e'][0], + ['f'][0], + data['l'][0], + ) + # If anything is returned, we'll return that as our result. + # If None is returned, it means go ahead and create the user. + if res: + return res + + user = User(username=data['u'][0], + first_name=data['f'][0], + last_name=data['l'][0], + email=data['e'][0], + password='setbypluginnotasha1', + ) + user.save() + + # Ok, we have a proper user record. Now tell django that + # we're authenticated so it persists it in the session. Before + # we do that, we have to annotate it with the backend information. + user.backend = "%s.%s" % (AuthBackend.__module__, AuthBackend.__name__) + django_login(request, user) + + # Finally, check of we have a data package that tells us where to + # redirect the user. + if data.has_key('d'): + (ivs, datas) = data['d'][0].split('$') + decryptor = AES.new(SHA.new(settings.SECRET_KEY).digest()[:16], + AES.MODE_CBC, + base64.b64decode(ivs, "-_")) + s = decryptor.decrypt(base64.b64decode(datas, "-_")).rstrip(' ') + try: + rdata = urlparse.parse_qs(s, strict_parsing=True) + except ValueError: + return HttpResponse("Invalid encrypted data received.", status=400) + if rdata.has_key('r'): + # Redirect address + return HttpResponseRedirect(rdata['r'][0]) + # No redirect specified, see if we have it in our settings + if hasattr(settings, 'PGAUTH_REDIRECT_SUCCESS'): + return HttpResponseRedirect(settings.PGAUTH_REDIRECT_SUCCESS) + return HttpResponse("Authentication successful, but don't know where to redirect!", status=500) + + +# Perform a search in the central system. Note that the results are returned as an +# array of dicts, and *not* as User objects. To be able to for example reference the +# user through a ForeignKey, a User object must be materialized locally. We don't do +# that here, as this search might potentially return a lot of unrelated users since +# it's a wildcard match. +# Unlike the authentication, searching does not involve the browser - we just make +# a direct http call. +def user_search(searchterm=None, userid=None): + # If upsteam isn't responding quickly, it's not going to respond at all, and + # 10 seconds is already quite long. + socket.setdefaulttimeout(10) + if userid: + q = {'u': userid} + else: + q = {'s': searchterm} + + u = urllib.urlopen('%ssearch/?%s' % ( + settings.PGAUTH_REDIRECT, + urllib.urlencode(q), + )) + (ivs, datas) = u.read().split('&') + u.close() + + # Decryption time + decryptor = AES.new(base64.b64decode(settings.PGAUTH_KEY), + AES.MODE_CBC, + base64.b64decode(ivs, "-_")) + s = decryptor.decrypt(base64.b64decode(datas, "-_")).rstrip(' ') + j = json.loads(s) + + return j + +# Import a user into the local authentication system. Will initially +# make a search for it, and if anything other than one entry is returned +# the import will fail. +# Import is only supported based on userid - so a search should normally +# be done first. This will result in multiple calls to the upstream +# server, but they are cheap... +# The call to this function should normally be wrapped in a transaction, +# and this function itself will make no attempt to do anything about that. +def user_import(uid): + u = user_search(userid=uid) + if len(u) != 1: + raise Exception("Internal error, duplicate or no user found") + + u = u[0] + + if User.objects.filter(username=u['u']).exists(): + raise Exception("User already exists") + + User(username=u['u'], + first_name=u['f'], + last_name=u['l'], + email=u['e'], + password='setbypluginnotsha1', + ).save() diff --git a/django/archives/mailarchives/api.py b/django/archives/mailarchives/api.py index b388dcd..ffd577c 100644 --- a/django/archives/mailarchives/api.py +++ b/django/archives/mailarchives/api.py @@ -10,6 +10,9 @@ import json @cache(hours=4) def latest(request, listname): + if not settings.PUBLIC_ARCHIVES: + return HttpResponseForbidden('No API access on private archives for now') + if not request.META['REMOTE_ADDR'] in settings.API_CLIENTS: return HttpResponseForbidden('Invalid host') @@ -59,6 +62,9 @@ def latest(request, listname): @cache(hours=4) def thread(request, msgid): + if not settings.PUBLIC_ARCHIVES: + return HttpResponseForbidden('No API access on private archives for now') + if not request.META['REMOTE_ADDR'] in settings.API_CLIENTS: return HttpResponseForbidden('Invalid host') diff --git a/django/archives/mailarchives/redirecthandler.py b/django/archives/mailarchives/redirecthandler.py new file mode 100644 index 0000000..030b43f --- /dev/null +++ b/django/archives/mailarchives/redirecthandler.py @@ -0,0 +1,10 @@ +from django import shortcuts + +class ERedirect(Exception): + def __init__(self, url): + self.url = url + +class RedirectMiddleware(object): + def process_exception(self, request, exception): + if isinstance(exception, ERedirect): + return shortcuts.redirect(exception.url) diff --git a/django/archives/mailarchives/views.py b/django/archives/mailarchives/views.py index fee134c..da00d7b 100644 --- a/django/archives/mailarchives/views.py +++ b/django/archives/mailarchives/views.py @@ -2,6 +2,7 @@ from django.template import RequestContext from django.http import HttpResponse, HttpResponseForbidden, Http404 from django.http import StreamingHttpResponse from django.http import HttpResponsePermanentRedirect, HttpResponseNotModified +from django.core.exceptions import PermissionDenied from django.shortcuts import render_to_response, get_object_or_404 from django.utils.http import http_date, parse_http_date_safe from django.db import connection, transaction @@ -19,16 +20,78 @@ from StringIO import StringIO import json +from redirecthandler import ERedirect + from models import * +# Ensure the user is logged in (if it's not public lists) +def ensure_logged_in(request): + if settings.PUBLIC_ARCHIVES: + return + if hasattr(request, 'user') and request.user.is_authenticated(): + return + raise ERedirect('%s?next=%s' % (settings.LOGIN_URL, request.path)) + +# Ensure the user has permissions to access a list. If not, raise +# a permissions exception. +def ensure_list_permissions(request, l): + if settings.PUBLIC_ARCHIVES: + return + if hasattr(request, 'user') and request.user.is_authenticated(): + if request.user.is_superuser: + return + if l.subscriber_access and ListSubscriber.objects.filter(list=l, username=request.user.username).exists(): + return + # Logged in but no access + raise PermissionDenied("Access denied.") + + # Redirect to a login page + raise ERedirect('%s?next=%s' % (settings.LOGIN_URL, request.path)) + +# Ensure the user has permissions to access a message. In order to view +# a message, the user must have permissions on *all* lists the thread +# appears on. +def ensure_message_permissions(request, msgid): + if settings.PUBLIC_ARCHIVES: + return + if hasattr(request, 'user') and request.user.is_authenticated(): + if request.user.is_superuser: + return + + curs = connection.cursor() + curs.execute("""SELECT EXISTS ( + SELECT 1 FROM list_threads + INNER JOIN messages ON messages.threadid=list_threads.threadid + WHERE messages.messageid=%(msgid)s + AND NOT EXISTS ( + SELECT 1 FROM listsubscribers + WHERE listsubscribers.list_id=list_threads.listid + AND listsubscribers.username=%(username)s + ) +)""", { + 'msgid': msgid, + 'username': request.user.username, + }) + if not curs.fetchone()[0]: + # This thread is not on any list that the user does not have permissions on. + return + + # Logged in but no access + raise PermissionDenied("Access denied.") + + # Redirect to a login page + raise ERedirect('%s?next=%s' % (settings.LOGIN_URL, request.path)) + # Decorator to set cache age def cache(days=0, hours=0, minutes=0, seconds=0): "Set the server to cache object a specified time. td must be a timedelta object" def _cache(fn): def __cache(request, *_args, **_kwargs): resp = fn(request, *_args, **_kwargs) - td = timedelta(hours=hours, minutes=minutes, seconds=seconds) - resp['Cache-Control'] = 's-maxage=%s' % (td.days*3600*24 + td.seconds) + if settings.PUBLIC_ARCHIVES: + # Only set cache headers on public archives + td = timedelta(hours=hours, minutes=minutes, seconds=seconds) + resp['Cache-Control'] = 's-maxage=%s' % (td.days*3600*24 + td.seconds) return resp return __cache return _cache @@ -36,7 +99,9 @@ def cache(days=0, hours=0, minutes=0, seconds=0): def nocache(fn): def _nocache(request, *_args, **_kwargs): resp = fn(request, *_args, **_kwargs) - resp['Cache-Control'] = 's-maxage=0' + if settings.PUBLIC_ARCHIVES: + # Only set cache headers on public archives + resp['Cache-Control'] = 's-maxage=0' return resp return _nocache @@ -63,10 +128,13 @@ def antispam_auth(fn): -def get_all_groups_and_lists(listid=None): +def get_all_groups_and_lists(request, listid=None): # Django doesn't (yet) support traversing the reverse relationship, # so we'll get all the lists and rebuild it backwards. - lists = List.objects.select_related('group').all().order_by('listname') + if settings.PUBLIC_ARCHIVES or request.user.is_superuser: + lists = List.objects.select_related('group').all().order_by('listname') + else: + lists = List.objects.select_related('group').filter(listsubscriber__username=request.user.username).order_by('listname') listgroupid = None groups = {} for l in lists: @@ -96,7 +164,7 @@ class NavContext(RequestContext): if expand_groupid: listgroupid = int(expand_groupid) else: - (groups, listgroupid) = get_all_groups_and_lists(listid) + (groups, listgroupid) = get_all_groups_and_lists(request, listid) for g in groups: # On the root page, remove *all* entries @@ -113,7 +181,9 @@ class NavContext(RequestContext): @cache(hours=4) def index(request): - (groups, listgroupid) = get_all_groups_and_lists() + ensure_logged_in(request) + + (groups, listgroupid) = get_all_groups_and_lists(request) return render_to_response('index.html', { 'groups': [{'groupname': g['groupname'], 'lists': g['lists']} for g in groups], }, NavContext(request, all_groups=groups)) @@ -132,6 +202,8 @@ def groupindex(request, groupid): @cache(hours=8) def monthlist(request, listname): l = get_object_or_404(List, listname=listname) + ensure_list_permissions(request, l) + curs = connection.cursor() curs.execute("SELECT year, month FROM list_months WHERE listid=%(listid)s ORDER BY year DESC, month DESC", {'listid': l.listid}) months=[{'year':r[0],'month':r[1], 'date':datetime(r[0],r[1],1)} for r in curs.fetchall()] @@ -166,19 +238,26 @@ def get_monthday_info(mlist, l, d): yearmonth = "%s%02d" % (monthdate.year, monthdate.month) return (yearmonth, daysinmonth) -def render_datelist_from(request, l, d, title, to=None): - datefilter = Q(date__gte=d) - if to: - datefilter.add(Q(date__lt=to), Q.AND) - mlist = Message.objects.defer('bodytxt', 'cc', 'to').select_related().filter(datefilter, hiddenstatus__isnull=True).extra(where=["threadid IN (SELECT threadid FROM list_threads WHERE listid=%s)" % l.listid]).order_by('date')[:200] +def _render_datelist(request, l, d, datefilter, title, queryproc): + # NOTE! Basic permissions checks must be done before calling this function! + + if not settings.PUBLIC_ARCHIVES and not request.user.is_superuser: + mlist = Message.objects.defer('bodytxt', 'cc', 'to').select_related().filter(datefilter, hiddenstatus__isnull=True).extra( + where=["threadid IN (SELECT threadid FROM list_threads t WHERE listid=%s AND NOT EXISTS (SELECT 1 FROM list_threads t2 WHERE t2.threadid=t.threadid AND listid NOT IN (SELECT list_id FROM listsubscribers WHERE username=%s)))"], + params=(l.listid, request.user.username), + ) + else: + # Else we return everything + mlist = Message.objects.defer('bodytxt', 'cc', 'to').select_related().filter(datefilter, hiddenstatus__isnull=True).extra(where=["threadid IN (SELECT threadid FROM list_threads WHERE listid=%s)" % l.listid]) + mlist = queryproc(mlist) allyearmonths = set([(m.date.year, m.date.month) for m in mlist]) (yearmonth, daysinmonth) = get_monthday_info(mlist, l, d) r = render_to_response('datelist.html', { 'list': l, - 'messages': list(mlist), + 'messages': mlist, 'title': title, 'daysinmonth': daysinmonth, 'yearmonth': yearmonth, @@ -186,28 +265,30 @@ def render_datelist_from(request, l, d, title, to=None): r['X-pglm'] = ':%s:' % (':'.join(['%s/%s/%s' % (l.listid, year, month) for year,month in allyearmonths])) return r +def render_datelist_from(request, l, d, title, to=None): + # NOTE! Basic permissions checks must be done before calling this function! + datefilter = Q(date__gte=d) + if to: + datefilter.add(Q(date__lt=to), Q.AND) + + return _render_datelist(request, l, d, datefilter, title, + lambda x: list(x.order_by('date')[:200])) + def render_datelist_to(request, l, d, title): + # NOTE! Basic permissions checks must be done before calling this function! + # Need to sort this backwards in the database to get the LIMIT applied # properly, and then manually resort it in the correct order. We can do # the second sort safely in python since it's not a lot of items.. - mlist = sorted(Message.objects.defer('bodytxt', 'cc', 'to').select_related().filter(date__lte=d, hiddenstatus__isnull=True).extra(where=["threadid IN (SELECT threadid FROM list_threads WHERE listid=%s)" % l.listid]).order_by('-date')[:200], key=lambda m: m.date) - allyearmonths = set([(m.date.year, m.date.month) for m in mlist]) - (yearmonth, daysinmonth) = get_monthday_info(mlist, l, d) - - r = render_to_response('datelist.html', { - 'list': l, - 'messages': list(mlist), - 'title': title, - 'daysinmonth': daysinmonth, - 'yearmonth': yearmonth, - }, NavContext(request, l.listid)) - r['X-pglm'] = ':%s:' % (':'.join(['%s/%s/%s' % (l.listid, year, month) for year,month in allyearmonths])) - return r + return _render_datelist(request, l, d, Q(date__lte=d), title, + lambda x: sorted(x.order_by('-date')[:200], key=lambda m: m.date)) @cache(hours=2) def datelistsince(request, listname, msgid): l = get_object_or_404(List, listname=listname) + ensure_list_permissions(request, l) + msg = get_object_or_404(Message, messageid=msgid) return render_datelist_from(request, l, msg.date, "%s since %s" % (l.listname, msg.date.strftime("%Y-%m-%d %H:%M:%S"))) @@ -215,6 +296,8 @@ def datelistsince(request, listname, msgid): @cache(hours=4) def datelistsincetime(request, listname, year, month, day, hour, minute): l = get_object_or_404(List, listname=listname) + ensure_list_permissions(request, l) + try: d = datetime(int(year), int(month), int(day), int(hour), int(minute)) except ValueError: @@ -224,12 +307,16 @@ def datelistsincetime(request, listname, year, month, day, hour, minute): @cache(hours=2) def datelistbefore(request, listname, msgid): l = get_object_or_404(List, listname=listname) + ensure_list_permissions(request, l) + msg = get_object_or_404(Message, messageid=msgid) return render_datelist_to(request, l, msg.date, "%s before %s" % (l.listname, msg.date.strftime("%Y-%m-%d %H:%M:%S"))) @cache(hours=2) def datelistbeforetime(request, listname, year, month, day, hour, minute): l = get_object_or_404(List, listname=listname) + ensure_list_permissions(request, l) + try: d = datetime(int(year), int(month), int(day), int(hour), int(minute)) except ValueError: @@ -239,6 +326,8 @@ def datelistbeforetime(request, listname, year, month, day, hour, minute): @cache(hours=4) def datelist(request, listname, year, month): l = get_object_or_404(List, listname=listname) + ensure_list_permissions(request, l) + try: d = datetime(int(year), int(month), 1) except ValueError: @@ -252,13 +341,17 @@ def datelist(request, listname, year, month): def attachment(request, attid): # Use a direct query instead of django, since it has bad support for # bytea + # XXX: minor information leak, because we load the whole attachment before we check + # the thread permissions. Is that OK? curs = connection.cursor() - curs.execute("SELECT filename, contenttype, attachment FROM attachments WHERE id=%(id)s AND EXISTS (SELECT 1 FROM messages WHERE messages.id=attachments.message AND messages.hiddenstatus IS NULL)", { 'id': int(attid)}) + curs.execute("SELECT filename, contenttype, messageid, attachment FROM attachments WHERE id=%(id)s AND EXISTS (SELECT 1 FROM messages WHERE messages.id=attachments.message AND messages.hiddenstatus IS NULL)", { 'id': int(attid)}) r = curs.fetchall() if len(r) != 1: return HttpResponse("Attachment not found") - return HttpResponse(r[0][2], content_type=r[0][1]) + ensure_message_permissions(request, r[0][2]) + + return HttpResponse(r[0][3], content_type=r[0][1]) def _build_thread_structure(threadid): # Yeah, this is *way* too complicated for the django ORM @@ -317,6 +410,8 @@ SELECT l.listid,0, @cache(hours=4) def message(request, msgid): + ensure_message_permissions(request, msgid) + try: m = Message.objects.get(messageid=msgid) except Message.DoesNotExist: @@ -356,6 +451,8 @@ def message(request, msgid): @cache(hours=4) def message_flat(request, msgid): + ensure_message_permissions(request, msgid) + try: msg = Message.objects.get(messageid=msgid) except Message.DoesNotExist: @@ -380,6 +477,8 @@ def message_flat(request, msgid): @nocache @antispam_auth def message_raw(request, msgid): + ensure_message_permissions(request, msgid) + curs = connection.cursor() curs.execute("SELECT threadid, hiddenstatus, rawtxt FROM messages WHERE messageid=%(messageid)s", { 'messageid': msgid, @@ -436,6 +535,8 @@ def _build_mbox(query, params, msgid=None): @nocache @antispam_auth def message_mbox(request, msgid): + ensure_message_permissions(request, msgid) + msg = get_object_or_404(Message, messageid=msgid) return _build_mbox( @@ -450,19 +551,34 @@ def message_mbox(request, msgid): def mbox(request, listname, listname2, mboxyear, mboxmonth): if (listname != listname2): raise Http404('List name mismatch') + l = get_object_or_404(List, listname=listname) + ensure_list_permissions(request, l) mboxyear = int(mboxyear) mboxmonth = int(mboxmonth) - return _build_mbox( - "SELECT messageid, rawtxt FROM messages m INNER JOIN list_threads t ON t.threadid=m.threadid WHERE listid=(SELECT listid FROM lists WHERE listname=%(list)s) AND hiddenstatus IS NULL AND date >= %(startdate)s AND date <= %(enddate)s ORDER BY date", - { - 'list': listname, - 'startdate': date(mboxyear, mboxmonth, 1), - 'enddate': datetime(mboxyear, mboxmonth, calendar.monthrange(mboxyear, mboxmonth)[1], 23, 59, 59), - }, - ) + + query = "SELECT messageid, rawtxt FROM messages m INNER JOIN list_threads t ON t.threadid=m.threadid WHERE listid=%(listid)s AND hiddenstatus IS NULL AND date >= %(startdate)s AND date <= %(enddate)s %%% ORDER BY date" + params = { + 'listid': l.listid, + 'startdate': date(mboxyear, mboxmonth, 1), + 'enddate': datetime(mboxyear, mboxmonth, calendar.monthrange(mboxyear, mboxmonth)[1], 23, 59, 59), + } + + if not settings.PUBLIC_ARCHIVES and not request.user.is_superuser: + # Restrict to only view messages that the user has permissions on all threads they're on + query = query.replace('%%%', 'AND NOT EXISTS (SELECT 1 FROM list_threads t2 WHERE t2.threadid=t.threadid AND listid NOT IN (SELECT list_id FROM listsubscribers WHERE username=%(username)s))') + params['username'] = request.user.username + else: + # Just return the whole thing + query = query.replace('%%%', '') + return _build_mbox(query, params) def search(request): + if not settings.PUBLIC_ARCHIVES: + # We don't support searching of non-public archives at all at this point. + # XXX: room for future improvement + return HttpResponseForbidden('Not public archives') + # Only certain hosts are allowed to call the search API if not request.META['REMOTE_ADDR'] in settings.SEARCH_CLIENTS: return HttpResponseForbidden('Invalid host') diff --git a/django/archives/settings.py b/django/archives/settings.py index ed48da9..79925b1 100644 --- a/django/archives/settings.py +++ b/django/archives/settings.py @@ -92,13 +92,13 @@ TEMPLATE_LOADERS = ( # 'django.template.loaders.eggs.Loader', ) -MIDDLEWARE_CLASSES = ( +MIDDLEWARE_CLASSES = [ 'django.middleware.common.CommonMiddleware', # 'django.contrib.sessions.middleware.SessionMiddleware', # 'django.middleware.csrf.CsrfViewMiddleware', # 'django.contrib.auth.middleware.AuthenticationMiddleware', # 'django.contrib.messages.middleware.MessageMiddleware', -) +] ROOT_URLCONF = 'archives.urls' @@ -108,7 +108,7 @@ TEMPLATE_DIRS = ( # Don't forget to use absolute paths, not relative paths. ) -INSTALLED_APPS = ( +INSTALLED_APPS = [ # 'django.contrib.auth', # 'django.contrib.contenttypes', # 'django.contrib.sessions', @@ -120,7 +120,7 @@ INSTALLED_APPS = ( # Uncomment the next line to enable admin documentation: # 'django.contrib.admindocs', 'archives.mailarchives', -) +] # A sample logging configuration. The only tangible logging # performed by this configuration is to send an email to @@ -153,8 +153,26 @@ FORCE_SCRIPT_NAME="" # Always override! SEARCH_CLIENTS = ('127.0.0.1',) API_CLIENTS = ('127.0.0.1',) +PUBLIC_ARCHIVES = False try: from settings_local import * except ImportError: pass + +# If this is a non-public site, enable middleware for handling logins etc +if not PUBLIC_ARCHIVES: + MIDDLEWARE_CLASSES = [ + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + ] + MIDDLEWARE_CLASSES + MIDDLEWARE_CLASSES.append('archives.mailarchives.redirecthandler.RedirectMiddleware') + + INSTALLED_APPS = [ + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + ] + INSTALLED_APPS + + from archives.util import validate_new_user + PGAUTH_CREATEUSER_CALLBACK=validate_new_user diff --git a/django/archives/urls.py b/django/archives/urls.py index bdae714..4fa4a15 100644 --- a/django/archives/urls.py +++ b/django/archives/urls.py @@ -57,6 +57,12 @@ urlpatterns = patterns('', # development installs. (r'^dyncss/base.css', 'archives.mailarchives.views.base_css'), + # For non-public archives, support login + (r'^accounts/login/?$', 'archives.auth.login'), + (r'^accounts/logout/?$', 'archives.auth.logout'), + (r'^auth_receive/$', 'archives.auth.auth_receive'), + + # Normally served by the webserver, but needed for development installs (r'^media/(.*)$', 'django.views.static.serve', { 'document_root': '../media', diff --git a/django/archives/util.py b/django/archives/util.py new file mode 100644 index 0000000..4bfe306 --- /dev/null +++ b/django/archives/util.py @@ -0,0 +1,15 @@ +from django.http import HttpResponse +from django.db import connection + +def validate_new_user(username, email, firstname, lastname): + # Only allow user creation if they are already a subscriber + curs = connection.cursor() + curs.execute("SELECT EXISTS(SELECT 1 FROM listsubscribers WHERE username=%(username)s)", { + 'username': username, + }) + if curs.fetchone()[0]: + # User is subscribed to something, so allow creation + return None + + return HttpResponse("You are not currently subscribed to any mailing list on this server. Account not created.") + -- 2.39.5