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
.api
.decorators
import user_has_privilege
25 from mediagoblin
.db
.models
import User
, LocalUser
, MediaEntry
, Comment
, TextComment
, 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
= LocalUser
.query
.filter(LocalUser
.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
= LocalUser
.query
.filter(LocalUser
.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"]
119 if "X-File-Name" in request
.headers
:
120 filename
= request
.headers
["X-File-Name"]
122 filename
= mimetypes
.guess_all_extensions(mimetype
)
123 filename
= 'unknown' + filename
[0] if filename
else filename
125 file_data
= FileStorage(
126 stream
=io
.BytesIO(request
.data
),
128 content_type
=mimetype
132 entry
= new_upload_entry(request
.user
)
133 entry
.media_type
= IMAGE_MEDIA_TYPE
134 return api_upload_request(request
, file_data
, entry
)
136 return json_error("Not yet implemented", 501)
140 def inbox_endpoint(request
, inbox
=None):
141 """ This is the user's inbox
143 Currently because we don't have the ability to represent the inbox in the
144 database this is not a "real" inbox in the pump.io/Activity streams 1.0
145 sense but instead just gives back all the data on the website
147 inbox: allows you to pass a query in to limit inbox scope
149 username
= request
.matchdict
["username"]
150 user
= LocalUser
.query
.filter(LocalUser
.username
==username
).first()
153 return json_error("No such 'user' with id '{0}'".format(username
), 404)
156 # Only the user who's authorized should be able to read their inbox
157 if user
.id != request
.user
.id:
159 "Only '{0}' can read this inbox.".format(user
.username
),
164 inbox
= Activity
.query
166 # Count how many items for the "totalItems" field
167 total_items
= inbox
.count()
169 # We want to make a query for all media on the site and then apply GET
170 # limits where we can.
171 inbox
= inbox
.order_by(Activity
.published
.desc())
173 # Limit by the "count" (default: 20)
175 limit
= int(request
.args
.get("count", 20))
179 # Prevent the count being too big (pump uses 200 so we shall)
180 limit
= limit
if limit
<= 200 else 200
183 inbox
= inbox
.limit(limit
)
185 # Offset (default: no offset - first <count> results)
186 inbox
= inbox
.offset(request
.args
.get("offset", 0))
188 # build the inbox feed
190 "displayName": "Activities for {0}".format(user
.username
),
191 "author": user
.serialize(request
),
192 "objectTypes": ["activity"],
193 "url": request
.base_url
,
194 "links": {"self": {"href": request
.url
}},
196 "totalItems": total_items
,
199 for activity
in inbox
:
201 feed
["items"].append(activity
.serialize(request
))
202 except AttributeError:
203 # As with the feed endpint this occurs because of how we our
204 # hard-deletion method. Some activites might exist where the
205 # Activity object and/or target no longer exist, for this case we
206 # should just skip them.
209 return json_response(feed
)
213 def inbox_minor_endpoint(request
):
214 """ Inbox subset for less important Activities """
215 inbox
= Activity
.query
.filter(
216 (Activity
.verb
== "update") |
(Activity
.verb
== "delete")
219 return inbox_endpoint(request
=request
, inbox
=inbox
)
223 def inbox_major_endpoint(request
):
224 """ Inbox subset for most important Activities """
225 inbox
= Activity
.query
.filter_by(verb
="post")
226 return inbox_endpoint(request
=request
, inbox
=inbox
)
230 def feed_endpoint(request
, outbox
=None):
231 """ Handles the user's outbox - /api/user/<username>/feed """
232 username
= request
.matchdict
["username"]
233 requested_user
= LocalUser
.query
.filter(LocalUser
.username
==username
).first()
235 # check if the user exists
236 if requested_user
is None:
237 return json_error("No such 'user' with id '{0}'".format(username
), 404)
240 data
= json
.loads(request
.data
.decode())
242 data
= {"verb": None, "object": {}}
245 if request
.method
in ["POST", "PUT"]:
246 # Validate that the activity is valid
247 if "verb" not in data
or "object" not in data
:
248 return json_error("Invalid activity provided.")
250 # Check that the verb is valid
251 if data
["verb"] not in ["post", "update", "delete"]:
252 return json_error("Verb not yet implemented", 501)
254 # We need to check that the user they're posting to is
255 # the person that they are.
256 if requested_user
.id != request
.user
.id:
258 "Not able to post to another users feed.",
263 if data
["verb"] == "post":
264 obj
= data
.get("object", None)
266 return json_error("Could not find 'object' element.")
268 if obj
.get("objectType", None) == "comment":
270 if not request
.user
.has_privilege(u
'commenter'):
272 "Privilege 'commenter' required to comment.",
276 comment
= TextComment(actor
=request
.user
.id)
277 comment
.unserialize(data
["object"], request
)
280 # Create activity for comment
281 generator
= create_generator(request
)
282 activity
= create_activity(
286 target
=comment
.get_reply_to(),
290 return json_response(activity
.serialize(request
))
292 elif obj
.get("objectType", None) == "image":
293 # Posting an image to the feed
294 media_id
= extract_url_arguments(
295 url
=data
["object"]["id"],
296 urlmap
=request
.app
.url_map
300 public_id
= request
.urlgen(
301 "mediagoblin.api.object",
302 object_type
=obj
["objectType"],
307 media
= MediaEntry
.query
.filter_by(
312 return json_response(
313 "No such 'image' with id '{0}'".format(media_id
),
317 if media
.actor
!= request
.user
.id:
319 "Privilege 'commenter' required to comment.",
324 if not media
.unserialize(data
["object"]):
326 "Invalid 'image' with id '{0}'".format(media_id
)
330 # Add location if one exists
331 if "location" in data
:
332 Location
.create(data
["location"], self
)
335 activity
= api_add_to_feed(request
, media
)
337 return json_response(activity
.serialize(request
))
339 elif obj
.get("objectType", None) is None:
340 # They need to tell us what type of object they're giving us.
341 return json_error("No objectType specified.")
343 # Oh no! We don't know about this type of object (yet)
344 object_type
= obj
.get("objectType", None)
346 "Unknown object type '{0}'.".format(object_type
)
349 # Updating existing objects
350 if data
["verb"] == "update":
351 # Check we've got a valid object
352 obj
= data
.get("object", None)
355 return json_error("Could not find 'object' element.")
357 if "objectType" not in obj
:
358 return json_error("No objectType specified.")
361 return json_error("Object ID has not been specified.")
363 obj_id
= extract_url_arguments(
365 urlmap
=request
.app
.url_map
368 public_id
= request
.urlgen(
369 "mediagoblin.api.object",
370 object_type
=obj
["objectType"],
375 # Now try and find object
376 if obj
["objectType"] == "comment":
377 if not request
.user
.has_privilege(u
'commenter'):
379 "Privilege 'commenter' required to comment.",
383 comment
= TextComment
.query
.filter_by(
388 "No such 'comment' with id '{0}'.".format(obj_id
)
391 # Check that the person trying to update the comment is
392 # the author of the comment.
393 if comment
.actor
!= request
.user
.id:
395 "Only author of comment is able to update comment.",
399 if not comment
.unserialize(data
["object"], request
):
401 "Invalid 'comment' with id '{0}'".format(obj
["id"])
406 # Create an update activity
407 generator
= create_generator(request
)
408 activity
= create_activity(
415 return json_response(activity
.serialize(request
))
417 elif obj
["objectType"] == "image":
418 image
= MediaEntry
.query
.filter_by(
423 "No such 'image' with the id '{0}'.".format(obj
["id"])
426 # Check that the person trying to update the comment is
427 # the author of the comment.
428 if image
.actor
!= request
.user
.id:
430 "Only uploader of image is able to update image.",
434 if not image
.unserialize(obj
):
436 "Invalid 'image' with id '{0}'".format(obj_id
)
438 image
.generate_slug()
441 # Create an update activity
442 generator
= create_generator(request
)
443 activity
= create_activity(
450 return json_response(activity
.serialize(request
))
451 elif obj
["objectType"] == "person":
452 # check this is the same user
453 if "id" not in obj
or obj
["id"] != requested_user
.id:
455 "Incorrect user id, unable to update"
458 requested_user
.unserialize(obj
)
459 requested_user
.save()
461 generator
= create_generator(request
)
462 activity
= create_activity(
469 return json_response(activity
.serialize(request
))
471 elif data
["verb"] == "delete":
472 obj
= data
.get("object", None)
474 return json_error("Could not find 'object' element.")
476 if "objectType" not in obj
:
477 return json_error("No objectType specified.")
480 return json_error("Object ID has not been specified.")
482 # Parse out the object ID
483 obj_id
= extract_url_arguments(
485 urlmap
=request
.app
.url_map
488 public_id
= request
.urlgen(
489 "mediagoblin.api.object",
490 object_type
=obj
["objectType"],
495 if obj
.get("objectType", None) == "comment":
496 # Find the comment asked for
497 comment
= TextComment
.query
.filter_by(
499 actor
=request
.user
.id
504 "No such 'comment' with id '{0}'.".format(obj_id
)
507 # Make a delete activity
508 generator
= create_generator(request
)
509 activity
= create_activity(
516 # Unfortunately this has to be done while hard deletion exists
517 context
= activity
.serialize(request
)
519 # now we can delete the comment
522 return json_response(context
)
524 if obj
.get("objectType", None) == "image":
526 entry
= MediaEntry
.query
.filter_by(
528 actor
=request
.user
.id
533 "No such 'image' with id '{0}'.".format(obj_id
)
536 # Make the delete activity
537 generator
= create_generator(request
)
538 activity
= create_activity(
545 # This is because we have hard deletion
546 context
= activity
.serialize(request
)
548 # Now we can delete the image
551 return json_response(context
)
553 elif request
.method
!= "GET":
555 "Unsupported HTTP method {0}".format(request
.method
),
560 "displayName": "Activities by {user}@{host}".format(
561 user
=request
.user
.username
,
564 "objectTypes": ["activity"],
565 "url": request
.base_url
,
566 "links": {"self": {"href": request
.url
}},
567 "author": request
.user
.serialize(request
),
573 outbox
= Activity
.query
.filter_by(actor
=requested_user
.id)
575 outbox
= outbox
.filter_by(actor
=requested_user
.id)
577 # We want the newest things at the top (issue: #1055)
578 outbox
= outbox
.order_by(Activity
.published
.desc())
580 # Limit by the "count" (default: 20)
581 limit
= request
.args
.get("count", 20)
588 # The upper most limit should be 200
589 limit
= limit
if limit
< 200 else 200
592 outbox
= outbox
.limit(limit
)
594 # Offset (default: no offset - first <count> result)
595 offset
= request
.args
.get("offset", 0)
600 outbox
= outbox
.offset(offset
)
603 for activity
in outbox
:
605 feed
["items"].append(activity
.serialize(request
))
606 except AttributeError:
607 # This occurs because of how we hard-deletion and the object
608 # no longer existing anymore. We want to keep the Activity
609 # in case someone wishes to look it up but we shouldn't display
612 feed
["totalItems"] = len(feed
["items"])
614 return json_response(feed
)
617 def feed_minor_endpoint(request
):
618 """ Outbox for minor activities such as updates """
619 # If it's anything but GET pass it along
620 if request
.method
!= "GET":
621 return feed_endpoint(request
)
623 outbox
= Activity
.query
.filter(
624 (Activity
.verb
== "update") |
(Activity
.verb
== "delete")
626 return feed_endpoint(request
, outbox
=outbox
)
629 def feed_major_endpoint(request
):
630 """ Outbox for all major activities """
631 # If it's anything but a GET pass it along
632 if request
.method
!= "GET":
633 return feed_endpoint(request
)
635 outbox
= Activity
.query
.filter_by(verb
="post")
636 return feed_endpoint(request
, outbox
=outbox
)
639 def object_endpoint(request
):
640 """ Lookup for a object type """
641 object_type
= request
.matchdict
["object_type"]
643 object_id
= request
.matchdict
["id"]
645 error
= "Invalid object ID '{0}' for '{1}'".format(
646 request
.matchdict
["id"],
649 return json_error(error
)
651 if object_type
not in ["image"]:
652 # not sure why this is 404, maybe ask evan. Maybe 400?
654 "Unknown type: {0}".format(object_type
),
658 public_id
= request
.urlgen(
659 "mediagoblin.api.object",
660 object_type
=object_type
,
665 media
= MediaEntry
.query
.filter_by(public_id
=public_id
).first()
668 "Can't find '{0}' with ID '{1}'".format(object_type
, object_id
),
672 return json_response(media
.serialize(request
))
675 def object_comments(request
):
676 """ Looks up for the comments on a object """
677 public_id
= request
.urlgen(
678 "mediagoblin.api.object",
679 object_type
=request
.matchdict
["object_type"],
680 id=request
.matchdict
["id"],
683 media
= MediaEntry
.query
.filter_by(public_id
=public_id
).first()
685 return json_error("Can't find '{0}' with ID '{1}'".format(
686 request
.matchdict
["object_type"],
687 request
.matchdict
["id"]
690 comments
= media
.serialize(request
)
691 comments
= comments
.get("replies", {
694 "url": request
.urlgen(
695 "mediagoblin.api.object.comments",
696 object_type
=media
.object_type
,
702 comments
["displayName"] = "Replies to {0}".format(comments
["url"])
703 comments
["links"] = {
704 "first": comments
["url"],
705 "self": comments
["url"],
707 return json_response(comments
)
710 # RFC6415 - Web Host Metadata
712 def host_meta(request
):
714 This provides the host-meta URL information that is outlined
715 in RFC6415. By default this should provide XRD+XML however
716 if the client accepts JSON we will provide that over XRD+XML.
717 The 'Accept' header is used to decude this.
719 A client should use this endpoint to determine what URLs to
720 use for OAuth endpoints.
726 "type": "application/json",
727 "href": request
.urlgen(
728 "mediagoblin.webfinger.well-known.webfinger",
733 "rel": "registration_endpoint",
734 "href": request
.urlgen(
735 "mediagoblin.oauth.client_register",
740 "rel": "http://apinamespace.org/oauth/request_token",
741 "href": request
.urlgen(
742 "mediagoblin.oauth.request_token",
747 "rel": "http://apinamespace.org/oauth/authorize",
748 "href": request
.urlgen(
749 "mediagoblin.oauth.authorize",
754 "rel": "http://apinamespace.org/oauth/access_token",
755 "href": request
.urlgen(
756 "mediagoblin.oauth.access_token",
761 "rel": "http://apinamespace.org/activitypub/whoami",
762 "href": request
.urlgen(
763 "mediagoblin.webfinger.whoami",
769 if "application/json" in request
.accept_mimetypes
:
770 return json_response({"links": links
})
773 return render_to_response(
775 "mediagoblin/api/host-meta.xml",
777 mimetype
="application/xrd+xml"
780 def lrdd_lookup(request
):
782 This is the lrdd endpoint which can lookup a user (or
783 other things such as activities). This is as specified by
786 The cleint must provide a 'resource' as a GET parameter which
787 should be the query to be looked up.
790 if "resource" not in request
.args
:
791 return json_error("No resource parameter", status
=400)
793 resource
= request
.args
["resource"]
796 # Lets pull out the username
797 resource
= resource
[5:] if resource
.startswith("acct:") else resource
798 username
, host
= resource
.split("@", 1)
800 # Now lookup the user
801 user
= LocalUser
.query
.filter(LocalUser
.username
==username
).first()
805 "Can't find 'user' with username '{0}'".format(username
))
807 return json_response([
809 "rel": "http://webfinger.net/rel/profile-page",
810 "href": user
.url_for_self(request
.urlgen
),
815 "href": request
.urlgen(
816 "mediagoblin.api.user",
817 username
=user
.username
,
822 "rel": "activity-outbox",
823 "href": request
.urlgen(
824 "mediagoblin.api.feed",
825 username
=user
.username
,
831 return json_error("Unrecognized resource parameter", status
=404)
835 """ /api/whoami - HTTP redirect to API profile """
836 if request
.user
is None:
837 return json_error("Not logged in.", status
=401)
839 profile
= request
.urlgen(
840 "mediagoblin.api.user.profile",
841 username
=request
.user
.username
,
845 return redirect(request
, location
=profile
)