zulip/zephyr/views.py

679 lines
28 KiB
Python

from django.conf import settings
from django.contrib.auth import authenticate, login
from django.contrib.auth.decorators import login_required
from django.core.urlresolvers import reverse
from django.http import HttpResponse, HttpResponseRedirect, HttpResponseBadRequest, HttpResponseForbidden
from django.shortcuts import render_to_response
from django.template import RequestContext
from django.shortcuts import render
from django.utils.timezone import utc
from django.core.exceptions import ValidationError
from django.contrib.auth.views import login as django_login_page
from django.contrib.auth.models import User
from zephyr.models import Message, UserProfile, Stream, Subscription, \
Recipient, get_display_recipient, get_huddle, Realm, UserMessage, \
create_user, do_send_message, mit_sync_table, create_user_if_needed, \
create_stream_if_needed, PreregistrationUser
from zephyr.forms import RegistrationForm, HomepageForm, is_unique
from django.views.decorators.csrf import csrf_exempt
from zephyr.decorator import asynchronous
from zephyr.lib.query import last_n
from zephyr.lib.avatar import gravatar_hash
from confirmation.models import Confirmation
import datetime
import simplejson
import socket
import re
import urllib
import time
SERVER_GENERATION = int(time.time())
def require_post(view_func):
def _wrapped_view_func(request, *args, **kwargs):
if request.method != "POST":
return HttpResponseBadRequest('This form can only be submitted by POST.')
return view_func(request, *args, **kwargs)
return _wrapped_view_func
# api_key_required will add the authenticated user's user_profile to
# the view function's arguments list, since we have to look it up
# anyway.
def login_required_api_view(view_func):
@csrf_exempt
@require_post
def _wrapped_view_func(request, *args, **kwargs):
# Arguably @require_post should protect us from having to do
# this, but I don't want to count on us always getting the
# decorator ordering right.
try:
user_profile = UserProfile.objects.get(user__email=request.POST.get("email"))
except UserProfile.DoesNotExist:
return json_error("Invalid user")
if user_profile is None or request.POST.get("api-key") != user_profile.api_key:
return json_error('Invalid API user/key pair.')
return view_func(request, user_profile, *args, **kwargs)
return _wrapped_view_func
# Checks if the request is a POST request and that the user is logged
# in. If not, return an error (the @login_required behavior of
# redirecting to a login page doesn't make sense for json views)
def login_required_json_view(view_func):
def _wrapped_view_func(request, *args, **kwargs):
# Arguably @require_post should protect us from having to do
# this, but I don't want to count on us always getting the
# decorator ordering right.
if request.method != "POST":
return HttpResponseBadRequest('This form can only be submitted by POST.')
if not request.user.is_authenticated():
return json_error("Not logged in")
return view_func(request, *args, **kwargs)
return _wrapped_view_func
def json_response(res_type="success", msg="", data={}, status=200):
content = {"result":res_type, "msg":msg}
content.update(data)
return HttpResponse(content=simplejson.dumps(content),
mimetype='application/json', status=status)
def json_success(data={}):
return json_response(data=data)
def json_error(msg, data={}):
return json_response(res_type="error", msg=msg, data=data, status=400)
def get_stream(stream_name, realm):
stream = Stream.objects.filter(name__iexact=stream_name, realm=realm)
if stream:
return stream[0]
else:
return None
@require_post
def accounts_register(request):
key = request.POST['key']
email = Confirmation.objects.get(confirmation_key=key).content_object.email
company_name = email.split('@')[-1]
try:
is_unique(email)
except ValidationError:
return HttpResponseRedirect(reverse('django.contrib.auth.views.login') + '?email=' + urllib.quote_plus(email))
if request.POST.get('from_confirmation'):
form = RegistrationForm()
else:
form = RegistrationForm(request.POST)
if form.is_valid():
password = form.cleaned_data['password']
full_name = form.cleaned_data['full_name']
short_name = email.split('@')[0]
domain = form.cleaned_data['domain']
(realm, _) = Realm.objects.get_or_create(domain=domain)
# FIXME: sanitize email addresses
create_user(email, password, realm, full_name, short_name)
login(request, authenticate(username=email, password=password))
return HttpResponseRedirect(reverse('zephyr.views.home'))
return render_to_response('zephyr/register.html',
{ 'form': form, 'company_name': company_name, 'email': email, 'key': key },
context_instance=RequestContext(request))
def login_page(request, **kwargs):
template_response = django_login_page(request, **kwargs)
try:
template_response.context_data['email'] = request.GET['email']
except KeyError:
pass
return template_response
def accounts_home(request):
if request.method == 'POST':
form = HomepageForm(request.POST)
if form.is_valid():
try:
email = form.cleaned_data['email']
user = PreregistrationUser.objects.get(email=email)
except PreregistrationUser.DoesNotExist:
user = PreregistrationUser()
user.email = email
user.save()
Confirmation.objects.send_confirmation(user, user.email)
return HttpResponseRedirect(reverse('send_confirm', kwargs={'email':user.email}))
try:
email = request.POST['email']
is_unique(email)
except ValidationError:
return HttpResponseRedirect(reverse('django.contrib.auth.views.login') + '?email=' + urllib.quote_plus(email))
return render_to_response('zephyr/accounts_home.html',
context_instance=RequestContext(request))
def home(request):
if not request.user.is_authenticated():
return HttpResponseRedirect(reverse(settings.NOT_LOGGED_IN_REDIRECT))
user_profile = UserProfile.objects.get(user=request.user)
num_messages = UserMessage.objects.filter(user_profile=user_profile).count()
if user_profile.pointer == -1 and num_messages > 0:
# Put the new user's pointer at the bottom
#
# This improves performance, because we limit backfilling of messages
# before the pointer. It's also likely that someone joining an
# organization is interested in recent messages more than the very
# first messages on the system.
max_id = (UserMessage.objects.filter(user_profile=user_profile)
.order_by('message')
.reverse()[0]).message_id
user_profile.pointer = max_id
user_profile.save()
# Populate personals autocomplete list based on everyone in your
# realm. Later we might want a 2-layer autocomplete, where we
# consider specially some sort of "buddy list" who e.g. you've
# talked to before, but for small organizations, the right list is
# everyone in your realm.
people = [{'email' : profile.user.email,
'full_name' : profile.full_name}
for profile in
UserProfile.objects.select_related().filter(realm=user_profile.realm) if
profile != user_profile]
subscriptions = Subscription.objects.select_related().filter(userprofile_id=user_profile, active=True)
streams = [get_display_recipient(sub.recipient) for sub in subscriptions
if sub.recipient.type == Recipient.STREAM]
return render_to_response('zephyr/index.html',
{'user_profile': user_profile,
'email_hash' : gravatar_hash(user_profile.user.email),
'people' : people,
'streams' : streams,
'have_initial_messages':
'true' if num_messages > 0 else 'false',
'show_debug':
settings.DEBUG and ('show_debug' in request.GET) },
context_instance=RequestContext(request))
@login_required_json_view
def json_update_pointer(request):
user_profile = UserProfile.objects.get(user=request.user)
pointer = request.POST.get('pointer')
if not pointer:
return json_error("Missing pointer")
try:
pointer = int(pointer)
except ValueError:
return json_error("Invalid pointer: must be an integer")
if pointer < 0:
return json_error("Invalid pointer value")
user_profile.pointer = pointer
user_profile.save()
return json_success()
def format_updates_response(messages, mit_sync_bot=False, apply_markdown=False,
reason_empty=None, user_profile=None, where='bottom'):
max_message_id = None
if user_profile is not None:
try:
max_message_id = Message.objects.filter(usermessage__user_profile=user_profile).order_by('-id')[0].id
except:
pass
if mit_sync_bot:
messages = [m for m in messages if not mit_sync_table.get(m.id)]
ret = {'messages': [message.to_dict(apply_markdown) for message in messages],
"result": "success",
"msg": "",
'where': where,
'server_generation': SERVER_GENERATION}
if reason_empty is not None:
ret['reason_empty'] = reason_empty
if max_message_id is not None:
# TODO: Figure out how to accurately return this always
ret["max_message_id"] = max_message_id
return ret
def return_messages_immediately(request, handler, user_profile, **kwargs):
first = request.POST.get("first")
last = request.POST.get("last")
failures = request.POST.get("failures")
want_old_messages = (request.POST.get("want_old_messages") == "true")
client_server_generation = request.POST.get("server_generation")
client_reload_pending = request.POST.get("reload_pending")
if first is None or last is None:
# When an API user is first querying the server to subscribe,
# there's no reason to reply immediately.
# TODO: Make this work with server_generation/failures
return False
first = int(first)
last = int(last)
if failures is not None:
failures = int(failures)
if client_reload_pending is not None:
client_reload_pending = int(client_reload_pending)
where = 'bottom'
query = Message.objects.select_related().filter(usermessage__user_profile = user_profile).order_by('id')
if last == -1:
# User has no messages yet
# Get a range around the pointer
ptr = user_profile.pointer
messages = (last_n(200, query.filter(id__lt=ptr))
+ list(query.filter(id__gte=ptr)[:200]))
else:
messages = query.filter(id__gt=last)[:400]
if want_old_messages and not messages:
# No more messages in the future; try filling in from the past.
messages = last_n(400, query.filter(id__lt=first))
where = 'top'
# Filter for mit_sync_bot before checking whether there are any
# messages to pass on. If we don't do this, when the only message
# to forward is one that was sent via mit_sync_bot, the API client
# will end up in an endless loop requesting more data from us.
if kwargs.get("mit_sync_bot"):
messages = [m for m in messages if not mit_sync_table.get(m.id)]
if messages:
handler.finish(format_updates_response(messages, where=where, **kwargs))
return True
# We might want to return an empty list to the client immediately.
# In that case, we tell the client why.
reason_empty = None
if want_old_messages:
# Tell the client to hide the "Load more messages" button.
reason_empty = 'no_old_messages'
elif failures >= 4:
# Tell the client to hide the connection failure message.
reason_empty = 'reset_failures'
elif (client_server_generation is not None
and int(client_server_generation) != SERVER_GENERATION
and not client_reload_pending):
# Inform the client that they should reload.
reason_empty = 'client_reload'
if reason_empty is not None:
handler.finish(format_updates_response(
[],
where="bottom",
user_profile=user_profile,
reason_empty=reason_empty,
**kwargs))
return True
return False
def get_updates_backend(request, user_profile, handler, **kwargs):
if return_messages_immediately(request, handler, user_profile, **kwargs):
return
def on_receive(messages):
if handler.request.connection.stream.closed():
return
try:
handler.finish(format_updates_response(messages, **kwargs))
except socket.error:
pass
user_profile.add_callback(handler.async_callback(on_receive))
@asynchronous
@login_required_json_view
def json_get_updates(request, handler):
if not ('last' in request.POST and 'first' in request.POST):
return json_error("Missing message range")
user_profile = UserProfile.objects.get(user=request.user)
return get_updates_backend(request, user_profile, handler, apply_markdown=True)
@asynchronous
@login_required_api_view
def api_get_messages(request, user_profile, handler):
return get_updates_backend(request, user_profile, handler,
apply_markdown=(request.POST.get("apply_markdown") is not None),
mit_sync_bot=request.POST.get("mit_sync_bot"))
@login_required_api_view
def api_send_message(request, user_profile):
return send_message_backend(request, user_profile, user_profile.user)
@login_required_json_view
def json_send_message(request):
user_profile = UserProfile.objects.get(user=request.user)
if 'time' in request.POST:
return json_error("Invalid field 'time'")
return send_message_backend(request, user_profile, request.user)
# TODO: This should have a real superuser security check
def is_super_user_api(request):
return request.POST.get("api-key") == "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
def already_sent_forged_message(request):
email = request.POST['sender'].lower()
if Message.objects.filter(sender__user__email=email,
content=request.POST['content'],
pub_date__gt=datetime.datetime.utcfromtimestamp(float(request.POST['time']) - 10).replace(tzinfo=utc),
pub_date__lt=datetime.datetime.utcfromtimestamp(float(request.POST['time']) + 10).replace(tzinfo=utc)):
return True
return False
def create_forged_message_users(request, user_profile):
# Create a user for the sender, if needed
email = request.POST['sender'].lower()
user = create_user_if_needed(user_profile.realm, email,
request.POST['fullname'],
request.POST['shortname'])
# Create users for huddle recipients, if needed.
if request.POST['type'] == 'personal':
if ',' in request.POST['recipient']:
# Huddle message
for user_email in [e.strip() for e in request.POST["recipient"].split(",")]:
create_user_if_needed(user_profile.realm, user_email,
user_email.split('@')[0],
user_email.split('@')[0])
else:
user_email = request.POST["recipient"].strip()
create_user_if_needed(user_profile.realm, user_email,
user_email.split('@')[0],
user_email.split('@')[0])
return user
# We do not @require_login for send_message_backend, since it is used
# both from the API and the web service. Code calling
# send_message_backend should either check the API key or check that
# the user is logged in.
def send_message_backend(request, user_profile, sender):
if "type" not in request.POST:
return json_error("Missing type")
if "content" not in request.POST:
return json_error("Missing message contents")
if "forged" in request.POST:
if not is_super_user_api(request):
return json_error("User not authorized for this query")
if "time" not in request.POST:
return json_error("Missing time")
if "time" in request.POST:
# Checking for time in request.POST is a hack to detect MIT.
# We should clean this up, since we only use the 'time' if
# 'forged' is also set.
if already_sent_forged_message(request):
return json_success()
sender = create_forged_message_users(request, user_profile)
message_type_name = request.POST["type"]
if message_type_name == 'stream':
if "stream" not in request.POST:
return json_error("Missing stream")
if "subject" not in request.POST:
return json_error("Missing subject")
stream_name = request.POST['stream'].strip()
subject_name = request.POST['subject'].strip()
if not valid_stream_name(stream_name):
return json_error("Invalid stream name")
## FIXME: Commented out temporarily while we figure out what we want
# if not valid_stream_name(subject_name):
# return json_error("Invalid subject name")
stream = create_stream_if_needed(user_profile.realm, stream_name)
recipient = Recipient.objects.get(type_id=stream.id, type=Recipient.STREAM)
elif message_type_name == 'personal':
if "recipient" not in request.POST:
return json_error("Missing recipient")
recipient_data = request.POST['recipient']
if ',' in recipient_data:
# This is actually a huddle message, which shares the
# "personal" message sending form
recipients = [r.strip() for r in recipient_data.split(',')]
# Ignore any blank recipients
recipients = [r for r in recipients if r]
recipient_ids = []
for recipient in recipients:
try:
recipient_ids.append(
UserProfile.objects.get(user__email=recipient).id)
except UserProfile.DoesNotExist:
return json_error("Invalid email '%s'" % (recipient))
# Make sure the sender is included in the huddle
recipient_ids.append(UserProfile.objects.get(user=sender).id)
huddle = get_huddle(recipient_ids)
recipient = Recipient.objects.get(type_id=huddle.id, type=Recipient.HUDDLE)
else:
# This is actually a personal message
if not User.objects.filter(email=recipient_data):
return json_error("Invalid email '%s'" % (recipient_data))
recipient_user = User.objects.get(email=recipient_data)
recipient_user_profile = UserProfile.objects.get(user=recipient_user)
recipient = Recipient.objects.get(type_id=recipient_user_profile.id,
type=Recipient.PERSONAL)
else:
return json_error("Invalid message type")
message = Message()
message.sender = UserProfile.objects.get(user=sender)
message.content = request.POST['content']
message.recipient = recipient
if message_type_name == 'stream':
message.subject = subject_name
if 'forged' in request.POST:
# Forged messages come with a timestamp
message.pub_date = datetime.datetime.utcfromtimestamp(float(request.POST['time'])).replace(tzinfo=utc)
else:
message.pub_date = datetime.datetime.utcnow().replace(tzinfo=utc)
# To avoid message loops, we must pass whether the message was
# synced from MIT message here.
do_send_message(message, synced_from_mit = 'time' in request.POST)
return json_success()
@asynchronous
@csrf_exempt
@require_post
def notify_new_message(request, handler):
# Check the shared secret.
# Also check the originating IP, at least for now.
if ((request.META['REMOTE_ADDR'] not in ('127.0.0.1', '::1'))
or (request.POST.get('secret') != settings.SHARED_SECRET)):
handler.set_status(403)
handler.finish('Access denied')
return
# FIXME: better query
users = [UserProfile.objects.get(id=user)
for user in request.POST['users'].split(',')]
message = Message.objects.get(id=request.POST['message'])
mit_sync_table[message.id] = (request.POST["synced_from_mit"] == "True")
for user in users:
user.receive(message)
handler.finish()
@login_required_api_view
def api_get_public_streams(request, user_profile):
streams = sorted([stream.name for stream in
Stream.objects.filter(realm=user_profile.realm)])
return json_success({"streams": streams})
def gather_subscriptions(user_profile):
subscriptions = Subscription.objects.filter(userprofile=user_profile, active=True)
# For now, don't display the subscription for your ability to receive personals.
return sorted([get_display_recipient(sub.recipient) for sub in subscriptions
if sub.recipient.type == Recipient.STREAM])
@login_required_api_view
def api_get_subscriptions(request, user_profile):
return json_success({"streams": gather_subscriptions(user_profile)})
@login_required_json_view
def json_list_subscriptions(request):
subs = gather_subscriptions(UserProfile.objects.get(user=request.user))
return json_success({"subscriptions": subs})
@login_required_json_view
def json_remove_subscription(request):
user_profile = UserProfile.objects.get(user=request.user)
if 'subscription' not in request.POST:
return json_error("Missing subscriptions")
sub_name = request.POST.get('subscription')
stream = get_stream(sub_name, user_profile.realm)
if not stream:
return json_error("Not subscribed, so you can't unsubscribe")
recipient = Recipient.objects.get(type_id=stream.id,
type=Recipient.STREAM)
subscription = Subscription.objects.get(
userprofile=user_profile, recipient=recipient)
subscription.active = False
subscription.save()
return json_success({"data": sub_name})
def valid_stream_name(name):
# Streams must start with a letter or number.
return re.match("^[.a-zA-Z0-9][.a-z A-Z0-9_-]*$", name)
@login_required_api_view
def api_subscribe(request, user_profile):
if "streams" not in request.POST:
return json_error("Missing streams argument.")
streams = simplejson.loads(request.POST.get("streams"))
for stream_name in streams:
if len(stream_name) > 30:
return json_error("Stream name (%s) too long." % (stream_name,))
if not valid_stream_name(stream_name):
return json_error("Invalid characters in stream name (%s)." % (stream_name,))
res = add_subscriptions_backend(request, user_profile, streams)
return json_success(res)
@login_required_json_view
def json_add_subscription(request):
user_profile = UserProfile.objects.get(user=request.user)
if "new_subscription" not in request.POST:
return json_error("Missing new_subscription argument")
stream_name = request.POST.get('new_subscription').strip()
if not valid_stream_name(stream_name):
return json_error("Invalid characters in stream names")
if len(stream_name) > 30:
return json_error("Stream name %s too long." % (stream_name,))
res = add_subscriptions_backend(request,user_profile,
[request.POST["new_subscription"]])
if len(res["already_subscribed"]) != 0:
return json_error("Subscription already exists")
return json_success({"data": res["subscribed"][0]})
def add_subscriptions_backend(request, user_profile, streams):
subscribed = []
already_subscribed = []
for stream_name in streams:
stream = create_stream_if_needed(user_profile.realm, stream_name)
recipient = Recipient.objects.get(type_id=stream.id,
type=Recipient.STREAM)
(subscription, created) = Subscription.objects.get_or_create(
userprofile=user_profile, recipient=recipient,
defaults={'active': True})
if not created:
if subscription.active:
# Subscription already exists and is active
already_subscribed.append(stream_name)
continue
else:
subscription.active = True
subscription.save()
subscribed.append(stream_name)
return {"subscribed": subscribed,
"already_subscribed": already_subscribed}
@login_required_json_view
def json_change_settings(request):
user_profile = UserProfile.objects.get(user=request.user)
# First validate all the inputs
if "full_name" not in request.POST:
return json_error("Invalid settings request -- missing full_name.")
if "new_password" not in request.POST:
return json_error("Invalid settings request -- missing new_password.")
if "old_password" not in request.POST:
return json_error("Invalid settings request -- missing old_password.")
if "confirm_password" not in request.POST:
return json_error("Invalid settings request -- missing confirm_password.")
old_password = request.POST['old_password']
new_password = request.POST['new_password']
confirm_password = request.POST['confirm_password']
full_name = request.POST['full_name']
if new_password != "":
if new_password != confirm_password:
return json_error("New password must match confirmation password!")
if not authenticate(username=user_profile.user.email, password=old_password):
return json_error("Wrong password!")
user_profile.user.set_password(new_password)
result = {}
if user_profile.full_name != full_name:
user_profile.full_name = full_name
result['full_name'] = full_name
user_profile.user.save()
user_profile.save()
return json_success(result)
@login_required_json_view
def json_stream_exists(request):
if "stream" not in request.POST:
return json_error("Missing stream argument.")
stream = request.POST.get("stream")
if not valid_stream_name(stream):
return json_error("Invalid characters in stream name")
exists = bool(get_stream(stream, UserProfile.objects.get(user=request.user).realm))
return json_success({"exists": exists})
@csrf_exempt
@require_post
def api_fetch_api_key(request):
try:
username = request.POST['username']
password = request.POST['password']
except KeyError:
return HttpResponseBadRequest("You must specify the username and password via POST.")
user = authenticate(username=username, password=password)
if user is None:
return HttpResponseForbidden("Your username or password is incorrect.")
if not user.is_active:
return HttpResponseForbidden("Your account has been disabled.")
return HttpResponse(user.userprofile.api_key)
@login_required_json_view
def json_fetch_api_key(request):
try:
password = request.POST['password']
except KeyError:
return json_error("You must specify your password to get your API key.")
if not request.user.check_password(password):
return json_error("Your username or password is incorrect.")
return json_success({"api_key": request.user.userprofile.api_key})