1 # GNU 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/>.
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
, \
35 from mediagoblin
.media_types
.image
import MEDIA_TYPE
as IMAGE_MEDIA_TYPE
38 def get_profile(request
):
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).
47 username
= request
.matchdict
["username"]
48 user
= User
.query
.filter_by(username
=username
).first()
53 return user
, user
.serialize(request
)
58 def profile_endpoint(request
):
59 """ This is /api/user/<username>/profile - This will give profile info """
60 user
, user_profile
= get_profile(request
)
63 username
= request
.matchdict
["username"]
65 "No such 'user' with username '{0}'".format(username
),
69 # user profiles are public so return information
70 return json_response(user_profile
)
73 def user_endpoint(request
):
74 """ This is /api/user/<username> - This will get the user """
75 user
, user_profile
= get_profile(request
)
78 username
= request
.matchdict
["username"]
80 "No such 'user' with username '{0}'".format(username
),
84 return json_response({
85 "nickname": user
.username
,
86 "updated": user
.created
.isoformat(),
87 "published": user
.created
.isoformat(),
88 "profile": user_profile
,
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
105 if requested_user
.id != request
.user
.id:
107 "Not able to post to another users feed.",
111 # Wrap the data in the werkzeug file wrapper
112 if "Content-Type" not in request
.headers
:
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
),
123 content_type
=mimetype
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)
135 def inbox_endpoint(request
, inbox
=None):
136 """ This is the user's inbox
138 Currently because we don't have the ability to represent the inbox in the
139 database this is not a "real" inbox in the pump.io/Activity streams 1.0
140 sense but instead just gives back all the data on the website
142 inbox: allows you to pass a query in to limit inbox scope
144 username
= request
.matchdict
["username"]
145 user
= User
.query
.filter_by(username
=username
).first()
148 return json_error("No such 'user' with id '{0}'".format(username
), 404)
151 # Only the user who's authorized should be able to read their inbox
152 if user
.id != request
.user
.id:
154 "Only '{0}' can read this inbox.".format(user
.username
),
159 inbox
= Activity
.query
161 # Count how many items for the "totalItems" field
162 total_items
= inbox
.count()
164 # We want to make a query for all media on the site and then apply GET
165 # limits where we can.
166 inbox
= inbox
.order_by(Activity
.published
.desc())
168 # Limit by the "count" (default: 20)
170 limit
= int(request
.args
.get("count", 20))
174 # Prevent the count being too big (pump uses 200 so we shall)
175 limit
= limit
if limit
<= 200 else 200
178 inbox
= inbox
.limit(limit
)
180 # Offset (default: no offset - first <count> results)
181 inbox
= inbox
.offset(request
.args
.get("offset", 0))
183 # build the inbox feed
185 "displayName": "Activities for {0}".format(user
.username
),
186 "author": user
.serialize(request
),
187 "objectTypes": ["activity"],
188 "url": request
.base_url
,
189 "links": {"self": {"href": request
.url
}},
191 "totalItems": total_items
,
194 for activity
in inbox
:
196 feed
["items"].append(activity
.serialize(request
))
197 except AttributeError:
198 # As with the feed endpint this occurs because of how we our
199 # hard-deletion method. Some activites might exist where the
200 # Activity object and/or target no longer exist, for this case we
201 # should just skip them.
204 return json_response(feed
)
208 def inbox_minor_endpoint(request
):
209 """ Inbox subset for less important Activities """
210 inbox
= Activity
.query
.filter(
211 (Activity
.verb
== "update") |
(Activity
.verb
== "delete")
214 return inbox_endpoint(request
=request
, inbox
=inbox
)
218 def inbox_major_endpoint(request
):
219 """ Inbox subset for most important Activities """
220 inbox
= Activity
.query
.filter_by(verb
="post")
221 return inbox_endpoint(request
=request
, inbox
=inbox
)
225 def feed_endpoint(request
, outbox
=None):
226 """ Handles the user's outbox - /api/user/<username>/feed """
227 username
= request
.matchdict
["username"]
228 requested_user
= User
.query
.filter_by(username
=username
).first()
230 # check if the user exists
231 if requested_user
is None:
232 return json_error("No such 'user' with id '{0}'".format(username
), 404)
235 data
= json
.loads(request
.data
.decode())
237 data
= {"verb": None, "object": {}}
240 if request
.method
in ["POST", "PUT"]:
241 # Validate that the activity is valid
242 if "verb" not in data
or "object" not in data
:
243 return json_error("Invalid activity provided.")
245 # Check that the verb is valid
246 if data
["verb"] not in ["post", "update", "delete"]:
247 return json_error("Verb not yet implemented", 501)
249 # We need to check that the user they're posting to is
250 # the person that they are.
251 if requested_user
.id != request
.user
.id:
253 "Not able to post to another users feed.",
258 if data
["verb"] == "post":
259 obj
= data
.get("object", None)
261 return json_error("Could not find 'object' element.")
263 if obj
.get("objectType", None) == "comment":
265 if not request
.user
.has_privilege(u
'commenter'):
267 "Privilege 'commenter' required to comment.",
271 comment
= MediaComment(author
=request
.user
.id)
272 comment
.unserialize(data
["object"], request
)
275 # Create activity for comment
276 generator
= create_generator(request
)
277 activity
= create_activity(
281 target
=comment
.get_entry
,
285 return json_response(activity
.serialize(request
))
287 elif obj
.get("objectType", None) == "image":
288 # Posting an image to the feed
289 media_id
= int(extract_url_arguments(
290 url
=data
["object"]["id"],
291 urlmap
=request
.app
.url_map
294 media
= MediaEntry
.query
.filter_by(id=media_id
).first()
297 return json_response(
298 "No such 'image' with id '{0}'".format(media_id
),
302 if media
.uploader
!= request
.user
.id:
304 "Privilege 'commenter' required to comment.",
309 if not media
.unserialize(data
["object"]):
311 "Invalid 'image' with id '{0}'".format(media_id
)
315 # Add location if one exists
316 if "location" in data
:
317 Location
.create(data
["location"], self
)
320 activity
= api_add_to_feed(request
, media
)
322 return json_response(activity
.serialize(request
))
324 elif obj
.get("objectType", None) is None:
325 # They need to tell us what type of object they're giving us.
326 return json_error("No objectType specified.")
328 # Oh no! We don't know about this type of object (yet)
329 object_type
= obj
.get("objectType", None)
331 "Unknown object type '{0}'.".format(object_type
)
334 # Updating existing objects
335 if data
["verb"] == "update":
336 # Check we've got a valid object
337 obj
= data
.get("object", None)
340 return json_error("Could not find 'object' element.")
342 if "objectType" not in obj
:
343 return json_error("No objectType specified.")
346 return json_error("Object ID has not been specified.")
348 obj_id
= int(extract_url_arguments(
350 urlmap
=request
.app
.url_map
353 # Now try and find object
354 if obj
["objectType"] == "comment":
355 if not request
.user
.has_privilege(u
'commenter'):
357 "Privilege 'commenter' required to comment.",
361 comment
= MediaComment
.query
.filter_by(id=obj_id
).first()
364 "No such 'comment' with id '{0}'.".format(obj_id
)
367 # Check that the person trying to update the comment is
368 # the author of the comment.
369 if comment
.author
!= request
.user
.id:
371 "Only author of comment is able to update comment.",
375 if not comment
.unserialize(data
["object"], request
):
377 "Invalid 'comment' with id '{0}'".format(obj
["id"])
382 # Create an update activity
383 generator
= create_generator(request
)
384 activity
= create_activity(
391 return json_response(activity
.serialize(request
))
393 elif obj
["objectType"] == "image":
394 image
= MediaEntry
.query
.filter_by(id=obj_id
).first()
397 "No such 'image' with the id '{0}'.".format(obj
["id"])
400 # Check that the person trying to update the comment is
401 # the author of the comment.
402 if image
.uploader
!= request
.user
.id:
404 "Only uploader of image is able to update image.",
408 if not image
.unserialize(obj
):
410 "Invalid 'image' with id '{0}'".format(obj_id
)
412 image
.generate_slug()
415 # Create an update activity
416 generator
= create_generator(request
)
417 activity
= create_activity(
424 return json_response(activity
.serialize(request
))
425 elif obj
["objectType"] == "person":
426 # check this is the same user
427 if "id" not in obj
or obj
["id"] != requested_user
.id:
429 "Incorrect user id, unable to update"
432 requested_user
.unserialize(obj
)
433 requested_user
.save()
435 generator
= create_generator(request
)
436 activity
= create_activity(
443 return json_response(activity
.serialize(request
))
445 elif data
["verb"] == "delete":
446 obj
= data
.get("object", None)
448 return json_error("Could not find 'object' element.")
450 if "objectType" not in obj
:
451 return json_error("No objectType specified.")
454 return json_error("Object ID has not been specified.")
456 # Parse out the object ID
457 obj_id
= int(extract_url_arguments(
459 urlmap
=request
.app
.url_map
462 if obj
.get("objectType", None) == "comment":
463 # Find the comment asked for
464 comment
= MediaComment
.query
.filter_by(
466 author
=request
.user
.id
471 "No such 'comment' with id '{0}'.".format(obj_id
)
474 # Make a delete activity
475 generator
= create_generator(request
)
476 activity
= create_activity(
483 # Unfortunately this has to be done while hard deletion exists
484 context
= activity
.serialize(request
)
486 # now we can delete the comment
489 return json_response(context
)
491 if obj
.get("objectType", None) == "image":
493 entry
= MediaEntry
.query
.filter_by(
495 uploader
=request
.user
.id
500 "No such 'image' with id '{0}'.".format(obj_id
)
503 # Make the delete activity
504 generator
= create_generator(request
)
505 activity
= create_activity(
512 # This is because we have hard deletion
513 context
= activity
.serialize(request
)
515 # Now we can delete the image
518 return json_response(context
)
520 elif request
.method
!= "GET":
522 "Unsupported HTTP method {0}".format(request
.method
),
527 "displayName": "Activities by {user}@{host}".format(
528 user
=request
.user
.username
,
531 "objectTypes": ["activity"],
532 "url": request
.base_url
,
533 "links": {"self": {"href": request
.url
}},
534 "author": request
.user
.serialize(request
),
540 outbox
= Activity
.query
.filter_by(actor
=request
.user
.id)
542 outbox
= outbox
.filter_by(actor
=request
.user
.id)
544 # We want the newest things at the top (issue: #1055)
545 outbox
= outbox
.order_by(Activity
.published
.desc())
547 # Limit by the "count" (default: 20)
548 limit
= request
.args
.get("count", 20)
555 # The upper most limit should be 200
556 limit
= limit
if limit
< 200 else 200
559 outbox
= outbox
.limit(limit
)
561 # Offset (default: no offset - first <count> result)
562 outbox
= outbox
.offset(request
.args
.get("offset", 0))
565 for activity
in outbox
:
567 feed
["items"].append(activity
.serialize(request
))
568 except AttributeError:
569 # This occurs because of how we hard-deletion and the object
570 # no longer existing anymore. We want to keep the Activity
571 # in case someone wishes to look it up but we shouldn't display
574 feed
["totalItems"] = len(feed
["items"])
576 return json_response(feed
)
579 def feed_minor_endpoint(request
):
580 """ Outbox for minor activities such as updates """
581 # If it's anything but GET pass it along
582 if request
.method
!= "GET":
583 return feed_endpoint(request
)
585 outbox
= Activity
.query
.filter(
586 (Activity
.verb
== "update") |
(Activity
.verb
== "delete")
588 return feed_endpoint(request
, outbox
=outbox
)
591 def feed_major_endpoint(request
):
592 """ Outbox for all major activities """
593 # If it's anything but a GET pass it along
594 if request
.method
!= "GET":
595 return feed_endpoint(request
)
597 outbox
= Activity
.query
.filter_by(verb
="post")
598 return feed_endpoint(request
, outbox
=outbox
)
601 def object_endpoint(request
):
602 """ Lookup for a object type """
603 object_type
= request
.matchdict
["object_type"]
605 object_id
= request
.matchdict
["id"]
607 error
= "Invalid object ID '{0}' for '{1}'".format(
608 request
.matchdict
["id"],
611 return json_error(error
)
613 if object_type
not in ["image"]:
614 # not sure why this is 404, maybe ask evan. Maybe 400?
616 "Unknown type: {0}".format(object_type
),
620 media
= MediaEntry
.query
.filter_by(id=object_id
).first()
623 "Can't find '{0}' with ID '{1}'".format(object_type
, object_id
),
627 return json_response(media
.serialize(request
))
630 def object_comments(request
):
631 """ Looks up for the comments on a object """
632 media
= MediaEntry
.query
.filter_by(id=request
.matchdict
["id"]).first()
634 return json_error("Can't find '{0}' with ID '{1}'".format(
635 request
.matchdict
["object_type"],
636 request
.matchdict
["id"]
639 comments
= media
.serialize(request
)
640 comments
= comments
.get("replies", {
643 "url": request
.urlgen(
644 "mediagoblin.federation.object.comments",
645 object_type
=media
.object_type
,
651 comments
["displayName"] = "Replies to {0}".format(comments
["url"])
652 comments
["links"] = {
653 "first": comments
["url"],
654 "self": comments
["url"],
656 return json_response(comments
)
659 # RFC6415 - Web Host Metadata
661 def host_meta(request
):
663 This provides the host-meta URL information that is outlined
664 in RFC6415. By default this should provide XRD+XML however
665 if the client accepts JSON we will provide that over XRD+XML.
666 The 'Accept' header is used to decude this.
668 A client should use this endpoint to determine what URLs to
669 use for OAuth endpoints.
675 "type": "application/json",
676 "href": request
.urlgen(
677 "mediagoblin.webfinger.well-known.webfinger",
682 "rel": "registration_endpoint",
683 "href": request
.urlgen(
684 "mediagoblin.oauth.client_register",
689 "rel": "http://apinamespace.org/oauth/request_token",
690 "href": request
.urlgen(
691 "mediagoblin.oauth.request_token",
696 "rel": "http://apinamespace.org/oauth/authorize",
697 "href": request
.urlgen(
698 "mediagoblin.oauth.authorize",
703 "rel": "http://apinamespace.org/oauth/access_token",
704 "href": request
.urlgen(
705 "mediagoblin.oauth.access_token",
710 "rel": "http://apinamespace.org/activitypub/whoami",
711 "href": request
.urlgen(
712 "mediagoblin.webfinger.whoami",
718 if "application/json" in request
.accept_mimetypes
:
719 return json_response({"links": links
})
722 return render_to_response(
724 "mediagoblin/federation/host-meta.xml",
726 mimetype
="application/xrd+xml"
729 def lrdd_lookup(request
):
731 This is the lrdd endpoint which can lookup a user (or
732 other things such as activities). This is as specified by
735 The cleint must provide a 'resource' as a GET parameter which
736 should be the query to be looked up.
739 if "resource" not in request
.args
:
740 return json_error("No resource parameter", status
=400)
742 resource
= request
.args
["resource"]
745 # Lets pull out the username
746 resource
= resource
[5:] if resource
.startswith("acct:") else resource
747 username
, host
= resource
.split("@", 1)
749 # Now lookup the user
750 user
= User
.query
.filter_by(username
=username
).first()
754 "Can't find 'user' with username '{0}'".format(username
))
756 return json_response([
758 "rel": "http://webfinger.net/rel/profile-page",
759 "href": user
.url_for_self(request
.urlgen
),
764 "href": request
.urlgen(
765 "mediagoblin.federation.user",
766 username
=user
.username
,
771 "rel": "activity-outbox",
772 "href": request
.urlgen(
773 "mediagoblin.federation.feed",
774 username
=user
.username
,
780 return json_error("Unrecognized resource parameter", status
=404)
784 """ /api/whoami - HTTP redirect to API profile """
785 if request
.user
is None:
786 return json_error("Not logged in.", status
=401)
788 profile
= request
.urlgen(
789 "mediagoblin.federation.user.profile",
790 username
=request
.user
.username
,
794 return redirect(request
, location
=profile
)
796 @require_active_login
797 def activity_view(request
):
798 """ /<username>/activity/<id> - Display activity
800 This should display a HTML presentation of the activity
801 this is NOT an API endpoint.
803 # Get the user object.
804 username
= request
.matchdict
["username"]
805 user
= User
.query
.filter_by(username
=username
).first()
807 activity_id
= request
.matchdict
["id"]
809 if request
.user
is None:
810 return render_404(request
)
812 activity
= Activity
.query
.filter_by(
817 return render_404(request
)
819 return render_to_response(
821 "mediagoblin/federation/activity.html",
822 {"activity": activity
}