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