Fix #1024 - Correctly set generator on Activities
[larjonas-mediagoblin.git] / mediagoblin / federation / views.py
blob9823fffe3696d689f290e5dd04556456a8b62596
1 # GN MediaGoblin -- federated, autonomous media hosting
2 # Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
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.
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.
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/>.
17 import json
18 import io
19 import mimetypes
21 from werkzeug.datastructures import FileStorage
23 from mediagoblin.decorators import oauth_required, require_active_login
24 from mediagoblin.federation.decorators import user_has_privilege
25 from mediagoblin.db.models import User, MediaEntry, MediaComment, Activity
26 from mediagoblin.tools.federation import create_activity, create_generator
27 from mediagoblin.tools.routing import extract_url_arguments
28 from mediagoblin.tools.response import redirect, json_response, json_error, \
29 render_404, render_to_response
30 from mediagoblin.meddleware.csrf import csrf_exempt
31 from mediagoblin.submit.lib import new_upload_entry, api_upload_request, \
32 api_add_to_feed
34 # MediaTypes
35 from mediagoblin.media_types.image import MEDIA_TYPE as IMAGE_MEDIA_TYPE
37 # Getters
38 def get_profile(request):
39 """
40 Gets the user's profile for the endpoint requested.
42 For example an endpoint which is /api/{username}/feed
43 as /api/cwebber/feed would get cwebber's profile. This
44 will return a tuple (username, user_profile). If no user
45 can be found then this function returns a (None, None).
46 """
47 username = request.matchdict["username"]
48 user = User.query.filter_by(username=username).first()
50 if user is None:
51 return None, None
53 return user, user.serialize(request)
56 # Endpoints
57 @oauth_required
58 def profile_endpoint(request):
59 """ This is /api/user/<username>/profile - This will give profile info """
60 user, user_profile = get_profile(request)
62 if user is None:
63 username = request.matchdict["username"]
64 return json_error(
65 "No such 'user' with username '{0}'".format(username),
66 status=404
69 # user profiles are public so return information
70 return json_response(user_profile)
72 @oauth_required
73 def user_endpoint(request):
74 """ This is /api/user/<username> - This will get the user """
75 user, user_profile = get_profile(request)
77 if user is None:
78 username = request.matchdict["username"]
79 return json_error(
80 "No such 'user' with username '{0}'".format(username),
81 status=404
84 return json_response({
85 "nickname": user.username,
86 "updated": user.created.isoformat(),
87 "published": user.created.isoformat(),
88 "profile": user_profile,
91 @oauth_required
92 @csrf_exempt
93 @user_has_privilege(u'uploader')
94 def uploads_endpoint(request):
95 """ Endpoint for file uploads """
96 username = request.matchdict["username"]
97 requested_user = User.query.filter_by(username=username).first()
99 if requested_user is None:
100 return json_error("No such 'user' with id '{0}'".format(username), 404)
102 if request.method == "POST":
103 # Ensure that the user is only able to upload to their own
104 # upload endpoint.
105 if requested_user.id != request.user.id:
106 return json_error(
107 "Not able to post to another users feed.",
108 status=403
111 # Wrap the data in the werkzeug file wrapper
112 if "Content-Type" not in request.headers:
113 return json_error(
114 "Must supply 'Content-Type' header to upload media."
117 mimetype = request.headers["Content-Type"]
118 filename = mimetypes.guess_all_extensions(mimetype)
119 filename = 'unknown' + filename[0] if filename else filename
120 file_data = FileStorage(
121 stream=io.BytesIO(request.data),
122 filename=filename,
123 content_type=mimetype
126 # Find media manager
127 entry = new_upload_entry(request.user)
128 entry.media_type = IMAGE_MEDIA_TYPE
129 return api_upload_request(request, file_data, entry)
131 return json_error("Not yet implemented", 501)
133 @oauth_required
134 @csrf_exempt
135 def feed_endpoint(request):
136 """ Handles the user's outbox - /api/user/<username>/feed """
137 username = request.matchdict["username"]
138 requested_user = User.query.filter_by(username=username).first()
140 # check if the user exists
141 if requested_user is None:
142 return json_error("No such 'user' with id '{0}'".format(username), 404)
144 if request.data:
145 data = json.loads(request.data.decode())
146 else:
147 data = {"verb": None, "object": {}}
150 if request.method in ["POST", "PUT"]:
151 # Validate that the activity is valid
152 if "verb" not in data or "object" not in data:
153 return json_error("Invalid activity provided.")
155 # Check that the verb is valid
156 if data["verb"] not in ["post", "update"]:
157 return json_error("Verb not yet implemented", 501)
159 # We need to check that the user they're posting to is
160 # the person that they are.
161 if requested_user.id != request.user.id:
162 return json_error(
163 "Not able to post to another users feed.",
164 status=403
167 # Handle new posts
168 if data["verb"] == "post":
169 obj = data.get("object", None)
170 if obj is None:
171 return json_error("Could not find 'object' element.")
173 if obj.get("objectType", None) == "comment":
174 # post a comment
175 if not request.user.has_privilege(u'commenter'):
176 return json_error(
177 "Privilege 'commenter' required to comment.",
178 status=403
181 comment = MediaComment(author=request.user.id)
182 comment.unserialize(data["object"], request)
183 comment.save()
185 # Create activity for comment
186 generator = create_generator(request)
187 activity = create_activity(
188 verb="post",
189 actor=request.user,
190 obj=comment,
191 target=comment.get_entry,
192 generator=generator
195 data = {
196 "verb": "post",
197 "object": comment.serialize(request)
199 return json_response(data)
201 elif obj.get("objectType", None) == "image":
202 # Posting an image to the feed
203 media_id = int(extract_url_arguments(
204 url=data["object"]["id"],
205 urlmap=request.app.url_map
206 )["id"])
208 media = MediaEntry.query.filter_by(id=media_id).first()
210 if media is None:
211 return json_response(
212 "No such 'image' with id '{0}'".format(media_id),
213 status=404
216 if media.uploader != request.user.id:
217 return json_error(
218 "Privilege 'commenter' required to comment.",
219 status=403
223 if not media.unserialize(data["object"]):
224 return json_error(
225 "Invalid 'image' with id '{0}'".format(media_id)
229 # Add location if one exists
230 if "location" in data:
231 Location.create(data["location"], self)
233 media.save()
234 api_add_to_feed(request, media)
236 return json_response({
237 "verb": "post",
238 "object": media.serialize(request)
241 elif obj.get("objectType", None) is None:
242 # They need to tell us what type of object they're giving us.
243 return json_error("No objectType specified.")
244 else:
245 # Oh no! We don't know about this type of object (yet)
246 object_type = obj.get("objectType", None)
247 return json_error(
248 "Unknown object type '{0}'.".format(object_type)
251 # Updating existing objects
252 if data["verb"] == "update":
253 # Check we've got a valid object
254 obj = data.get("object", None)
256 if obj is None:
257 return json_error("Could not find 'object' element.")
259 if "objectType" not in obj:
260 return json_error("No objectType specified.")
262 if "id" not in obj:
263 return json_error("Object ID has not been specified.")
265 obj_id = int(extract_url_arguments(
266 url=obj["id"],
267 urlmap=request.app.url_map
268 )["id"])
270 # Now try and find object
271 if obj["objectType"] == "comment":
272 if not request.user.has_privilege(u'commenter'):
273 return json_error(
274 "Privilege 'commenter' required to comment.",
275 status=403
278 comment = MediaComment.query.filter_by(id=obj_id).first()
279 if comment is None:
280 return json_error(
281 "No such 'comment' with id '{0}'.".format(obj_id)
284 # Check that the person trying to update the comment is
285 # the author of the comment.
286 if comment.author != request.user.id:
287 return json_error(
288 "Only author of comment is able to update comment.",
289 status=403
292 if not comment.unserialize(data["object"]):
293 return json_error(
294 "Invalid 'comment' with id '{0}'".format(obj_id)
297 comment.save()
299 activity = {
300 "verb": "update",
301 "object": comment.serialize(request),
303 return json_response(activity)
305 elif obj["objectType"] == "image":
306 image = MediaEntry.query.filter_by(id=obj_id).first()
307 if image is None:
308 return json_error(
309 "No such 'image' with the id '{0}'.".format(obj_id)
312 # Check that the person trying to update the comment is
313 # the author of the comment.
314 if image.uploader != request.user.id:
315 return json_error(
316 "Only uploader of image is able to update image.",
317 status=403
320 if not image.unserialize(obj):
321 return json_error(
322 "Invalid 'image' with id '{0}'".format(obj_id)
324 image.generate_slug()
325 image.save()
327 activity = {
328 "verb": "update",
329 "object": image.serialize(request),
331 return json_response(activity)
332 elif obj["objectType"] == "person":
333 # check this is the same user
334 if "id" not in obj or obj["id"] != requested_user.id:
335 return json_error(
336 "Incorrect user id, unable to update"
339 requested_user.unserialize(obj)
340 requested_user.save()
342 elif request.method != "GET":
343 return json_error(
344 "Unsupported HTTP method {0}".format(request.method),
345 status=501
348 feed_url = request.urlgen(
349 "mediagoblin.federation.feed",
350 username=request.user.username,
351 qualified=True
354 feed = {
355 "displayName": "Activities by {user}@{host}".format(
356 user=request.user.username,
357 host=request.host
359 "objectTypes": ["activity"],
360 "url": feed_url,
361 "links": {
362 "first": {
363 "href": feed_url,
365 "self": {
366 "href": request.url,
368 "prev": {
369 "href": feed_url,
371 "next": {
372 "href": feed_url,
375 "author": request.user.serialize(request),
376 "items": [],
379 for activity in Activity.query.filter_by(actor=request.user.id):
380 try:
381 feed["items"].append(activity.serialize(request))
382 except AttributeError:
383 # This occurs because of how we hard-deletion and the object
384 # no longer existing anymore. We want to keep the Activity
385 # in case someone wishes to look it up but we shouldn't display
386 # it in the feed.
387 pass
388 feed["totalItems"] = len(feed["items"])
390 return json_response(feed)
392 @oauth_required
393 def object_endpoint(request):
394 """ Lookup for a object type """
395 object_type = request.matchdict["object_type"]
396 try:
397 object_id = request.matchdict["id"]
398 except ValueError:
399 error = "Invalid object ID '{0}' for '{1}'".format(
400 request.matchdict["id"],
401 object_type
403 return json_error(error)
405 if object_type not in ["image"]:
406 # not sure why this is 404, maybe ask evan. Maybe 400?
407 return json_error(
408 "Unknown type: {0}".format(object_type),
409 status=404
412 media = MediaEntry.query.filter_by(id=object_id).first()
413 if media is None:
414 return json_error(
415 "Can't find '{0}' with ID '{1}'".format(object_type, object_id),
416 status=404
419 return json_response(media.serialize(request))
421 @oauth_required
422 def object_comments(request):
423 """ Looks up for the comments on a object """
424 media = MediaEntry.query.filter_by(id=request.matchdict["id"]).first()
425 if media is None:
426 return json_error("Can't find '{0}' with ID '{1}'".format(
427 request.matchdict["object_type"],
428 request.matchdict["id"]
429 ), 404)
431 comments = media.serialize(request)
432 comments = comments.get("replies", {
433 "totalItems": 0,
434 "items": [],
435 "url": request.urlgen(
436 "mediagoblin.federation.object.comments",
437 object_type=media.object_type,
438 id=media.id,
439 qualified=True
443 comments["displayName"] = "Replies to {0}".format(comments["url"])
444 comments["links"] = {
445 "first": comments["url"],
446 "self": comments["url"],
448 return json_response(comments)
451 # RFC6415 - Web Host Metadata
453 def host_meta(request):
455 This provides the host-meta URL information that is outlined
456 in RFC6415. By default this should provide XRD+XML however
457 if the client accepts JSON we will provide that over XRD+XML.
458 The 'Accept' header is used to decude this.
460 A client should use this endpoint to determine what URLs to
461 use for OAuth endpoints.
464 links = [
466 "rel": "lrdd",
467 "type": "application/json",
468 "href": request.urlgen(
469 "mediagoblin.webfinger.well-known.webfinger",
470 qualified=True
474 "rel": "registration_endpoint",
475 "href": request.urlgen(
476 "mediagoblin.oauth.client_register",
477 qualified=True
481 "rel": "http://apinamespace.org/oauth/request_token",
482 "href": request.urlgen(
483 "mediagoblin.oauth.request_token",
484 qualified=True
488 "rel": "http://apinamespace.org/oauth/authorize",
489 "href": request.urlgen(
490 "mediagoblin.oauth.authorize",
491 qualified=True
495 "rel": "http://apinamespace.org/oauth/access_token",
496 "href": request.urlgen(
497 "mediagoblin.oauth.access_token",
498 qualified=True
502 "rel": "http://apinamespace.org/activitypub/whoami",
503 "href": request.urlgen(
504 "mediagoblin.webfinger.whoami",
505 qualified=True
510 if "application/json" in request.accept_mimetypes:
511 return json_response({"links": links})
513 # provide XML+XRD
514 return render_to_response(
515 request,
516 "mediagoblin/federation/host-meta.xml",
517 {"links": links},
518 mimetype="application/xrd+xml"
521 def lrdd_lookup(request):
523 This is the lrdd endpoint which can lookup a user (or
524 other things such as activities). This is as specified by
525 RFC6415.
527 The cleint must provide a 'resource' as a GET parameter which
528 should be the query to be looked up.
531 if "resource" not in request.args:
532 return json_error("No resource parameter", status=400)
534 resource = request.args["resource"]
536 if "@" in resource:
537 # Lets pull out the username
538 resource = resource[5:] if resource.startswith("acct:") else resource
539 username, host = resource.split("@", 1)
541 # Now lookup the user
542 user = User.query.filter_by(username=username).first()
544 if user is None:
545 return json_error(
546 "Can't find 'user' with username '{0}'".format(username))
548 return json_response([
550 "rel": "http://webfinger.net/rel/profile-page",
551 "href": user.url_for_self(request.urlgen),
552 "type": "text/html"
555 "rel": "self",
556 "href": request.urlgen(
557 "mediagoblin.federation.user",
558 username=user.username,
559 qualified=True
563 "rel": "activity-outbox",
564 "href": request.urlgen(
565 "mediagoblin.federation.feed",
566 username=user.username,
567 qualified=True
571 else:
572 return json_error("Unrecognized resource parameter", status=404)
575 def whoami(request):
576 """ /api/whoami - HTTP redirect to API profile """
577 if request.user is None:
578 return json_error("Not logged in.", status=401)
580 profile = request.urlgen(
581 "mediagoblin.federation.user.profile",
582 username=request.user.username,
583 qualified=True
586 return redirect(request, location=profile)
588 @require_active_login
589 def activity_view(request):
590 """ /<username>/activity/<id> - Display activity
592 This should display a HTML presentation of the activity
593 this is NOT an API endpoint.
595 # Get the user object.
596 username = request.matchdict["username"]
597 user = User.query.filter_by(username=username).first()
599 activity_id = request.matchdict["id"]
601 if request.user is None:
602 return render_404(request)
604 activity = Activity.query.filter_by(
605 id=activity_id,
606 author=user.id
607 ).first()
608 if activity is None:
609 return render_404(request)
611 return render_to_response(
612 request,
613 "mediagoblin/federation/activity.html",
614 {"activity": activity}