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