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