Fix #1007 - get location object not string of location name; caused 500 when editing...
[mediagoblin:mediagoblin.git] / mediagoblin / edit / views.py
1 # GNU MediaGoblin -- federated, autonomous media hosting
2 # Copyright (C) 2011, 2012 MediaGoblin contributors.  See AUTHORS.
3 #
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU Affero General Public License as published by
6 # the Free Software Foundation, either version 3 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 # GNU Affero General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
16
17 import six
18
19 from datetime import datetime
20
21 from itsdangerous import BadSignature
22 from pyld import jsonld
23 from werkzeug.exceptions import Forbidden
24 from werkzeug.utils import secure_filename
25 from jsonschema import ValidationError, Draft4Validator
26
27 from mediagoblin import messages
28 from mediagoblin import mg_globals
29
30 from mediagoblin.auth import (check_password,
31                               tools as auth_tools)
32 from mediagoblin.edit import forms
33 from mediagoblin.edit.lib import may_edit_media
34 from mediagoblin.decorators import (require_active_login, active_user_from_url,
35                             get_media_entry_by_id, user_may_alter_collection,
36                             get_user_collection, user_has_privilege,
37                             user_not_banned)
38 from mediagoblin.tools.crypto import get_timed_signer_url
39 from mediagoblin.tools.metadata import (compact_and_validate, DEFAULT_CHECKER,
40                                         DEFAULT_SCHEMA)
41 from mediagoblin.tools.mail import email_debug_message
42 from mediagoblin.tools.response import (render_to_response,
43                                         redirect, redirect_obj, render_404)
44 from mediagoblin.tools.translate import pass_to_ugettext as _
45 from mediagoblin.tools.template import render_template
46 from mediagoblin.tools.text import (
47     convert_to_tag_list_of_dicts, media_tags_as_string)
48 from mediagoblin.tools.url import slugify
49 from mediagoblin.db.util import check_media_slug_used, check_collection_slug_used
50 from mediagoblin.db.models import User, Client, AccessToken, Location
51
52 import mimetypes
53
54
55 @get_media_entry_by_id
56 @require_active_login
57 def edit_media(request, media):
58     if not may_edit_media(request, media):
59         raise Forbidden("User may not edit this media")
60
61     defaults = dict(
62         title=media.title,
63         slug=media.slug,
64         description=media.description,
65         tags=media_tags_as_string(media.tags),
66         license=media.license)
67
68     form = forms.EditForm(
69         request.form,
70         **defaults)
71
72     if request.method == 'POST' and form.validate():
73         # Make sure there isn't already a MediaEntry with such a slug
74         # and userid.
75         slug = slugify(form.slug.data)
76         slug_used = check_media_slug_used(media.uploader, slug, media.id)
77
78         if slug_used:
79             form.slug.errors.append(
80                 _(u'An entry with that slug already exists for this user.'))
81         else:
82             media.title = form.title.data
83             media.description = form.description.data
84             media.tags = convert_to_tag_list_of_dicts(
85                                    form.tags.data)
86
87             media.license = six.text_type(form.license.data) or None
88             media.slug = slug
89             media.save()
90
91             return redirect_obj(request, media)
92
93     if request.user.has_privilege(u'admin') \
94             and media.uploader != request.user.id \
95             and request.method != 'POST':
96         messages.add_message(
97             request, messages.WARNING,
98             _("You are editing another user's media. Proceed with caution."))
99
100     return render_to_response(
101         request,
102         'mediagoblin/edit/edit.html',
103         {'media': media,
104          'form': form})
105
106
107 # Mimetypes that browsers parse scripts in.
108 # Content-sniffing isn't taken into consideration.
109 UNSAFE_MIMETYPES = [
110         'text/html',
111         'text/svg+xml']
112
113
114 @get_media_entry_by_id
115 @require_active_login
116 def edit_attachments(request, media):
117     if mg_globals.app_config['allow_attachments']:
118         form = forms.EditAttachmentsForm()
119
120         # Add any attachements
121         if 'attachment_file' in request.files \
122             and request.files['attachment_file']:
123
124             # Security measure to prevent attachments from being served as
125             # text/html, which will be parsed by web clients and pose an XSS
126             # threat.
127             #
128             # TODO
129             # This method isn't flawless as some browsers may perform
130             # content-sniffing.
131             # This method isn't flawless as we do the mimetype lookup on the
132             # machine parsing the upload form, and not necessarily the machine
133             # serving the attachments.
134             if mimetypes.guess_type(
135                     request.files['attachment_file'].filename)[0] in \
136                     UNSAFE_MIMETYPES:
137                 public_filename = secure_filename('{0}.notsafe'.format(
138                     request.files['attachment_file'].filename))
139             else:
140                 public_filename = secure_filename(
141                         request.files['attachment_file'].filename)
142
143             attachment_public_filepath \
144                 = mg_globals.public_store.get_unique_filepath(
145                 ['media_entries', six.text_type(media.id), 'attachment',
146                  public_filename])
147
148             attachment_public_file = mg_globals.public_store.get_file(
149                 attachment_public_filepath, 'wb')
150
151             try:
152                 attachment_public_file.write(
153                     request.files['attachment_file'].stream.read())
154             finally:
155                 request.files['attachment_file'].stream.close()
156
157             media.attachment_files.append(dict(
158                     name=form.attachment_name.data \
159                         or request.files['attachment_file'].filename,
160                     filepath=attachment_public_filepath,
161                     created=datetime.utcnow(),
162                     ))
163
164             media.save()
165
166             messages.add_message(
167                 request, messages.SUCCESS,
168                 _("You added the attachment %s!") \
169                     % (form.attachment_name.data
170                        or request.files['attachment_file'].filename))
171
172             return redirect(request,
173                             location=media.url_for_self(request.urlgen))
174         return render_to_response(
175             request,
176             'mediagoblin/edit/attachments.html',
177             {'media': media,
178              'form': form})
179     else:
180         raise Forbidden("Attachments are disabled")
181
182 @require_active_login
183 def legacy_edit_profile(request):
184     """redirect the old /edit/profile/?username=USER to /u/USER/edit/"""
185     username = request.GET.get('username') or request.user.username
186     return redirect(request, 'mediagoblin.edit.profile', user=username)
187
188
189 @require_active_login
190 @active_user_from_url
191 def edit_profile(request, url_user=None):
192     # admins may edit any user profile
193     if request.user.username != url_user.username:
194         if not request.user.has_privilege(u'admin'):
195             raise Forbidden(_("You can only edit your own profile."))
196
197         # No need to warn again if admin just submitted an edited profile
198         if request.method != 'POST':
199             messages.add_message(
200                 request, messages.WARNING,
201                 _("You are editing a user's profile. Proceed with caution."))
202
203     user = url_user
204
205     # Get the location name
206     if user.location is None:
207         location = ""
208     else:
209         location = user.get_location.name
210
211     form = forms.EditProfileForm(request.form,
212         url=user.url,
213         bio=user.bio,
214         location=location)
215
216     if request.method == 'POST' and form.validate():
217         user.url = six.text_type(form.url.data)
218         user.bio = six.text_type(form.bio.data)
219
220         # Save location
221         if form.location.data and user.location is None:
222             user.get_location = Location(name=unicode(form.location.data))
223         elif form.location.data:
224             location = user.get_location
225             location.name = unicode(form.location.data)
226             location.save()
227
228         user.save()
229
230         messages.add_message(request,
231                              messages.SUCCESS,
232                              _("Profile changes saved"))
233         return redirect(request,
234                        'mediagoblin.user_pages.user_home',
235                         user=user.username)
236
237     return render_to_response(
238         request,
239         'mediagoblin/edit/edit_profile.html',
240         {'user': user,
241          'form': form})
242
243 EMAIL_VERIFICATION_TEMPLATE = (
244     u'{uri}?'
245     u'token={verification_key}')
246
247
248 @require_active_login
249 def edit_account(request):
250     user = request.user
251     form = forms.EditAccountForm(request.form,
252         wants_comment_notification=user.wants_comment_notification,
253         license_preference=user.license_preference,
254         wants_notifications=user.wants_notifications)
255
256     if request.method == 'POST' and form.validate():
257         user.wants_comment_notification = form.wants_comment_notification.data
258         user.wants_notifications = form.wants_notifications.data
259
260         user.license_preference = form.license_preference.data
261
262         user.save()
263         messages.add_message(request,
264                              messages.SUCCESS,
265                              _("Account settings saved"))
266         return redirect(request,
267                         'mediagoblin.user_pages.user_home',
268                         user=user.username)
269
270     return render_to_response(
271         request,
272         'mediagoblin/edit/edit_account.html',
273         {'user': user,
274          'form': form})
275
276 @require_active_login
277 def deauthorize_applications(request):
278     """ Deauthroize OAuth applications """
279     if request.method == 'POST' and "application" in request.form:
280         token = request.form["application"]
281         access_token = AccessToken.query.filter_by(token=token).first()
282         if access_token is None:
283             messages.add_message(
284                 request,
285                 messages.ERROR,
286                 _("Unknown application, not able to deauthorize")
287             )
288         else:
289             access_token.delete()
290             messages.add_message(
291                 request,
292                 messages.SUCCESS,
293                 _("Application has been deauthorized")
294             )
295
296     access_tokens = AccessToken.query.filter_by(user=request.user.id)
297     applications = [(a.get_requesttoken, a) for a in access_tokens]
298
299     return render_to_response(
300         request,
301         'mediagoblin/edit/deauthorize_applications.html',
302         {'applications': applications}
303     )
304
305 @require_active_login
306 def delete_account(request):
307     """Delete a user completely"""
308     user = request.user
309     if request.method == 'POST':
310         if request.form.get(u'confirmed'):
311             # Form submitted and confirmed. Actually delete the user account
312             # Log out user and delete cookies etc.
313             # TODO: Should we be using MG.auth.views.py:logout for this?
314             request.session.delete()
315
316             # Delete user account and all related media files etc....
317             request.user.delete()
318
319             # We should send a message that the user has been deleted
320             # successfully. But we just deleted the session, so we
321             # can't...
322             return redirect(request, 'index')
323
324         else: # Did not check the confirmation box...
325             messages.add_message(
326                 request, messages.WARNING,
327                 _('You need to confirm the deletion of your account.'))
328
329     # No POST submission or not confirmed, just show page
330     return render_to_response(
331         request,
332         'mediagoblin/edit/delete_account.html',
333         {'user': user})
334
335
336 @require_active_login
337 @user_may_alter_collection
338 @get_user_collection
339 def edit_collection(request, collection):
340     defaults = dict(
341         title=collection.title,
342         slug=collection.slug,
343         description=collection.description)
344
345     form = forms.EditCollectionForm(
346         request.form,
347         **defaults)
348
349     if request.method == 'POST' and form.validate():
350         # Make sure there isn't already a Collection with such a slug
351         # and userid.
352         slug_used = check_collection_slug_used(collection.creator,
353                 form.slug.data, collection.id)
354
355         # Make sure there isn't already a Collection with this title
356         existing_collection = request.db.Collection.query.filter_by(
357                 creator=request.user.id,
358                 title=form.title.data).first()
359
360         if existing_collection and existing_collection.id != collection.id:
361             messages.add_message(
362                 request, messages.ERROR,
363                 _('You already have a collection called "%s"!') % \
364                     form.title.data)
365         elif slug_used:
366             form.slug.errors.append(
367                 _(u'A collection with that slug already exists for this user.'))
368         else:
369             collection.title = six.text_type(form.title.data)
370             collection.description = six.text_type(form.description.data)
371             collection.slug = six.text_type(form.slug.data)
372
373             collection.save()
374
375             return redirect_obj(request, collection)
376
377     if request.user.has_privilege(u'admin') \
378             and collection.creator != request.user.id \
379             and request.method != 'POST':
380         messages.add_message(
381             request, messages.WARNING,
382             _("You are editing another user's collection. Proceed with caution."))
383
384     return render_to_response(
385         request,
386         'mediagoblin/edit/edit_collection.html',
387         {'collection': collection,
388          'form': form})
389
390
391 def verify_email(request):
392     """
393     Email verification view for changing email address
394     """
395     # If no token, we can't do anything
396     if not 'token' in request.GET:
397         return render_404(request)
398
399     # Catch error if token is faked or expired
400     token = None
401     try:
402         token = get_timed_signer_url("mail_verification_token") \
403                 .loads(request.GET['token'], max_age=10*24*3600)
404     except BadSignature:
405         messages.add_message(
406             request,
407             messages.ERROR,
408             _('The verification key or user id is incorrect.'))
409
410         return redirect(
411             request,
412             'index')
413
414     user = User.query.filter_by(id=int(token['user'])).first()
415
416     if user:
417         user.email = token['email']
418         user.save()
419
420         messages.add_message(
421             request,
422             messages.SUCCESS,
423             _('Your email address has been verified.'))
424
425     else:
426             messages.add_message(
427                 request,
428                 messages.ERROR,
429                 _('The verification key or user id is incorrect.'))
430
431     return redirect(
432         request, 'mediagoblin.user_pages.user_home',
433         user=user.username)
434
435
436 def change_email(request):
437     """ View to change the user's email """
438     form = forms.ChangeEmailForm(request.form)
439     user = request.user
440
441     # If no password authentication, no need to enter a password
442     if 'pass_auth' not in request.template_env.globals or not user.pw_hash:
443         form.__delitem__('password')
444
445     if request.method == 'POST' and form.validate():
446         new_email = form.new_email.data
447         users_with_email = User.query.filter_by(
448             email=new_email).count()
449
450         if users_with_email:
451             form.new_email.errors.append(
452                 _('Sorry, a user with that email address'
453                     ' already exists.'))
454
455         if form.password and user.pw_hash and not check_password(
456                 form.password.data, user.pw_hash):
457             form.password.errors.append(
458                 _('Wrong password'))
459
460         if not form.errors:
461             verification_key = get_timed_signer_url(
462                 'mail_verification_token').dumps({
463                     'user': user.id,
464                     'email': new_email})
465
466             rendered_email = render_template(
467                 request, 'mediagoblin/edit/verification.txt',
468                 {'username': user.username,
469                     'verification_url': EMAIL_VERIFICATION_TEMPLATE.format(
470                     uri=request.urlgen('mediagoblin.edit.verify_email',
471                                     qualified=True),
472                     verification_key=verification_key)})
473
474             email_debug_message(request)
475             auth_tools.send_verification_email(user, request, new_email,
476                                             rendered_email)
477
478             return redirect(request, 'mediagoblin.edit.account')
479
480     return render_to_response(
481         request,
482         'mediagoblin/edit/change_email.html',
483         {'form': form,
484          'user': user})
485
486 @user_has_privilege(u'admin')
487 @require_active_login
488 @get_media_entry_by_id
489 def edit_metadata(request, media):
490     form = forms.EditMetaDataForm(request.form)
491     if request.method == "POST" and form.validate():
492         metadata_dict = dict([(row['identifier'],row['value'])
493                             for row in form.media_metadata.data])
494         json_ld_metadata = None
495         json_ld_metadata = compact_and_validate(metadata_dict)
496         media.media_metadata = json_ld_metadata
497         media.save()
498         return redirect_obj(request, media)
499
500     if len(form.media_metadata) == 0:
501         for identifier, value in six.iteritems(media.media_metadata):
502             if identifier == "@context": continue
503             form.media_metadata.append_entry({
504                 'identifier':identifier,
505                 'value':value})
506
507     return render_to_response(
508         request,
509         'mediagoblin/edit/metadata.html',
510         {'form':form,
511          'media':media})