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