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