Merge branch 'stable'
[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, 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 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.actor, 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=six.text_type(form.location.data))
223 elif form.location.data:
224 location = user.get_location
225 location.name = six.text_type(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(actor=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 user = User.query.filter(User.id==user.id).first()
318 user.delete()
319
320 # We should send a message that the user has been deleted
321 # successfully. But we just deleted the session, so we
322 # can't...
323 return redirect(request, 'index')
324
325 else: # Did not check the confirmation box...
326 messages.add_message(
327 request, messages.WARNING,
328 _('You need to confirm the deletion of your account.'))
329
330 # No POST submission or not confirmed, just show page
331 return render_to_response(
332 request,
333 'mediagoblin/edit/delete_account.html',
334 {'user': user})
335
336
337 @require_active_login
338 @user_may_alter_collection
339 @get_user_collection
340 def edit_collection(request, collection):
341 defaults = dict(
342 title=collection.title,
343 slug=collection.slug,
344 description=collection.description)
345
346 form = forms.EditCollectionForm(
347 request.form,
348 **defaults)
349
350 if request.method == 'POST' and form.validate():
351 # Make sure there isn't already a Collection with such a slug
352 # and userid.
353 slug_used = check_collection_slug_used(collection.actor,
354 form.slug.data, collection.id)
355
356 # Make sure there isn't already a Collection with this title
357 existing_collection = request.db.Collection.query.filter_by(
358 actor=request.user.id,
359 title=form.title.data).first()
360
361 if existing_collection and existing_collection.id != collection.id:
362 messages.add_message(
363 request, messages.ERROR,
364 _('You already have a collection called "%s"!') % \
365 form.title.data)
366 elif slug_used:
367 form.slug.errors.append(
368 _(u'A collection with that slug already exists for this user.'))
369 else:
370 collection.title = six.text_type(form.title.data)
371 collection.description = six.text_type(form.description.data)
372 collection.slug = six.text_type(form.slug.data)
373
374 collection.save()
375
376 return redirect_obj(request, collection)
377
378 if request.user.has_privilege(u'admin') \
379 and collection.actor != request.user.id \
380 and request.method != 'POST':
381 messages.add_message(
382 request, messages.WARNING,
383 _("You are editing another user's collection. Proceed with caution."))
384
385 return render_to_response(
386 request,
387 'mediagoblin/edit/edit_collection.html',
388 {'collection': collection,
389 'form': form})
390
391
392 def verify_email(request):
393 """
394 Email verification view for changing email address
395 """
396 # If no token, we can't do anything
397 if not 'token' in request.GET:
398 return render_404(request)
399
400 # Catch error if token is faked or expired
401 token = None
402 try:
403 token = get_timed_signer_url("mail_verification_token") \
404 .loads(request.GET['token'], max_age=10*24*3600)
405 except BadSignature:
406 messages.add_message(
407 request,
408 messages.ERROR,
409 _('The verification key or user id is incorrect.'))
410
411 return redirect(
412 request,
413 'index')
414
415 user = User.query.filter_by(id=int(token['user'])).first()
416
417 if user:
418 user.email = token['email']
419 user.save()
420
421 messages.add_message(
422 request,
423 messages.SUCCESS,
424 _('Your email address has been verified.'))
425
426 else:
427 messages.add_message(
428 request,
429 messages.ERROR,
430 _('The verification key or user id is incorrect.'))
431
432 return redirect(
433 request, 'mediagoblin.user_pages.user_home',
434 user=user.username)
435
436
437 def change_email(request):
438 """ View to change the user's email """
439 form = forms.ChangeEmailForm(request.form)
440 user = request.user
441
442 # If no password authentication, no need to enter a password
443 if 'pass_auth' not in request.template_env.globals or not user.pw_hash:
444 form.__delitem__('password')
445
446 if request.method == 'POST' and form.validate():
447 new_email = form.new_email.data
448 users_with_email = User.query.filter(
449 LocalUser.email==new_email
450 ).count()
451
452 if users_with_email:
453 form.new_email.errors.append(
454 _('Sorry, a user with that email address'
455 ' already exists.'))
456
457 if form.password and user.pw_hash and not check_password(
458 form.password.data, user.pw_hash):
459 form.password.errors.append(
460 _('Wrong password'))
461
462 if not form.errors:
463 verification_key = get_timed_signer_url(
464 'mail_verification_token').dumps({
465 'user': user.id,
466 'email': new_email})
467
468 rendered_email = render_template(
469 request, 'mediagoblin/edit/verification.txt',
470 {'username': user.username,
471 'verification_url': EMAIL_VERIFICATION_TEMPLATE.format(
472 uri=request.urlgen('mediagoblin.edit.verify_email',
473 qualified=True),
474 verification_key=verification_key)})
475
476 email_debug_message(request)
477 auth_tools.send_verification_email(user, request, new_email,
478 rendered_email)
479
480 return redirect(request, 'mediagoblin.edit.account')
481
482 return render_to_response(
483 request,
484 'mediagoblin/edit/change_email.html',
485 {'form': form,
486 'user': user})
487
488 @user_has_privilege(u'admin')
489 @require_active_login
490 @get_media_entry_by_id
491 def edit_metadata(request, media):
492 form = forms.EditMetaDataForm(request.form)
493 if request.method == "POST" and form.validate():
494 metadata_dict = dict([(row['identifier'],row['value'])
495 for row in form.media_metadata.data])
496 json_ld_metadata = None
497 json_ld_metadata = compact_and_validate(metadata_dict)
498 media.media_metadata = json_ld_metadata
499 media.save()
500 return redirect_obj(request, media)
501
502 if len(form.media_metadata) == 0:
503 for identifier, value in six.iteritems(media.media_metadata):
504 if identifier == "@context": continue
505 form.media_metadata.append_entry({
506 'identifier':identifier,
507 'value':value})
508
509 return render_to_response(
510 request,
511 'mediagoblin/edit/metadata.html',
512 {'form':form,
513 'media':media})