Add polymorphic properties to User
[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
ed484545 50from mediagoblin.db.models import User, Client, AccessToken, Location
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
c0434db4
JT
205 # Get the location name
206 if user.location is None:
207 location = ""
208 else:
209 location = user.get_location.name
210
111a609d 211 form = forms.EditProfileForm(request.form,
066d49b2 212 url=user.url,
c0434db4
JT
213 bio=user.bio,
214 location=location)
630b57a3 215
216 if request.method == 'POST' and form.validate():
e49b7e02
BP
217 user.url = six.text_type(form.url.data)
218 user.bio = six.text_type(form.bio.data)
4c465852 219
c0434db4
JT
220 # Save location
221 if form.location.data and user.location is None:
896d00fb 222 user.get_location = Location(name=six.text_type(form.location.data))
c0434db4 223 elif form.location.data:
c5f258fe 224 location = user.get_location
896d00fb 225 location.name = six.text_type(form.location.data)
c0434db4
JT
226 location.save()
227
c8071fa5 228 user.save()
630b57a3 229
c8071fa5
JS
230 messages.add_message(request,
231 messages.SUCCESS,
232 _("Profile changes saved"))
233 return redirect(request,
234 'mediagoblin.user_pages.user_home',
703d09b9 235 user=user.username)
630b57a3 236
237 return render_to_response(
238 request,
239 'mediagoblin/edit/edit_profile.html',
240 {'user': user,
241 'form': form})
c8071fa5 242
89e1563f
RE
243EMAIL_VERIFICATION_TEMPLATE = (
244 u'{uri}?'
245 u'token={verification_key}')
246
c8071fa5
JS
247
248@require_active_login
249def edit_account(request):
c8071fa5 250 user = request.user
111a609d 251 form = forms.EditAccountForm(request.form,
066d49b2 252 wants_comment_notification=user.wants_comment_notification,
93d805ad
RE
253 license_preference=user.license_preference,
254 wants_notifications=user.wants_notifications)
c8071fa5 255
89e1563f 256 if request.method == 'POST' and form.validate():
f670f48d 257 user.wants_comment_notification = form.wants_comment_notification.data
93d805ad 258 user.wants_notifications = form.wants_notifications.data
dc4dfbde 259
f670f48d 260 user.license_preference = form.license_preference.data
dc4dfbde 261
dd57c6c5
RE
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)
630b57a3 269
270 return render_to_response(
271 request,
c8071fa5 272 'mediagoblin/edit/edit_account.html',
630b57a3 273 {'user': user,
274 'form': form})
be5be115 275
7e15632b
JT
276@require_active_login
277def 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 )
be5be115 304
380f22b8
SS
305@require_active_login
306def 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
be5be115
AW
336@require_active_login
337@user_may_alter_collection
338@get_user_collection
339def edit_collection(request, collection):
340 defaults = dict(
341 title=collection.title,
342 slug=collection.slug,
343 description=collection.description)
344
345 form = forms.EditCollectionForm(
111a609d 346 request.form,
be5be115
AW
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.
455fd36f 352 slug_used = check_collection_slug_used(collection.creator,
dc03850b 353 form.slug.data, collection.id)
c43f8c1d 354
be5be115 355 # Make sure there isn't already a Collection with this title
44082b12
RE
356 existing_collection = request.db.Collection.query.filter_by(
357 creator=request.user.id,
358 title=form.title.data).first()
c43f8c1d 359
be5be115
AW
360 if existing_collection and existing_collection.id != collection.id:
361 messages.add_message(
a6481028
CAW
362 request, messages.ERROR,
363 _('You already have a collection called "%s"!') % \
dc03850b 364 form.title.data)
be5be115
AW
365 elif slug_used:
366 form.slug.errors.append(
367 _(u'A collection with that slug already exists for this user.'))
368 else:
e49b7e02
BP
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)
be5be115
AW
372
373 collection.save()
374
2e6ee596 375 return redirect_obj(request, collection)
be5be115 376
8394febb 377 if request.user.has_privilege(u'admin') \
5c2b8486 378 and collection.creator != request.user.id \
be5be115
AW
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})
39aa1db4
RE
389
390
89e1563f
RE
391def 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
377db0e7
RE
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')
89e1563f
RE
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)
5adb906a
RE
434
435
402f4360
RE
436def 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
201ac388 455 if form.password and user.pw_hash and not check_password(
402f4360
RE
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})
fffc5dcf 485
486@user_has_privilege(u'admin')
487@require_active_login
488@get_media_entry_by_id
489def edit_metadata(request, media):
9919fb08 490 form = forms.EditMetaDataForm(request.form)
491 if request.method == "POST" and form.validate():
9919fb08 492 metadata_dict = dict([(row['identifier'],row['value'])
493 for row in form.media_metadata.data])
0d6550fb 494 json_ld_metadata = None
6b6b1b07 495 json_ld_metadata = compact_and_validate(metadata_dict)
496 media.media_metadata = json_ld_metadata
497 media.save()
c0434db4 498 return redirect_obj(request, media)
9919fb08 499
1688abbf 500 if len(form.media_metadata) == 0:
13f37e75 501 for identifier, value in six.iteritems(media.media_metadata):
6b6b1b07 502 if identifier == "@context": continue
e80596c8 503 form.media_metadata.append_entry({
504 'identifier':identifier,
505 'value':value})
0d6550fb 506
fffc5dcf 507 return render_to_response(
508 request,
509 'mediagoblin/edit/metadata.html',
e80596c8 510 {'form':form,
511 'media':media})