Fix #549 - Deauthorize OAuth applications
[mediagoblin.git] / mediagoblin / edit / views.py
CommitLineData
9bfe1d8e 1# GNU MediaGoblin -- federated, autonomous media hosting
cf29e8a8 2# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
9bfe1d8e
E
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/>.
aba81c9f 16
e49b7e02
BP
17import six
18
3a8c3a38
JW
19from datetime import datetime
20
377db0e7 21from itsdangerous import BadSignature
9919fb08 22from pyld import jsonld
62d14bf5 23from werkzeug.exceptions import Forbidden
3a8c3a38 24from werkzeug.utils import secure_filename
0d6550fb 25from jsonschema import ValidationError, Draft4Validator
aba81c9f 26
d9ed098e 27from mediagoblin import messages
10d7496d 28from mediagoblin import mg_globals
152a3bfa 29
201ac388
CAW
30from mediagoblin.auth import (check_password,
31 tools as auth_tools)
aba81c9f 32from mediagoblin.edit import forms
b5a64f78 33from mediagoblin.edit.lib import may_edit_media
abc4da29 34from mediagoblin.decorators import (require_active_login, active_user_from_url,
89e1563f 35 get_media_entry_by_id, user_may_alter_collection,
fffc5dcf 36 get_user_collection, user_has_privilege,
37 user_not_banned)
89e1563f 38from mediagoblin.tools.crypto import get_timed_signer_url
0d6550fb 39from mediagoblin.tools.metadata import (compact_and_validate, DEFAULT_CHECKER,
40 DEFAULT_SCHEMA)
af4414a8 41from mediagoblin.tools.mail import email_debug_message
89e1563f
RE
42from mediagoblin.tools.response import (render_to_response,
43 redirect, redirect_obj, render_404)
5ae0cbaa 44from mediagoblin.tools.translate import pass_to_ugettext as _
89e1563f 45from mediagoblin.tools.template import render_template
152a3bfa 46from mediagoblin.tools.text import (
a855e92a 47 convert_to_tag_list_of_dicts, media_tags_as_string)
9e9d9083 48from mediagoblin.tools.url import slugify
be5be115 49from mediagoblin.db.util import check_media_slug_used, check_collection_slug_used
7e15632b 50from mediagoblin.db.models import User, Client, AccessToken
c849e690 51
825b1362
JW
52import mimetypes
53
97ec97db 54
461dd971 55@get_media_entry_by_id
aba81c9f
E
56@require_active_login
57def edit_media(request, media):
c849e690 58 if not may_edit_media(request, media):
cfa92229 59 raise Forbidden("User may not edit this media")
c849e690 60
2c437493 61 defaults = dict(
ec82fbd8 62 title=media.title,
5da0bf90 63 slug=media.slug,
1d939966 64 description=media.description,
a6c49d49 65 tags=media_tags_as_string(media.tags),
97ec97db 66 license=media.license)
aba81c9f 67
2c437493 68 form = forms.EditForm(
111a609d 69 request.form,
2c437493
JW
70 **defaults)
71
98857207 72 if request.method == 'POST' and form.validate():
d5e90fe4
CAW
73 # Make sure there isn't already a MediaEntry with such a slug
74 # and userid.
dc03850b 75 slug = slugify(form.slug.data)
9e9d9083 76 slug_used = check_media_slug_used(media.uploader, slug, media.id)
3a8c3a38 77
b62b3b98 78 if slug_used:
d5e90fe4 79 form.slug.errors.append(
4b1adc13 80 _(u'An entry with that slug already exists for this user.'))
d5e90fe4 81 else:
dc03850b
HL
82 media.title = form.title.data
83 media.description = form.description.data
de917303 84 media.tags = convert_to_tag_list_of_dicts(
dc03850b 85 form.tags.data)
3a8c3a38 86
e49b7e02 87 media.license = six.text_type(form.license.data) or None
9e9d9083 88 media.slug = slug
747623cc 89 media.save()
d5e90fe4 90
2e6ee596 91 return redirect_obj(request, media)
98857207 92
8394febb 93 if request.user.has_privilege(u'admin') \
5c2b8486 94 and media.uploader != request.user.id \
96a2c366
CAW
95 and request.method != 'POST':
96 messages.add_message(
97 request, messages.WARNING,
4b1adc13 98 _("You are editing another user's media. Proceed with caution."))
96a2c366 99
9038c9f9
CAW
100 return render_to_response(
101 request,
c9c24934
E
102 'mediagoblin/edit/edit.html',
103 {'media': media,
104 'form': form})
46fd661e 105
3a8c3a38 106
825b1362
JW
107# Mimetypes that browsers parse scripts in.
108# Content-sniffing isn't taken into consideration.
109UNSAFE_MIMETYPES = [
110 'text/html',
111 'text/svg+xml']
112
113
954b407c 114@get_media_entry_by_id
630b57a3 115@require_active_login
3a8c3a38
JW
116def edit_attachments(request, media):
117 if mg_globals.app_config['allow_attachments']:
118 form = forms.EditAttachmentsForm()
119
120 # Add any attachements
c43f8c1d
JW
121 if 'attachment_file' in request.files \
122 and request.files['attachment_file']:
3a8c3a38 123
825b1362
JW
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(
c43f8c1d 135 request.files['attachment_file'].filename)[0] in \
825b1362
JW
136 UNSAFE_MIMETYPES:
137 public_filename = secure_filename('{0}.notsafe'.format(
c43f8c1d 138 request.files['attachment_file'].filename))
825b1362
JW
139 else:
140 public_filename = secure_filename(
c43f8c1d 141 request.files['attachment_file'].filename)
825b1362 142
3a8c3a38
JW
143 attachment_public_filepath \
144 = mg_globals.public_store.get_unique_filepath(
e49b7e02 145 ['media_entries', six.text_type(media.id), 'attachment',
825b1362 146 public_filename])
3a8c3a38
JW
147
148 attachment_public_file = mg_globals.public_store.get_file(
149 attachment_public_filepath, 'wb')
150
151 try:
152 attachment_public_file.write(
c43f8c1d 153 request.files['attachment_file'].stream.read())
3a8c3a38 154 finally:
c43f8c1d 155 request.files['attachment_file'].stream.close()
3a8c3a38 156
35029581 157 media.attachment_files.append(dict(
dc03850b 158 name=form.attachment_name.data \
c43f8c1d 159 or request.files['attachment_file'].filename,
3a8c3a38 160 filepath=attachment_public_filepath,
243c3843 161 created=datetime.utcnow(),
3a8c3a38 162 ))
630b57a3 163
3a8c3a38
JW
164 media.save()
165
166 messages.add_message(
167 request, messages.SUCCESS,
32255ec0 168 _("You added the attachment %s!") \
dc03850b 169 % (form.attachment_name.data
c43f8c1d 170 or request.files['attachment_file'].filename))
3a8c3a38 171
950124e6
SS
172 return redirect(request,
173 location=media.url_for_self(request.urlgen))
3a8c3a38
JW
174 return render_to_response(
175 request,
176 'mediagoblin/edit/attachments.html',
177 {'media': media,
178 'form': form})
179 else:
cfa92229 180 raise Forbidden("Attachments are disabled")
3a8c3a38 181
abc4da29
SS
182@require_active_login
183def 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
3a8c3a38
JW
188
189@require_active_login
abc4da29
SS
190@active_user_from_url
191def edit_profile(request, url_user=None):
192 # admins may edit any user profile
193 if request.user.username != url_user.username:
8394febb 194 if not request.user.has_privilege(u'admin'):
abc4da29
SS
195 raise Forbidden(_("You can only edit your own profile."))
196
a0cf14fe
CFD
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,
4b1adc13 201 _("You are editing a user's profile. Proceed with caution."))
abc4da29
SS
202
203 user = url_user
a0cf14fe 204
111a609d 205 form = forms.EditProfileForm(request.form,
066d49b2
SS
206 url=user.url,
207 bio=user.bio)
630b57a3 208
209 if request.method == 'POST' and form.validate():
e49b7e02
BP
210 user.url = six.text_type(form.url.data)
211 user.bio = six.text_type(form.bio.data)
4c465852 212
c8071fa5 213 user.save()
630b57a3 214
c8071fa5
JS
215 messages.add_message(request,
216 messages.SUCCESS,
217 _("Profile changes saved"))
218 return redirect(request,
219 'mediagoblin.user_pages.user_home',
703d09b9 220 user=user.username)
630b57a3 221
222 return render_to_response(
223 request,
224 'mediagoblin/edit/edit_profile.html',
225 {'user': user,
226 'form': form})
c8071fa5 227
89e1563f
RE
228EMAIL_VERIFICATION_TEMPLATE = (
229 u'{uri}?'
230 u'token={verification_key}')
231
c8071fa5
JS
232
233@require_active_login
234def edit_account(request):
c8071fa5 235 user = request.user
111a609d 236 form = forms.EditAccountForm(request.form,
066d49b2 237 wants_comment_notification=user.wants_comment_notification,
93d805ad
RE
238 license_preference=user.license_preference,
239 wants_notifications=user.wants_notifications)
c8071fa5 240
89e1563f 241 if request.method == 'POST' and form.validate():
f670f48d 242 user.wants_comment_notification = form.wants_comment_notification.data
93d805ad 243 user.wants_notifications = form.wants_notifications.data
dc4dfbde 244
f670f48d 245 user.license_preference = form.license_preference.data
dc4dfbde 246
dd57c6c5
RE
247 user.save()
248 messages.add_message(request,
249 messages.SUCCESS,
250 _("Account settings saved"))
251 return redirect(request,
252 'mediagoblin.user_pages.user_home',
253 user=user.username)
630b57a3 254
255 return render_to_response(
256 request,
c8071fa5 257 'mediagoblin/edit/edit_account.html',
630b57a3 258 {'user': user,
259 'form': form})
be5be115 260
7e15632b
JT
261@require_active_login
262def deauthorize_applications(request):
263 """ Deauthroize OAuth applications """
264 if request.method == 'POST' and "application" in request.form:
265 token = request.form["application"]
266 access_token = AccessToken.query.filter_by(token=token).first()
267 if access_token is None:
268 messages.add_message(
269 request,
270 messages.ERROR,
271 _("Unknown application, not able to deauthorize")
272 )
273 else:
274 access_token.delete()
275 messages.add_message(
276 request,
277 messages.SUCCESS,
278 _("Application has been deauthorized")
279 )
280
281 access_tokens = AccessToken.query.filter_by(user=request.user.id)
282 applications = [(a.get_requesttoken, a) for a in access_tokens]
283
284 return render_to_response(
285 request,
286 'mediagoblin/edit/deauthorize_applications.html',
287 {'applications': applications}
288 )
be5be115 289
380f22b8
SS
290@require_active_login
291def delete_account(request):
292 """Delete a user completely"""
293 user = request.user
294 if request.method == 'POST':
295 if request.form.get(u'confirmed'):
296 # Form submitted and confirmed. Actually delete the user account
297 # Log out user and delete cookies etc.
298 # TODO: Should we be using MG.auth.views.py:logout for this?
299 request.session.delete()
300
301 # Delete user account and all related media files etc....
302 request.user.delete()
303
304 # We should send a message that the user has been deleted
305 # successfully. But we just deleted the session, so we
306 # can't...
307 return redirect(request, 'index')
308
309 else: # Did not check the confirmation box...
310 messages.add_message(
311 request, messages.WARNING,
312 _('You need to confirm the deletion of your account.'))
313
314 # No POST submission or not confirmed, just show page
315 return render_to_response(
316 request,
317 'mediagoblin/edit/delete_account.html',
318 {'user': user})
319
320
be5be115
AW
321@require_active_login
322@user_may_alter_collection
323@get_user_collection
324def edit_collection(request, collection):
325 defaults = dict(
326 title=collection.title,
327 slug=collection.slug,
328 description=collection.description)
329
330 form = forms.EditCollectionForm(
111a609d 331 request.form,
be5be115
AW
332 **defaults)
333
334 if request.method == 'POST' and form.validate():
335 # Make sure there isn't already a Collection with such a slug
336 # and userid.
455fd36f 337 slug_used = check_collection_slug_used(collection.creator,
dc03850b 338 form.slug.data, collection.id)
c43f8c1d 339
be5be115 340 # Make sure there isn't already a Collection with this title
44082b12
RE
341 existing_collection = request.db.Collection.query.filter_by(
342 creator=request.user.id,
343 title=form.title.data).first()
c43f8c1d 344
be5be115
AW
345 if existing_collection and existing_collection.id != collection.id:
346 messages.add_message(
a6481028
CAW
347 request, messages.ERROR,
348 _('You already have a collection called "%s"!') % \
dc03850b 349 form.title.data)
be5be115
AW
350 elif slug_used:
351 form.slug.errors.append(
352 _(u'A collection with that slug already exists for this user.'))
353 else:
e49b7e02
BP
354 collection.title = six.text_type(form.title.data)
355 collection.description = six.text_type(form.description.data)
356 collection.slug = six.text_type(form.slug.data)
be5be115
AW
357
358 collection.save()
359
2e6ee596 360 return redirect_obj(request, collection)
be5be115 361
8394febb 362 if request.user.has_privilege(u'admin') \
5c2b8486 363 and collection.creator != request.user.id \
be5be115
AW
364 and request.method != 'POST':
365 messages.add_message(
366 request, messages.WARNING,
367 _("You are editing another user's collection. Proceed with caution."))
368
369 return render_to_response(
370 request,
371 'mediagoblin/edit/edit_collection.html',
372 {'collection': collection,
373 'form': form})
39aa1db4
RE
374
375
89e1563f
RE
376def verify_email(request):
377 """
378 Email verification view for changing email address
379 """
380 # If no token, we can't do anything
381 if not 'token' in request.GET:
382 return render_404(request)
383
377db0e7
RE
384 # Catch error if token is faked or expired
385 token = None
386 try:
387 token = get_timed_signer_url("mail_verification_token") \
388 .loads(request.GET['token'], max_age=10*24*3600)
389 except BadSignature:
390 messages.add_message(
391 request,
392 messages.ERROR,
393 _('The verification key or user id is incorrect.'))
394
395 return redirect(
396 request,
397 'index')
89e1563f
RE
398
399 user = User.query.filter_by(id=int(token['user'])).first()
400
401 if user:
402 user.email = token['email']
403 user.save()
404
405 messages.add_message(
406 request,
407 messages.SUCCESS,
408 _('Your email address has been verified.'))
409
410 else:
411 messages.add_message(
412 request,
413 messages.ERROR,
414 _('The verification key or user id is incorrect.'))
415
416 return redirect(
417 request, 'mediagoblin.user_pages.user_home',
418 user=user.username)
5adb906a
RE
419
420
402f4360
RE
421def change_email(request):
422 """ View to change the user's email """
423 form = forms.ChangeEmailForm(request.form)
424 user = request.user
425
426 # If no password authentication, no need to enter a password
427 if 'pass_auth' not in request.template_env.globals or not user.pw_hash:
428 form.__delitem__('password')
429
430 if request.method == 'POST' and form.validate():
431 new_email = form.new_email.data
432 users_with_email = User.query.filter_by(
433 email=new_email).count()
434
435 if users_with_email:
436 form.new_email.errors.append(
437 _('Sorry, a user with that email address'
438 ' already exists.'))
439
201ac388 440 if form.password and user.pw_hash and not check_password(
402f4360
RE
441 form.password.data, user.pw_hash):
442 form.password.errors.append(
443 _('Wrong password'))
444
445 if not form.errors:
446 verification_key = get_timed_signer_url(
447 'mail_verification_token').dumps({
448 'user': user.id,
449 'email': new_email})
450
451 rendered_email = render_template(
452 request, 'mediagoblin/edit/verification.txt',
453 {'username': user.username,
454 'verification_url': EMAIL_VERIFICATION_TEMPLATE.format(
455 uri=request.urlgen('mediagoblin.edit.verify_email',
456 qualified=True),
457 verification_key=verification_key)})
458
459 email_debug_message(request)
460 auth_tools.send_verification_email(user, request, new_email,
461 rendered_email)
462
463 return redirect(request, 'mediagoblin.edit.account')
464
465 return render_to_response(
466 request,
467 'mediagoblin/edit/change_email.html',
468 {'form': form,
469 'user': user})
fffc5dcf 470
471@user_has_privilege(u'admin')
472@require_active_login
473@get_media_entry_by_id
474def edit_metadata(request, media):
9919fb08 475 form = forms.EditMetaDataForm(request.form)
476 if request.method == "POST" and form.validate():
9919fb08 477 metadata_dict = dict([(row['identifier'],row['value'])
478 for row in form.media_metadata.data])
0d6550fb 479 json_ld_metadata = None
6b6b1b07 480 json_ld_metadata = compact_and_validate(metadata_dict)
481 media.media_metadata = json_ld_metadata
482 media.save()
1688abbf 483 return redirect_obj(request, media)
9919fb08 484
1688abbf 485 if len(form.media_metadata) == 0:
13f37e75 486 for identifier, value in six.iteritems(media.media_metadata):
6b6b1b07 487 if identifier == "@context": continue
e80596c8 488 form.media_metadata.append_entry({
489 'identifier':identifier,
490 'value':value})
0d6550fb 491
fffc5dcf 492 return render_to_response(
493 request,
494 'mediagoblin/edit/metadata.html',
e80596c8 495 {'form':form,
496 'media':media})