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"]
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
= LocalUser
.query
.filter(LocalUser
.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
= LocalUser
.query
.filter(LocalUser
.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
= TextComment(actor
=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_reply_to(),
285 return json_response(activity
.serialize(request
))
287 elif obj
.get("objectType", None) == "image":
288 # Posting an image to the feed
289 media_id
= extract_url_arguments(
290 url
=data
["object"]["id"],
291 urlmap
=request
.app
.url_map
295 public_id
= request
.urlgen(
296 "mediagoblin.api.object",
297 object_type
=obj
["objectType"],
302 media
= MediaEntry
.query
.filter_by(
307 return json_response(
308 "No such 'image' with id '{0}'".format(media_id
),
312 if media
.actor
!= request
.user
.id:
314 "Privilege 'commenter' required to comment.",
319 if not media
.unserialize(data
["object"]):
321 "Invalid 'image' with id '{0}'".format(media_id
)
325 # Add location if one exists
326 if "location" in data
:
327 Location
.create(data
["location"], self
)
330 activity
= api_add_to_feed(request
, media
)
332 return json_response(activity
.serialize(request
))
334 elif obj
.get("objectType", None) is None:
335 # They need to tell us what type of object they're giving us.
336 return json_error("No objectType specified.")
338 # Oh no! We don't know about this type of object (yet)
339 object_type
= obj
.get("objectType", None)
341 "Unknown object type '{0}'.".format(object_type
)
344 # Updating existing objects
345 if data
["verb"] == "update":
346 # Check we've got a valid object
347 obj
= data
.get("object", None)
350 return json_error("Could not find 'object' element.")
352 if "objectType" not in obj
:
353 return json_error("No objectType specified.")
356 return json_error("Object ID has not been specified.")
358 obj_id
= extract_url_arguments(
360 urlmap
=request
.app
.url_map
363 public_id
= request
.urlgen(
364 "mediagoblin.api.object",
365 object_type
=obj
["objectType"],
370 # Now try and find object
371 if obj
["objectType"] == "comment":
372 if not request
.user
.has_privilege(u
'commenter'):
374 "Privilege 'commenter' required to comment.",
378 comment
= TextComment
.query
.filter_by(
383 "No such 'comment' with id '{0}'.".format(obj_id
)
386 # Check that the person trying to update the comment is
387 # the author of the comment.
388 if comment
.actor
!= request
.user
.id:
390 "Only author of comment is able to update comment.",
394 if not comment
.unserialize(data
["object"], request
):
396 "Invalid 'comment' with id '{0}'".format(obj
["id"])
401 # Create an update activity
402 generator
= create_generator(request
)
403 activity
= create_activity(
410 return json_response(activity
.serialize(request
))
412 elif obj
["objectType"] == "image":
413 image
= MediaEntry
.query
.filter_by(
418 "No such 'image' with the id '{0}'.".format(obj
["id"])
421 # Check that the person trying to update the comment is
422 # the author of the comment.
423 if image
.actor
!= request
.user
.id:
425 "Only uploader of image is able to update image.",
429 if not image
.unserialize(obj
):
431 "Invalid 'image' with id '{0}'".format(obj_id
)
433 image
.generate_slug()
436 # Create an update activity
437 generator
= create_generator(request
)
438 activity
= create_activity(
445 return json_response(activity
.serialize(request
))
446 elif obj
["objectType"] == "person":
447 # check this is the same user
448 if "id" not in obj
or obj
["id"] != requested_user
.id:
450 "Incorrect user id, unable to update"
453 requested_user
.unserialize(obj
)
454 requested_user
.save()
456 generator
= create_generator(request
)
457 activity
= create_activity(
464 return json_response(activity
.serialize(request
))
466 elif data
["verb"] == "delete":
467 obj
= data
.get("object", None)
469 return json_error("Could not find 'object' element.")
471 if "objectType" not in obj
:
472 return json_error("No objectType specified.")
475 return json_error("Object ID has not been specified.")
477 # Parse out the object ID
478 obj_id
= extract_url_arguments(
480 urlmap
=request
.app
.url_map
483 public_id
= request
.urlgen(
484 "mediagoblin.api.object",
485 object_type
=obj
["objectType"],
490 if obj
.get("objectType", None) == "comment":
491 # Find the comment asked for
492 comment
= TextComment
.query
.filter_by(
494 actor
=request
.user
.id
499 "No such 'comment' with id '{0}'.".format(obj_id
)
502 # Make a delete activity
503 generator
= create_generator(request
)
504 activity
= create_activity(
511 # Unfortunately this has to be done while hard deletion exists
512 context
= activity
.serialize(request
)
514 # now we can delete the comment
517 return json_response(context
)
519 if obj
.get("objectType", None) == "image":
521 entry
= MediaEntry
.query
.filter_by(
523 actor
=request
.user
.id
528 "No such 'image' with id '{0}'.".format(obj_id
)
531 # Make the delete activity
532 generator
= create_generator(request
)
533 activity
= create_activity(
540 # This is because we have hard deletion
541 context
= activity
.serialize(request
)
543 # Now we can delete the image
546 return json_response(context
)
548 elif request
.method
!= "GET":
550 "Unsupported HTTP method {0}".format(request
.method
),
555 "displayName": "Activities by {user}@{host}".format(
556 user
=request
.user
.username
,
559 "objectTypes": ["activity"],
560 "url": request
.base_url
,
561 "links": {"self": {"href": request
.url
}},
562 "author": request
.user
.serialize(request
),
568 outbox
= Activity
.query
.filter_by(actor
=request
.user
.id)
570 outbox
= outbox
.filter_by(actor
=request
.user
.id)
572 # We want the newest things at the top (issue: #1055)
573 outbox
= outbox
.order_by(Activity
.published
.desc())
575 # Limit by the "count" (default: 20)
576 limit
= request
.args
.get("count", 20)
583 # The upper most limit should be 200
584 limit
= limit
if limit
< 200 else 200
587 outbox
= outbox
.limit(limit
)
589 # Offset (default: no offset - first <count> result)
590 outbox
= outbox
.offset(request
.args
.get("offset", 0))
593 for activity
in outbox
:
595 feed
["items"].append(activity
.serialize(request
))
596 except AttributeError:
597 # This occurs because of how we hard-deletion and the object
598 # no longer existing anymore. We want to keep the Activity
599 # in case someone wishes to look it up but we shouldn't display
602 feed
["totalItems"] = len(feed
["items"])
604 return json_response(feed
)
607 def feed_minor_endpoint(request
):
608 """ Outbox for minor activities such as updates """
609 # If it's anything but GET pass it along
610 if request
.method
!= "GET":
611 return feed_endpoint(request
)
613 outbox
= Activity
.query
.filter(
614 (Activity
.verb
== "update") |
(Activity
.verb
== "delete")
616 return feed_endpoint(request
, outbox
=outbox
)
619 def feed_major_endpoint(request
):
620 """ Outbox for all major activities """
621 # If it's anything but a GET pass it along
622 if request
.method
!= "GET":
623 return feed_endpoint(request
)
625 outbox
= Activity
.query
.filter_by(verb
="post")
626 return feed_endpoint(request
, outbox
=outbox
)
629 def object_endpoint(request
):
630 """ Lookup for a object type """
631 object_type
= request
.matchdict
["object_type"]
633 object_id
= request
.matchdict
["id"]
635 error
= "Invalid object ID '{0}' for '{1}'".format(
636 request
.matchdict
["id"],
639 return json_error(error
)
641 if object_type
not in ["image"]:
642 # not sure why this is 404, maybe ask evan. Maybe 400?
644 "Unknown type: {0}".format(object_type
),
648 public_id
= request
.urlgen(
649 "mediagoblin.api.object",
650 object_type
=object_type
,
655 media
= MediaEntry
.query
.filter_by(public_id
=public_id
).first()
658 "Can't find '{0}' with ID '{1}'".format(object_type
, object_id
),
662 return json_response(media
.serialize(request
))
665 def object_comments(request
):
666 """ Looks up for the comments on a object """
667 public_id
= request
.urlgen(
668 "mediagoblin.api.object",
669 object_type
=request
.matchdict
["object_type"],
670 id=request
.matchdict
["id"],
673 media
= MediaEntry
.query
.filter_by(public_id
=public_id
).first()
675 return json_error("Can't find '{0}' with ID '{1}'".format(
676 request
.matchdict
["object_type"],
677 request
.matchdict
["id"]
680 comments
= media
.serialize(request
)
681 comments
= comments
.get("replies", {
684 "url": request
.urlgen(
685 "mediagoblin.api.object.comments",
686 object_type
=media
.object_type
,
692 comments
["displayName"] = "Replies to {0}".format(comments
["url"])
693 comments
["links"] = {
694 "first": comments
["url"],
695 "self": comments
["url"],
697 return json_response(comments
)
700 # RFC6415 - Web Host Metadata
702 def host_meta(request
):
704 This provides the host-meta URL information that is outlined
705 in RFC6415. By default this should provide XRD+XML however
706 if the client accepts JSON we will provide that over XRD+XML.
707 The 'Accept' header is used to decude this.
709 A client should use this endpoint to determine what URLs to
710 use for OAuth endpoints.
716 "type": "application/json",
717 "href": request
.urlgen(
718 "mediagoblin.webfinger.well-known.webfinger",
723 "rel": "registration_endpoint",
724 "href": request
.urlgen(
725 "mediagoblin.oauth.client_register",
730 "rel": "http://apinamespace.org/oauth/request_token",
731 "href": request
.urlgen(
732 "mediagoblin.oauth.request_token",
737 "rel": "http://apinamespace.org/oauth/authorize",
738 "href": request
.urlgen(
739 "mediagoblin.oauth.authorize",
744 "rel": "http://apinamespace.org/oauth/access_token",
745 "href": request
.urlgen(
746 "mediagoblin.oauth.access_token",
751 "rel": "http://apinamespace.org/activitypub/whoami",
752 "href": request
.urlgen(
753 "mediagoblin.webfinger.whoami",
759 if "application/json" in request
.accept_mimetypes
:
760 return json_response({"links": links
})
763 return render_to_response(
765 "mediagoblin/api/host-meta.xml",
767 mimetype
="application/xrd+xml"
770 def lrdd_lookup(request
):
772 This is the lrdd endpoint which can lookup a user (or
773 other things such as activities). This is as specified by
776 The cleint must provide a 'resource' as a GET parameter which
777 should be the query to be looked up.
780 if "resource" not in request
.args
:
781 return json_error("No resource parameter", status
=400)
783 resource
= request
.args
["resource"]
786 # Lets pull out the username
787 resource
= resource
[5:] if resource
.startswith("acct:") else resource
788 username
, host
= resource
.split("@", 1)
790 # Now lookup the user
791 user
= LocalUser
.query
.filter(LocalUser
.username
==username
).first()
795 "Can't find 'user' with username '{0}'".format(username
))
797 return json_response([
799 "rel": "http://webfinger.net/rel/profile-page",
800 "href": user
.url_for_self(request
.urlgen
),
805 "href": request
.urlgen(
806 "mediagoblin.api.user",
807 username
=user
.username
,
812 "rel": "activity-outbox",
813 "href": request
.urlgen(
814 "mediagoblin.api.feed",
815 username
=user
.username
,
821 return json_error("Unrecognized resource parameter", status
=404)
825 """ /api/whoami - HTTP redirect to API profile """
826 if request
.user
is None:
827 return json_error("Not logged in.", status
=401)
829 profile
= request
.urlgen(
830 "mediagoblin.api.user.profile",
831 username
=request
.user
.username
,
835 return redirect(request
, location
=profile
)