aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJessica Tallon <jessica@megworld.co.uk>2014-07-23 00:23:23 +0100
committerJessica Tallon <jessica@megworld.co.uk>2014-07-23 00:23:23 +0100
commitaab62394773c27e73996e8cb8fb425db3fa62d49 (patch)
tree5f7e8a14f557f9917179fc8f2d4e9987ef98097a
parent59ff4790c9f33fe13ee0a411d4152bd256eaa06a (diff)
parenta14d90c2db5ff96bdd72009a07f1afc0e8ef3595 (diff)
downloadmediagoblin-aab62394773c27e73996e8cb8fb425db3fa62d49.tar.lz
mediagoblin-aab62394773c27e73996e8cb8fb425db3fa62d49.tar.xz
mediagoblin-aab62394773c27e73996e8cb8fb425db3fa62d49.zip
Merge branch 'Federation'
-rw-r--r--docs/source/api/media.rst155
-rw-r--r--docs/source/api/media_interaction.rst65
-rwxr-xr-xlazystarter.sh2
-rw-r--r--mediagoblin.ini4
-rw-r--r--mediagoblin/db/migrations.py15
-rw-r--r--mediagoblin/db/mixin.py11
-rw-r--r--mediagoblin/db/models.py156
-rw-r--r--mediagoblin/decorators.py13
-rw-r--r--mediagoblin/federation/__init__.py (renamed from mediagoblin/webfinger/routing.py)8
-rw-r--r--mediagoblin/federation/decorators.py51
-rw-r--r--mediagoblin/federation/routing.py79
-rwxr-xr-x[-rw-r--r--]mediagoblin/federation/task.py (renamed from mediagoblin/webfinger/__init__.py)44
-rw-r--r--mediagoblin/federation/views.py380
-rw-r--r--mediagoblin/init/celery/__init__.py13
-rw-r--r--mediagoblin/media_types/image/__init__.py37
-rw-r--r--mediagoblin/oauth/oauth.py18
-rw-r--r--mediagoblin/oauth/routing.py8
-rw-r--r--mediagoblin/oauth/views.py3
-rw-r--r--mediagoblin/routing.py3
-rw-r--r--mediagoblin/tests/test_api.py307
-rw-r--r--mediagoblin/tests/test_legacy_api.py93
-rw-r--r--mediagoblin/tests/test_oauth1.py9
-rw-r--r--mediagoblin/tests/tools.py6
-rw-r--r--mediagoblin/tools/request.py20
-rw-r--r--mediagoblin/webfinger/views.py117
25 files changed, 1389 insertions, 228 deletions
diff --git a/docs/source/api/media.rst b/docs/source/api/media.rst
new file mode 100644
index 00000000..bafe43d3
--- /dev/null
+++ b/docs/source/api/media.rst
@@ -0,0 +1,155 @@
+.. MediaGoblin Documentation
+
+ Written in 2011, 2012 by MediaGoblin contributors
+
+ To the extent possible under law, the author(s) have dedicated all
+ copyright and related and neighboring rights to this software to
+ the public domain worldwide. This software is distributed without
+ any warranty.
+
+ You should have received a copy of the CC0 Public Domain
+ Dedication along with this software. If not, see
+ <http://creativecommons.org/publicdomain/zero/1.0/>.
+
+.. info:: Currently only image uploading is supported.
+
+===============
+Uploading Media
+===============
+
+To use any the APIs mentioned in this document you will required :doc:`oauth`
+
+Uploading and posting an media requiest you to make two to three requests:
+
+1) Uploads the data to the server
+2) Post media to feed
+3) Update media to have title, description, license, etc. (optional)
+
+These steps could be condenced in the future however currently this is how the
+pump.io API works. There is currently an issue open, if you would like to change
+how this works please contribute upstream: https://github.com/e14n/pump.io/issues/657
+
+----------------------
+Upload Media to Server
+----------------------
+
+To upload media you should use the URI `/api/user/<username>/uploads`.
+
+A POST request should be made to the media upload URI submitting at least two header:
+
+* `Content-Type` - This being a valid mimetype for the media.
+* `Content-Length` - size in bytes of the media.
+
+The media data should be submitted as POST data to the image upload URI.
+You will get back a JSON encoded response which will look similiar to::
+
+ {
+ "updated": "2014-01-11T09:45:48Z",
+ "links": {
+ "self": {
+ "href": "https://<server>/image/4wiBUV1HT8GRqseyvX8m-w"
+ }
+ },
+ "fullImage": {
+ "url": "https://<server>//uploads/<username>/2014/1/11/V3cBMw.jpg",
+ "width": 505,
+ "height": 600
+ },
+ "replies": {
+ "url": "https://<server>//api/image/4wiBUV1HT8GRqseyvX8m-w/replies"
+ },
+ "image": {
+ "url": "https://<server>/uploads/<username>/2014/1/11/V3cBMw_thumb.jpg",
+ "width": 269,
+ "height": 320
+ },
+ "author": {
+ "preferredUsername": "<username>",
+ "displayName": "<username>",
+ "links": {
+ "activity-outbox": {
+ "href": "https://<server>/api/user/<username>/feed"
+ },
+ "self": {
+ "href": "https://<server>/api/user/<username>/profile"
+ },
+ "activity-inbox": {
+ "href": "https://<server>/api/user/<username>/inbox"
+ }
+ },
+ "url": "https://<server>/<username>",
+ "updated": "2013-08-14T10:01:21Z",
+ "id": "acct:<username>@<server>",
+ "objectType": "person"
+ },
+ "url": "https://<server>/<username>/image/4wiBUV1HT8GRqseyvX8m-w",
+ "published": "2014-01-11T09:45:48Z",
+ "id": "https://<server>/api/image/4wiBUV1HT8GRqseyvX8m-w",
+ "objectType": "image"
+ }
+
+The main things in this response is `fullImage` which contains `url` (the URL
+of the original image - i.e. fullsize) and `image` which contains `url` (the URL
+of a thumbnail version).
+
+.. warning:: Media which have been uploaded but not submitted to a feed will
+ periodically be deleted.
+
+--------------
+Submit to feed
+--------------
+
+This is submitting the media to appear on the website. This will create an
+object in your feed which will then appear on the GNU MediaGoblin website so the
+user and others can view and interact with the media.
+
+The URL you need to POST to is `/api/user/<username>/feed`
+
+You first should do a post to the feed URI with some of the information you got
+back from the above request (which uploaded the media). The request should look
+something like::
+
+ {
+ "verb": "post",
+ "object": {
+ "id": "https://<server>/api/image/6_K9m-2NQFi37je845c83w",
+ "objectType": "image"
+ }
+ }
+
+.. warning:: Any other data submitted **will** be ignored
+
+-------------------
+Submitting Metadata
+-------------------
+
+Finally if you wish to set a title, description and license you will need to do
+and update request to the endpoint, the following attributes can be submitted:
+
++--------------+---------------------------------------+-------------------+
+| Name | Description | Required/Optional |
++==============+=======================================+===================+
+| displayName | This is the title for the media | Optional |
++--------------+---------------------------------------+-------------------+
+| content | This is the description for the media | Optional |
++--------------+---------------------------------------+-------------------+
+| license | This is the license to be used | Optional |
++--------------+---------------------------------------+-------------------+
+
+.. note:: license attribute is mediagoblin specific, pump.io does not support this attribute
+
+
+The update request should look something similiar to::
+
+ {
+ "verb": "update",
+ "object": {
+ "displayName": "My super awesome image!",
+ "content": "The awesome image I took while backpacking to modor",
+ "license": "creativecommons.org/licenses/by-sa/3.0/",
+ "id": "https://<server>/api/image/6_K9m-2NQFi37je845c83w",
+ "objectType": "image"
+ }
+ }
+
+.. warning:: Any other data submitted **will** be ignored.
diff --git a/docs/source/api/media_interaction.rst b/docs/source/api/media_interaction.rst
new file mode 100644
index 00000000..41114a71
--- /dev/null
+++ b/docs/source/api/media_interaction.rst
@@ -0,0 +1,65 @@
+.. MediaGoblin Documentation
+
+ Written in 2011, 2012 by MediaGoblin contributors
+
+ To the extent possible under law, the author(s) have dedicated all
+ copyright and related and neighboring rights to this software to
+ the public domain worldwide. This software is distributed without
+ any warranty.
+
+ You should have received a copy of the CC0 Public Domain
+ Dedication along with this software. If not, see
+ <http://creativecommons.org/publicdomain/zero/1.0/>.
+
+Pump.io supports a number of different interactions that can happen against
+media. Theser are commenting, liking/favoriting and (re-)sharing. Currently
+MediaGoblin supports just commenting although other interactions will come at
+a later date.
+
+--------------
+How to comment
+--------------
+
+.. warning:: Commenting on a comment currently is NOT supported.
+
+Commenting is done by posting a comment activity to the users feed. The
+activity should look similiar to::
+
+ {
+ "verb": "post",
+ "object": {
+ "objectType": "comment",
+ "inReplyTo": <media>
+ }
+ }
+
+This is where `<media>` is the media object you have got with from the server.
+
+----------------
+Getting comments
+----------------
+
+The media object you get back should have a `replies` section. This should
+be an object which contains the number of replies and if there are any (i.e.
+number of replies > 0) then `items` will include an array of every item::
+
+ {
+ "totalItems": 2,
+ "items: [
+ {
+ "id": 1,
+ "objectType": "comment",
+ "content": "I'm a comment ^_^",
+ "author": <author user object>
+ },
+ {
+ "id": 4,
+ "objectType": "comment",
+ "content": "Another comment! Blimey!",
+ "author": <author user object>
+ }
+ ],
+ "url": "http://some.server/api/images/1/comments/"
+ }
+
+
diff --git a/lazystarter.sh b/lazystarter.sh
index d3770194..41994015 100755
--- a/lazystarter.sh
+++ b/lazystarter.sh
@@ -76,7 +76,7 @@ case "$selfname" in
lazycelery.sh)
MEDIAGOBLIN_CONFIG="${ini_file}" \
CELERY_CONFIG_MODULE=mediagoblin.init.celery.from_celery \
- $starter "$@"
+ $starter -B "$@"
;;
*) exit 1 ;;
esac
diff --git a/mediagoblin.ini b/mediagoblin.ini
index 5e2477a4..6ccfa4f7 100644
--- a/mediagoblin.ini
+++ b/mediagoblin.ini
@@ -23,6 +23,10 @@ allow_registration = true
# Set to false to disable the ability for users to report offensive content
allow_reporting = true
+# Frequency garbage collection will run (setting to 0 or false to disable)
+# Setting units are minutes.
+garbage_collection = 60
+
## Uncomment this to put some user-overriding templates here
# local_templates = %(here)s/user_dev/templates/
diff --git a/mediagoblin/db/migrations.py b/mediagoblin/db/migrations.py
index 59aec4d2..88cda6f1 100644
--- a/mediagoblin/db/migrations.py
+++ b/mediagoblin/db/migrations.py
@@ -25,14 +25,14 @@ from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql import and_
from migrate.changeset.constraint import UniqueConstraint
-
from mediagoblin.db.extratypes import JSONEncoded, MutationDict
from mediagoblin.db.migration_tools import (
RegisterMigration, inspect_table, replace_table_hack)
-from mediagoblin.db.models import (MediaEntry, Collection, MediaComment, User,
- Privilege)
+from mediagoblin.db.models import (MediaEntry, Collection, MediaComment, User,
+ Privilege)
from mediagoblin.db.extratypes import JSONEncoded, MutationDict
+
MIGRATIONS = {}
@@ -466,7 +466,6 @@ def create_oauth1_tables(db):
db.commit()
-
@RegisterMigration(15, MIGRATIONS)
def wants_notifications(db):
"""Add a wants_notifications field to User model"""
@@ -660,8 +659,8 @@ def create_moderation_tables(db):
# admin, an active user or an inactive user ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
for admin_user in admin_users_ids:
admin_user_id = admin_user['id']
- for privilege_id in [admin_privilege_id, uploader_privilege_id,
- reporter_privilege_id, commenter_privilege_id,
+ for privilege_id in [admin_privilege_id, uploader_privilege_id,
+ reporter_privilege_id, commenter_privilege_id,
active_privilege_id]:
db.execute(user_privilege_assoc.insert().values(
core__privilege_id=admin_user_id,
@@ -669,7 +668,7 @@ def create_moderation_tables(db):
for active_user in active_users_ids:
active_user_id = active_user['id']
- for privilege_id in [uploader_privilege_id, reporter_privilege_id,
+ for privilege_id in [uploader_privilege_id, reporter_privilege_id,
commenter_privilege_id, active_privilege_id]:
db.execute(user_privilege_assoc.insert().values(
core__privilege_id=active_user_id,
@@ -677,7 +676,7 @@ def create_moderation_tables(db):
for inactive_user in inactive_users_ids:
inactive_user_id = inactive_user['id']
- for privilege_id in [uploader_privilege_id, reporter_privilege_id,
+ for privilege_id in [uploader_privilege_id, reporter_privilege_id,
commenter_privilege_id]:
db.execute(user_privilege_assoc.insert().values(
core__privilege_id=inactive_user_id,
diff --git a/mediagoblin/db/mixin.py b/mediagoblin/db/mixin.py
index 3d96ba34..87f4383a 100644
--- a/mediagoblin/db/mixin.py
+++ b/mediagoblin/db/mixin.py
@@ -202,6 +202,17 @@ class MediaEntryMixin(GenerateSlugMixin):
thumb_url = mg_globals.app.staticdirector(manager[u'default_thumb'])
return thumb_url
+ @property
+ def original_url(self):
+ """ Returns the URL for the original image
+ will return self.thumb_url if original url doesn't exist"""
+ if u"original" not in self.media_files:
+ return self.thumb_url
+
+ return mg_globals.app.public_store.file_url(
+ self.media_files[u"original"]
+ )
+
@cached_property
def media_manager(self):
"""Returns the MEDIA_MANAGER of the media's media_type
diff --git a/mediagoblin/db/models.py b/mediagoblin/db/models.py
index 4c9345fc..aaceb599 100644
--- a/mediagoblin/db/models.py
+++ b/mediagoblin/db/models.py
@@ -20,6 +20,7 @@ TODO: indexes on foreignkeys, where useful.
import logging
import datetime
+import base64
from sqlalchemy import Column, Integer, Unicode, UnicodeText, DateTime, \
Boolean, ForeignKey, UniqueConstraint, PrimaryKeyConstraint, \
@@ -136,6 +137,48 @@ class User(Base, UserMixin):
return UserBan.query.get(self.id) is not None
+ def serialize(self, request):
+ user = {
+ "id": "acct:{0}@{1}".format(self.username, request.host),
+ "preferredUsername": self.username,
+ "displayName": "{0}@{1}".format(self.username, request.host),
+ "objectType": "person",
+ "pump_io": {
+ "shared": False,
+ "followed": False,
+ },
+ "links": {
+ "self": {
+ "href": request.urlgen(
+ "mediagoblin.federation.user.profile",
+ username=self.username,
+ qualified=True
+ ),
+ },
+ "activity-inbox": {
+ "href": request.urlgen(
+ "mediagoblin.federation.inbox",
+ username=self.username,
+ qualified=True
+ )
+ },
+ "activity-outbox": {
+ "href": request.urlgen(
+ "mediagoblin.federation.feed",
+ username=self.username,
+ qualified=True
+ )
+ },
+ },
+ }
+
+ if self.bio:
+ user.update({"summary": self.bio})
+ if self.url:
+ user.update({"url": self.url})
+
+ return user
+
class Client(Base):
"""
Model representing a client - Used for API Auth
@@ -201,7 +244,6 @@ class NonceTimestamp(Base):
nonce = Column(Unicode, nullable=False, primary_key=True)
timestamp = Column(DateTime, nullable=False, primary_key=True)
-
class MediaEntry(Base, MediaEntryMixin):
"""
TODO: Consider fetching the media_files using join
@@ -388,6 +430,87 @@ class MediaEntry(Base, MediaEntryMixin):
# pass through commit=False/True in kwargs
super(MediaEntry, self).delete(**kwargs)
+ @property
+ def objectType(self):
+ """ Converts media_type to pump-like type - don't use internally """
+ return self.media_type.split(".")[-1]
+
+ def serialize(self, request, show_comments=True):
+ """ Unserialize MediaEntry to object """
+ author = self.get_uploader
+ url = request.urlgen(
+ "mediagoblin.user_pages.media_home",
+ user=author.username,
+ media=self.slug,
+ qualified=True
+ )
+
+ context = {
+ "id": self.id,
+ "author": author.serialize(request),
+ "objectType": self.objectType,
+ "url": url,
+ "image": {
+ "url": request.host_url + self.thumb_url[1:],
+ },
+ "fullImage":{
+ "url": request.host_url + self.original_url[1:],
+ },
+ "published": self.created.isoformat(),
+ "updated": self.created.isoformat(),
+ "pump_io": {
+ "shared": False,
+ },
+ "links": {
+ "self": {
+ "href": request.urlgen(
+ "mediagoblin.federation.object",
+ objectType=self.objectType,
+ id=self.id,
+ qualified=True
+ ),
+ },
+
+ }
+ }
+
+ if self.title:
+ context["displayName"] = self.title
+
+ if self.description:
+ context["content"] = self.description
+
+ if self.license:
+ context["license"] = self.license
+
+ if show_comments:
+ comments = [comment.serialize(request) for comment in self.get_comments()]
+ total = len(comments)
+ context["replies"] = {
+ "totalItems": total,
+ "items": comments,
+ "url": request.urlgen(
+ "mediagoblin.federation.object.comments",
+ objectType=self.objectType,
+ id=self.id,
+ qualified=True
+ ),
+ }
+
+ return context
+
+ def unserialize(self, data):
+ """ Takes API objects and unserializes on existing MediaEntry """
+ if "displayName" in data:
+ self.title = data["displayName"]
+
+ if "content" in data:
+ self.description = data["content"]
+
+ if "license" in data:
+ self.license = data["license"]
+
+ return True
class FileKeynames(Base):
"""
@@ -534,6 +657,37 @@ class MediaComment(Base, MediaCommentMixin):
lazy="dynamic",
cascade="all, delete-orphan"))
+ def serialize(self, request):
+ """ Unserialize to python dictionary for API """
+ media = MediaEntry.query.filter_by(id=self.media_entry).first()
+ author = self.get_author
+ context = {
+ "id": self.id,
+ "objectType": "comment",
+ "content": self.content,
+ "inReplyTo": media.serialize(request, show_comments=False),
+ "author": author.serialize(request)
+ }
+
+ return context
+
+ def unserialize(self, data):
+ """ Takes API objects and unserializes on existing comment """
+ # Do initial checks to verify the object is correct
+ required_attributes = ["content", "inReplyTo"]
+ for attr in required_attributes:
+ if attr not in data:
+ return False
+
+ # Validate inReplyTo has ID
+ if "id" not in data["inReplyTo"]:
+ return False
+
+ self.media_entry = data["inReplyTo"]["id"]
+ self.content = data["content"]
+ return True
+
+
class Collection(Base, CollectionMixin):
"""An 'album' or 'set' of media by a user.
diff --git a/mediagoblin/decorators.py b/mediagoblin/decorators.py
index 8515d091..90edf96b 100644
--- a/mediagoblin/decorators.py
+++ b/mediagoblin/decorators.py
@@ -22,7 +22,7 @@ from oauthlib.oauth1 import ResourceEndpoint
from mediagoblin import mg_globals as mgg
from mediagoblin import messages
-from mediagoblin.db.models import MediaEntry, User, MediaComment
+from mediagoblin.db.models import MediaEntry, User, MediaComment, AccessToken
from mediagoblin.tools.response import (
redirect, render_404,
render_user_banned, json_response)
@@ -401,10 +401,10 @@ def oauth_required(controller):
request_validator = GMGRequestValidator()
resource_endpoint = ResourceEndpoint(request_validator)
- valid, request = resource_endpoint.validate_protected_resource_request(
+ valid, r = resource_endpoint.validate_protected_resource_request(
uri=request.url,
http_method=request.method,
- body=request.get_data(),
+ body=request.data,
headers=dict(request.headers),
)
@@ -412,6 +412,13 @@ def oauth_required(controller):
error = "Invalid oauth prarameter."
return json_response({"error": error}, status=400)
+ # Fill user if not already
+ token = authorization[u"oauth_token"]
+ access_token = AccessToken.query.filter_by(token=token).first()
+ if access_token is not None and request.user is None:
+ user_id = access_token.user
+ request.user = User.query.filter_by(id=user_id).first()
+
return controller(request, *args, **kwargs)
return wrapper
diff --git a/mediagoblin/webfinger/routing.py b/mediagoblin/federation/__init__.py
index eb10509f..621845ba 100644
--- a/mediagoblin/webfinger/routing.py
+++ b/mediagoblin/federation/__init__.py
@@ -13,11 +13,3 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-from mediagoblin.tools.routing import add_route
-
-add_route('mediagoblin.webfinger.host_meta', '/.well-known/host-meta',
- 'mediagoblin.webfinger.views:host_meta')
-
-add_route('mediagoblin.webfinger.xrd', '/webfinger/xrd',
- 'mediagoblin.webfinger.views:xrd')
diff --git a/mediagoblin/federation/decorators.py b/mediagoblin/federation/decorators.py
new file mode 100644
index 00000000..f515af42
--- /dev/null
+++ b/mediagoblin/federation/decorators.py
@@ -0,0 +1,51 @@
+# GNU MediaGoblin -- federated, autonomous media hosting
+# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+from functools import wraps
+
+from mediagoblin.db.models import User
+from mediagoblin.decorators import require_active_login
+from mediagoblin.tools.response import json_response
+
+def user_has_privilege(privilege_name):
+ """
+ Requires that a user have a particular privilege in order to access a page.
+ In order to require that a user have multiple privileges, use this
+ decorator twice on the same view. This decorator also makes sure that the
+ user is not banned, or else it redirects them to the "You are Banned" page.
+
+ :param privilege_name A unicode object that is that represents
+ the privilege object. This object is
+ the name of the privilege, as assigned
+ in the Privilege.privilege_name column
+ """
+
+ def user_has_privilege_decorator(controller):
+ @wraps(controller)
+ @require_active_login
+ def wrapper(request, *args, **kwargs):
+ user_id = request.user.id
+ if not request.user.has_privilege(privilege_name):
+ error = "User '{0}' needs '{1}' privilege".format(
+ request.user.username,
+ privilege_name
+ )
+ return json_response({"error": error}, status=403)
+
+ return controller(request, *args, **kwargs)
+
+ return wrapper
+ return user_has_privilege_decorator
+
diff --git a/mediagoblin/federation/routing.py b/mediagoblin/federation/routing.py
new file mode 100644
index 00000000..2993b388
--- /dev/null
+++ b/mediagoblin/federation/routing.py
@@ -0,0 +1,79 @@
+# GNU MediaGoblin -- federated, autonomous media hosting
+# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from mediagoblin.tools.routing import add_route
+
+# Add user profile
+add_route(
+ "mediagoblin.federation.user",
+ "/api/user/<string:username>/",
+ "mediagoblin.federation.views:user"
+)
+
+add_route(
+ "mediagoblin.federation.user.profile",
+ "/api/user/<string:username>/profile",
+ "mediagoblin.federation.views:profile"
+)
+
+# Inbox and Outbox (feed)
+add_route(
+ "mediagoblin.federation.feed",
+ "/api/user/<string:username>/feed",
+ "mediagoblin.federation.views:feed"
+)
+
+add_route(
+ "mediagoblin.federation.user.uploads",
+ "/api/user/<string:username>/uploads",
+ "mediagoblin.federation.views:uploads"
+)
+
+add_route(
+ "mediagoblin.federation.inbox",
+ "/api/user/<string:username>/inbox",
+ "mediagoblin.federation.views:feed"
+)
+
+# object endpoints
+add_route(
+ "mediagoblin.federation.object",
+ "/api/<string:objectType>/<string:id>",
+ "mediagoblin.federation.views:object"
+ )
+add_route(
+ "mediagoblin.federation.object.comments",
+ "/api/<string:objectType>/<string:id>/comments",
+ "mediagoblin.federation.views:object_comments"
+)
+
+add_route(
+ "mediagoblin.webfinger.well-known.host-meta",
+ "/.well-known/host-meta",
+ "mediagoblin.federation.views:host_meta"
+)
+
+add_route(
+ "mediagoblin.webfinger.well-known.host-meta.json",
+ "/.well-known/host-meta.json",
+ "mediagoblin.federation.views:host_meta"
+)
+
+add_route(
+ "mediagoblin.webfinger.whoami",
+ "/api/whoami",
+ "mediagoblin.federation.views:whoami"
+)
diff --git a/mediagoblin/webfinger/__init__.py b/mediagoblin/federation/task.py
index 126e6ea2..1d42e851 100644..100755
--- a/mediagoblin/webfinger/__init__.py
+++ b/mediagoblin/federation/task.py
@@ -13,13 +13,37 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
-'''
-mediagoblin.webfinger_ provides an LRDD discovery service and
-a web host meta information file
-
-Links:
-- `LRDD Discovery Draft
- <http://tools.ietf.org/html/draft-hammer-discovery-06>`_.
-- `RFC 6415 - Web Host Metadata
- <http://tools.ietf.org/html/rfc6415>`_.
-'''
+
+import celery
+import datetime
+import logging
+import pytz
+
+from mediagoblin.db.models import MediaEntry
+
+_log = logging.getLogger(__name__)
+logging.basicConfig()
+_log.setLevel(logging.DEBUG)
+
+@celery.task()
+def collect_garbage():
+ """
+ Garbage collection to clean up media
+
+ This will look for all critera on models to clean
+ up. This is primerally written to clean up media that's
+ entered a erroneous state.
+ """
+ _log.info("Garbage collection is running.")
+ now = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=1)
+
+ garbage = MediaEntry.query.filter(MediaEntry.created > now)
+ garbage = garbage.filter(MediaEntry.state == "unprocessed")
+
+ for entry in garbage.all():
+ _log.info("Garbage media found with ID '{0}'".format(entry.id))
+ entry.delete()
+
+
+
+
diff --git a/mediagoblin/federation/views.py b/mediagoblin/federation/views.py
new file mode 100644
index 00000000..86670857
--- /dev/null
+++ b/mediagoblin/federation/views.py
@@ -0,0 +1,380 @@
+# GNU MediaGoblin -- federated, autonomous media hosting
+# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import json
+import io
+import mimetypes
+
+from werkzeug.datastructures import FileStorage
+
+from mediagoblin.media_types import sniff_media
+from mediagoblin.decorators import oauth_required
+from mediagoblin.federation.decorators import user_has_privilege
+from mediagoblin.db.models import User, MediaEntry, MediaComment
+from mediagoblin.tools.response import redirect, json_response
+from mediagoblin.meddleware.csrf import csrf_exempt
+from mediagoblin.submit.lib import new_upload_entry
+
+@oauth_required
+def profile(request, raw=False):
+ """ This is /api/user/<username>/profile - This will give profile info """
+ user = request.matchdict["username"]
+ requested_user = User.query.filter_by(username=user)
+
+ # check if the user exists
+ if requested_user is None:
+ error = "No such 'user' with id '{0}'".format(user)
+ return json_response({"error": error}, status=404)
+
+ user = requested_user[0]
+
+ if raw:
+ return (user, user.serialize(request))
+
+ # user profiles are public so return information
+ return json_response(user.serialize(request))
+
+@oauth_required
+def user(request):
+ """ This is /api/user/<username> - This will get the user """
+ user, user_profile = profile(request, raw=True)
+ data = {
+ "nickname": user.username,
+ "updated": user.created.isoformat(),
+ "published": user.created.isoformat(),
+ "profile": user_profile,
+ }
+
+ return json_response(data)
+
+@oauth_required
+@csrf_exempt
+@user_has_privilege(u'uploader')
+def uploads(request):
+ """ Endpoint for file uploads """
+ user = request.matchdict["username"]
+ requested_user = User.query.filter_by(username=user)
+
+ if requested_user is None:
+ error = "No such 'user' with id '{0}'".format(user)
+ return json_response({"error": error}, status=404)
+
+ request.user = requested_user[0]
+ if request.method == "POST":
+ # Wrap the data in the werkzeug file wrapper
+ if "Content-Type" not in request.headers:
+ error = "Must supply 'Content-Type' header to upload media."
+ return json_response({"error": error}, status=400)
+ mimetype = request.headers["Content-Type"]
+ filename = mimetypes.guess_all_extensions(mimetype)
+ filename = 'unknown' + filename[0] if filename else filename
+ file_data = FileStorage(
+ stream=io.BytesIO(request.data),
+ filename=filename,
+ content_type=mimetype
+ )
+
+ # Find media manager
+ media_type, media_manager = sniff_media(file_data, filename)
+ entry = new_upload_entry(request.user)
+ if hasattr(media_manager, "api_upload_request"):
+ return media_manager.api_upload_request(request, file_data, entry)
+ else:
+ return json_response({"error": "Not yet implemented"}, status=501)
+
+ return json_response({"error": "Not yet implemented"}, status=501)
+
+@oauth_required
+@csrf_exempt
+def feed(request):
+ """ Handles the user's outbox - /api/user/<username>/feed """
+ user = request.matchdict["username"]
+ requested_user = User.query.filter_by(username=user)
+
+ # check if the user exists
+ if requested_user is None:
+ error = "No such 'user' with id '{0}'".format(user)
+ return json_response({"error": error}, status=404)
+
+ request.user = requested_user[0]
+ if request.data:
+ data = json.loads(request.data)
+ else:
+ data = {"verb": None, "object": {}}
+
+ if request.method == "POST" and data["verb"] == "post":
+ obj = data.get("object", None)
+ if obj is None:
+ error = {"error": "Could not find 'object' element."}
+ return json_response(error, status=400)
+
+ if obj.get("objectType", None) == "comment":
+ # post a comment
+ comment = MediaComment(author=request.user.id)
+ comment.unserialize(data["object"])
+ comment.save()
+ data = {"verb": "post", "object": comment.serialize(request)}
+ return json_response(data)
+
+ elif obj.get("objectType", None) == "image":
+ # Posting an image to the feed
+ media_id = int(data["object"]["id"])
+ media = MediaEntry.query.filter_by(id=media_id)
+ if media is None:
+ error = "No such 'image' with id '{0}'".format(id=media_id)
+ return json_response(error, status=404)
+
+ media = media.first()
+ if not media.unserialize(data["object"]):
+ error = "Invalid 'image' with id '{0}'".format(media_id)
+ return json_response({"error": error}, status=400)
+ media.save()
+ media.media_manager.api_add_to_feed(request, media)
+
+ return json_response({
+ "verb": "post",
+ "object": media.serialize(request)
+ })
+
+ elif obj.get("objectType", None) is None:
+ # They need to tell us what type of object they're giving us.
+ error = {"error": "No objectType specified."}
+ return json_response(error, status=400)
+ else:
+ # Oh no! We don't know about this type of object (yet)
+ error_message = "Unknown object type '{0}'.".format(
+ obj.get("objectType", None)
+ )
+
+ error = {"error": error_message}
+ return json_response(error, status=400)
+
+ elif request.method in ["PUT", "POST"] and data["verb"] == "update":
+ # Check we've got a valid object
+ obj = data.get("object", None)
+
+ if obj is None:
+ error = {"error": "Could not find 'object' element."}
+ return json_response(error, status=400)
+
+ if "objectType" not in obj:
+ error = {"error": "No objectType specified."}
+ return json_response(error, status=400)
+
+ if "id" not in obj:
+ error = {"error": "Object ID has not been specified."}
+ return json_response(error, status=400)
+
+ obj_id = obj["id"]
+
+ # Now try and find object
+ if obj["objectType"] == "comment":
+ comment = MediaComment.query.filter_by(id=obj_id)
+ if comment is None:
+ error = "No such 'comment' with id '{0}'.".format(obj_id)
+ return json_response({"error": error}, status=400)
+
+ comment = comment[0]
+ if not comment.unserialize(data["object"]):
+ error = "Invalid 'comment' with id '{0}'".format(obj_id)
+ return json_response({"error": error}, status=400)
+
+ comment.save()
+
+ activity = {
+ "verb": "update",
+ "object": comment.serialize(request),
+ }
+ return json_response(activity)
+
+ elif obj["objectType"] == "image":
+ image = MediaEntry.query.filter_by(id=obj_id)
+ if image is None:
+ error = "No such 'image' with the id '{0}'.".format(obj_id)
+ return json_response({"error": error}, status=400)
+
+ image = image[0]
+ if not image.unserialize(obj):
+ "Invalid 'image' with id '{0}'".format(obj_id)
+ return json_response({"error": error}, status=400)
+ image.save()
+
+ activity = {
+ "verb": "update",
+ "object": image.serialize(request),
+ }
+ return json_response(activity)
+
+ elif request.method != "GET":
+ # Currently unsupported
+ error = "Unsupported HTTP method {0}".format(request.method)
+ return json_response({"error": error}, status=501)
+
+ feed_url = request.urlgen(
+ "mediagoblin.federation.feed",
+ username=request.user.username,
+ qualified=True
+ )
+
+ feed = {
+ "displayName": "Activities by {user}@{host}".format(
+ user=request.user.username,
+ host=request.host
+ ),
+ "objectTypes": ["activity"],
+ "url": feed_url,
+ "links": {
+ "first": {
+ "href": feed_url,
+ },
+ "self": {
+ "href": request.url,
+ },
+ "prev": {
+ "href": feed_url,
+ },
+ "next": {
+ "href": feed_url,
+ }
+ },
+ "author": request.user.serialize(request),
+ "items": [],
+ }
+
+
+ # Now lookup the user's feed.
+ for media in MediaEntry.query.all():
+ item = {
+ "verb": "post",
+ "object": media.serialize(request),
+ "actor": request.user.serialize(request),
+ "content": "{0} posted a picture".format(request.user.username),
+ "id": 1,
+ }
+ item["updated"] = item["object"]["updated"]
+ item["published"] = item["object"]["published"]
+ item["url"] = item["object"]["url"]
+ feed["items"].append(item)
+ feed["totalItems"] = len(feed["items"])
+
+ return json_response(feed)
+
+@oauth_required
+def object(request, raw_obj=False):
+ """ Lookup for a object type """
+ object_type = request.matchdict["objectType"]
+ try:
+ object_id = int(request.matchdict["id"])
+ except ValueError:
+ error = "Invalid object ID '{0}' for '{1}'".format(
+ request.matchdict["id"],
+ object_type
+ )
+ return json_response({"error": error}, status=400)
+
+ if object_type not in ["image"]:
+ error = "Unknown type: {0}".format(object_type)
+ # not sure why this is 404, maybe ask evan. Maybe 400?
+ return json_response({"error": error}, status=404)
+
+ media = MediaEntry.query.filter_by(id=object_id).first()
+ if media is None:
+ # no media found with that uuid
+ error = "Can't find '{0}' with ID '{1}'".format(
+ object_type,
+ object_id
+ )
+ return json_response({"error": error}, status=404)
+
+ if raw_obj:
+ return media
+
+ return json_response(media.serialize(request))
+
+@oauth_required
+def object_comments(request):
+ """ Looks up for the comments on a object """
+ media = object(request, raw_obj=True)
+ response = media
+ if isinstance(response, MediaEntry):
+ comments = response.serialize(request)
+ comments = comments.get("replies", {
+ "totalItems": 0,
+ "items": [],
+ "url": request.urlgen(
+ "mediagoblin.federation.object.comments",
+ objectType=media.objectType,
+ uuid=media.id,
+ qualified=True
+ )
+ })
+
+ comments["displayName"] = "Replies to {0}".format(comments["url"])
+ comments["links"] = {
+ "first": comments["url"],
+ "self": comments["url"],
+ }
+ response = json_response(comments)
+
+ return response
+
+##
+# Well known
+##
+def host_meta(request):
+ """ /.well-known/host-meta - provide URLs to resources """
+ links = []
+
+ links.append({
+ "ref": "registration_endpoint",
+ "href": request.urlgen(
+ "mediagoblin.oauth.client_register",
+ qualified=True
+ ),
+ })
+ links.append({
+ "ref": "http://apinamespace.org/oauth/request_token",
+ "href": request.urlgen(
+ "mediagoblin.oauth.request_token",
+ qualified=True
+ ),
+ })
+ links.append({
+ "ref": "http://apinamespace.org/oauth/authorize",
+ "href": request.urlgen(
+ "mediagoblin.oauth.authorize",
+ qualified=True
+ ),
+ })
+ links.append({
+ "ref": "http://apinamespace.org/oauth/access_token",
+ "href": request.urlgen(
+ "mediagoblin.oauth.access_token",
+ qualified=True
+ ),
+ })
+
+ return json_response({"links": links})
+
+def whoami(request):
+ """ /api/whoami - HTTP redirect to API profile """
+ profile = request.urlgen(
+ "mediagoblin.federation.user.profile",
+ username=request.user.username,
+ qualified=True
+ )
+
+ return redirect(request, location=profile)
diff --git a/mediagoblin/init/celery/__init__.py b/mediagoblin/init/celery/__init__.py
index 57242bf6..2f2c40d3 100644
--- a/mediagoblin/init/celery/__init__.py
+++ b/mediagoblin/init/celery/__init__.py
@@ -16,6 +16,7 @@
import os
import sys
+import datetime
import logging
from celery import Celery
@@ -58,6 +59,18 @@ def get_celery_settings_dict(app_config, global_config,
celery_settings['CELERY_ALWAYS_EAGER'] = True
celery_settings['CELERY_EAGER_PROPAGATES_EXCEPTIONS'] = True
+ # Garbage collection periodic task
+ frequency = app_config.get('garbage_collection', 60)
+ if frequency:
+ frequency = int(frequency)
+ celery_settings['CELERYBEAT_SCHEDULE'] = {
+ 'garbage-collection': {
+ 'task': 'mediagoblin.federation.task.garbage_collection',
+ 'schedule': datetime.timedelta(minutes=frequency),
+ }
+ }
+ celery_settings['BROKER_HEARTBEAT'] = 1
+
return celery_settings
diff --git a/mediagoblin/media_types/image/__init__.py b/mediagoblin/media_types/image/__init__.py
index 06e0f08f..96081068 100644
--- a/mediagoblin/media_types/image/__init__.py
+++ b/mediagoblin/media_types/image/__init__.py
@@ -19,12 +19,14 @@ import logging
from mediagoblin.media_types import MediaManagerBase
from mediagoblin.media_types.image.processing import sniff_handler, \
ImageProcessingManager
-
+from mediagoblin.tools.response import json_response
+from mediagoblin.submit.lib import prepare_queue_task, run_process_media
+from mediagoblin.notifications import add_comment_subscription
_log = logging.getLogger(__name__)
-ACCEPTED_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "tiff"]
+ACCEPTED_EXTENSIONS = ["jpe", "jpg", "jpeg", "png", "gif", "tiff"]
MEDIA_TYPE = 'mediagoblin.media_types.image'
@@ -56,6 +58,37 @@ class ImageMediaManager(MediaManagerBase):
except (KeyError, ValueError):
return None
+ @staticmethod
+ def api_upload_request(request, file_data, entry):
+ """ This handles a image upload request """
+ # Use the same kind of method from mediagoblin/submit/views:submit_start
+ entry.media_type = unicode(MEDIA_TYPE)
+ entry.title = file_data.filename
+ entry.generate_slug()
+
+ queue_file = prepare_queue_task(request.app, entry, file_data.filename)
+ with queue_file:
+ queue_file.write(request.data)
+
+ entry.save()
+ return json_response(entry.serialize(request))
+
+ @staticmethod
+ def api_add_to_feed(request, entry):
+ """ Add media to Feed """
+ if entry.title:
+ # Shame we have to do this here but we didn't have the data in
+ # api_upload_request as no filename is usually specified.
+ entry.slug = None
+ entry.generate_slug()
+
+ feed_url = request.urlgen(
+ 'mediagoblin.user_pages.atom_feed',
+ qualified=True, user=request.user.username)
+
+ run_process_media(entry, feed_url)
+ add_comment_subscription(request.user, entry)
+ return json_response(entry.serialize(request))
def get_media_type_and_manager(ext):
if ext in ACCEPTED_EXTENSIONS:
diff --git a/mediagoblin/oauth/oauth.py b/mediagoblin/oauth/oauth.py
index 8229c47d..8a60392c 100644
--- a/mediagoblin/oauth/oauth.py
+++ b/mediagoblin/oauth/oauth.py
@@ -15,12 +15,10 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from oauthlib.common import Request
-from oauthlib.oauth1 import RequestValidator
+from oauthlib.oauth1 import RequestValidator
from mediagoblin.db.models import NonceTimestamp, Client, RequestToken, AccessToken
-
-
class GMGRequestValidator(RequestValidator):
enforce_ssl = False
@@ -63,14 +61,14 @@ class GMGRequestValidator(RequestValidator):
""" Currently a stub - called when making AccessTokens """
return list()
- def validate_timestamp_and_nonce(self, client_key, timestamp,
- nonce, request, request_token=None,
+ def validate_timestamp_and_nonce(self, client_key, timestamp,
+ nonce, request, request_token=None,
access_token=None):
nc = NonceTimestamp.query.filter_by(timestamp=timestamp, nonce=nonce)
nc = nc.first()
if nc is None:
return True
-
+
return False
def validate_client_key(self, client_key, request):
@@ -78,7 +76,7 @@ class GMGRequestValidator(RequestValidator):
client = Client.query.filter_by(id=client_key).first()
if client is None:
return False
-
+
return True
def validate_access_token(self, client_key, token, request):
@@ -119,14 +117,14 @@ class GMGRequest(Request):
"""
def __init__(self, request, *args, **kwargs):
- """
+ """
:param request: werkzeug request object
-
+
any extra params are passed to oauthlib.common.Request object
"""
kwargs["uri"] = kwargs.get("uri", request.url)
kwargs["http_method"] = kwargs.get("http_method", request.method)
- kwargs["body"] = kwargs.get("body", request.get_data())
+ kwargs["body"] = kwargs.get("body", request.data)
kwargs["headers"] = kwargs.get("headers", dict(request.headers))
super(GMGRequest, self).__init__(*args, **kwargs)
diff --git a/mediagoblin/oauth/routing.py b/mediagoblin/oauth/routing.py
index e45077bb..7f2aa11d 100644
--- a/mediagoblin/oauth/routing.py
+++ b/mediagoblin/oauth/routing.py
@@ -18,25 +18,25 @@ from mediagoblin.tools.routing import add_route
# client registration & oauth
add_route(
- "mediagoblin.oauth",
+ "mediagoblin.oauth.client_register",
"/api/client/register",
"mediagoblin.oauth.views:client_register"
)
add_route(
- "mediagoblin.oauth",
+ "mediagoblin.oauth.request_token",
"/oauth/request_token",
"mediagoblin.oauth.views:request_token"
)
add_route(
- "mediagoblin.oauth",
+ "mediagoblin.oauth.authorize",
"/oauth/authorize",
"mediagoblin.oauth.views:authorize",
)
add_route(
- "mediagoblin.oauth",
+ "mediagoblin.oauth.access_token",
"/oauth/access_token",
"mediagoblin.oauth.views:access_token"
)
diff --git a/mediagoblin/oauth/views.py b/mediagoblin/oauth/views.py
index f424576b..5ade7a8d 100644
--- a/mediagoblin/oauth/views.py
+++ b/mediagoblin/oauth/views.py
@@ -252,6 +252,7 @@ def authorize(request):
if oauth_request.verifier is None:
orequest = GMGRequest(request)
+ orequest.resource_owner_key = token
request_validator = GMGRequestValidator()
auth_endpoint = AuthorizationEndpoint(request_validator)
verifier = auth_endpoint.create_verifier(orequest, {})
@@ -333,7 +334,7 @@ def access_token(request):
error = "Missing required parameter."
return json_response({"error": error}, status=400)
-
+ request.resource_owner_key = parsed_tokens["oauth_consumer_key"]
request.oauth_token = parsed_tokens["oauth_token"]
request_validator = GMGRequestValidator(data)
av = AccessTokenEndpoint(request_validator)
diff --git a/mediagoblin/routing.py b/mediagoblin/routing.py
index 9f2584d3..3ec1dba0 100644
--- a/mediagoblin/routing.py
+++ b/mediagoblin/routing.py
@@ -35,12 +35,11 @@ def get_url_map():
import mediagoblin.submit.routing
import mediagoblin.user_pages.routing
import mediagoblin.edit.routing
- import mediagoblin.webfinger.routing
import mediagoblin.listings.routing
import mediagoblin.notifications.routing
import mediagoblin.oauth.routing
+ import mediagoblin.federation.routing
-
for route in PluginManager().get_routes():
add_route(*route)
diff --git a/mediagoblin/tests/test_api.py b/mediagoblin/tests/test_api.py
index 4e0cbd8f..55228edc 100644
--- a/mediagoblin/tests/test_api.py
+++ b/mediagoblin/tests/test_api.py
@@ -13,81 +13,284 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
+import json
+import datetime
-
-import logging
-import base64
-
+import mock
+import pytz
import pytest
+from webtest import AppError
+from werkzeug.datastructures import FileStorage
+
+from .resources import GOOD_JPG
from mediagoblin import mg_globals
-from mediagoblin.tools import template, pluginapi
+from mediagoblin.media_types import sniff_media
+from mediagoblin.db.models import User, MediaEntry
+from mediagoblin.submit.lib import new_upload_entry
from mediagoblin.tests.tools import fixture_add_user
-from .resources import GOOD_JPG, GOOD_PNG, EVIL_FILE, EVIL_JPG, EVIL_PNG, \
- BIG_BLUE
+from mediagoblin.federation.task import collect_garbage
+from mediagoblin.moderation.tools import take_away_privileges
+class TestAPI(object):
+ """ Test mediagoblin's pump.io complient APIs """
-_log = logging.getLogger(__name__)
+ @pytest.fixture(autouse=True)
+ def setup(self, test_app):
+ self.test_app = test_app
+ self.db = mg_globals.database
+ self.user = fixture_add_user(privileges=[u'active', u'uploader'])
-class TestAPI(object):
- def setup(self):
- self.db = mg_globals.database
+ def _activity_to_feed(self, test_app, activity, headers=None):
+ """ Posts an activity to the user's feed """
+ if headers:
+ headers.setdefault("Content-Type", "application/json")
+ else:
+ headers = {"Content-Type": "application/json"}
+
+ with mock.patch("mediagoblin.decorators.oauth_required",
+ new_callable=self.mocked_oauth_required):
+ response = test_app.post(
+ "/api/user/{0}/feed".format(self.user.username),
+ json.dumps(activity),
+ headers=headers
+ )
+
+ return response, json.loads(response.body)
+
+ def _upload_image(self, test_app, image):
+ """ Uploads and image to MediaGoblin via pump.io API """
+ data = open(image, "rb").read()
+ headers = {
+ "Content-Type": "image/jpeg",
+ "Content-Length": str(len(data))
+ }
+
+
+ with mock.patch("mediagoblin.decorators.oauth_required",
+ new_callable=self.mocked_oauth_required):
+ response = test_app.post(
+ "/api/user/{0}/uploads".format(self.user.username),
+ data,
+ headers=headers
+ )
+ image = json.loads(response.body)
+
+ return response, image
+
+ def _post_image_to_feed(self, test_app, image):
+ """ Posts an already uploaded image to feed """
+ activity = {
+ "verb": "post",
+ "object": image,
+ }
+
+ return self._activity_to_feed(test_app, activity)
+
+
+ def mocked_oauth_required(self, *args, **kwargs):
+ """ Mocks mediagoblin.decorator.oauth_required to always validate """
+
+ def fake_controller(controller, request, *args, **kwargs):
+ request.user = User.query.filter_by(id=self.user.id).first()
+ return controller(request, *args, **kwargs)
+
+ def oauth_required(c):
+ return lambda *args, **kwargs: fake_controller(c, *args, **kwargs)
+
+ return oauth_required
+
+ def test_can_post_image(self, test_app):
+ """ Tests that an image can be posted to the API """
+ # First request we need to do is to upload the image
+ response, image = self._upload_image(test_app, GOOD_JPG)
+
+ # I should have got certain things back
+ assert response.status_code == 200
+
+ assert "id" in image
+ assert "fullImage" in image
+ assert "url" in image["fullImage"]
+ assert "url" in image
+ assert "author" in image
+ assert "published" in image
+ assert "updated" in image
+ assert image["objectType"] == "image"
+
+ # Check that we got the response we're expecting
+ response, _ = self._post_image_to_feed(test_app, image)
+ assert response.status_code == 200
+
+ def test_upload_image_with_filename(self, test_app):
+ """ Tests that you can upload an image with filename and description """
+ response, data = self._upload_image(test_app, GOOD_JPG)
+ response, data = self._post_image_to_feed(test_app, data)
+
+ image = data["object"]
+
+ # Now we need to add a title and description
+ title = "My image ^_^"
+ description = "This is my super awesome image :D"
+ license = "CC-BY-SA"
+
+ image["displayName"] = title
+ image["content"] = description
+ image["license"] = license
+
+ activity = {"verb": "update", "object": image}
+
+ with mock.patch("mediagoblin.decorators.oauth_required",
+ new_callable=self.mocked_oauth_required):
+ response = test_app.post(
+ "/api/user/{0}/feed".format(self.user.username),
+ json.dumps(activity),
+ headers={"Content-Type": "application/json"}
+ )
+
+ image = json.loads(response.body)["object"]
+
+ # Check everything has been set on the media correctly
+ media = MediaEntry.query.filter_by(id=image["id"]).first()
+ assert media.title == title
+ assert media.description == description
+ assert media.license == license
+
+ # Check we're being given back everything we should on an update
+ assert image["id"] == media.id
+ assert image["displayName"] == title
+ assert image["content"] == description
+ assert image["license"] == license
+
+
+ def test_only_uploaders_post_image(self, test_app):
+ """ Test that only uploaders can upload images """
+ # Remove uploader permissions from user
+ take_away_privileges(self.user.username, u"uploader")
+
+ # Now try and upload a image
+ data = open(GOOD_JPG, "rb").read()
+ headers = {
+ "Content-Type": "image/jpeg",
+ "Content-Length": str(len(data)),
+ }
+
+ with mock.patch("mediagoblin.decorators.oauth_required",
+ new_callable=self.mocked_oauth_required):
+ with pytest.raises(AppError) as excinfo:
+ test_app.post(
+ "/api/user/{0}/uploads".format(self.user.username),
+ data,
+ headers=headers
+ )
+
+ # Assert that we've got a 403
+ assert "403 FORBIDDEN" in excinfo.value.message
+
+ def test_object_endpoint(self, test_app):
+ """ Tests that object can be looked up at endpoint """
+ # Post an image
+ response, data = self._upload_image(test_app, GOOD_JPG)
+ response, data = self._post_image_to_feed(test_app, data)
+
+ # Now lookup image to check that endpoint works.
+ image = data["object"]
+
+ assert "links" in image
+ assert "self" in image["links"]
+
+ # Get URI and strip testing host off
+ object_uri = image["links"]["self"]["href"]
+ object_uri = object_uri.replace("http://localhost:80", "")
+
+ with mock.patch("mediagoblin.decorators.oauth_required",
+ new_callable=self.mocked_oauth_required):
+ request = test_app.get(object_uri)
+
+ image = json.loads(request.body)
+ entry = MediaEntry.query.filter_by(id=image["id"]).first()
+
+ assert request.status_code == 200
+ assert entry.id == image["id"]
- self.user_password = u'4cc355_70k3N'
- self.user = fixture_add_user(u'joapi', self.user_password,
- privileges=[u'active',u'uploader'])
+ assert "image" in image
+ assert "fullImage" in image
+ assert "pump_io" in image
+ assert "links" in image
- def login(self, test_app):
- test_app.post(
- '/auth/login/', {
- 'username': self.user.username,
- 'password': self.user_password})
+ def test_post_comment(self, test_app):
+ """ Tests that I can post an comment media """
+ # Upload some media to comment on
+ response, data = self._upload_image(test_app, GOOD_JPG)
+ response, data = self._post_image_to_feed(test_app, data)
- def get_context(self, template_name):
- return template.TEMPLATE_TEST_CONTEXT[template_name]
+ content = "Hai this is a comment on this lovely picture ^_^"
- def http_auth_headers(self):
- return {'Authorization': 'Basic {0}'.format(
- base64.b64encode(':'.join([
- self.user.username,
- self.user_password])))}
+ activity = {
+ "verb": "post",
+ "object": {
+ "objectType": "comment",
+ "content": content,
+ "inReplyTo": data["object"],
+ }
+ }
- def do_post(self, data, test_app, **kwargs):
- url = kwargs.pop('url', '/api/submit')
- do_follow = kwargs.pop('do_follow', False)
+ response, comment_data = self._activity_to_feed(test_app, activity)
+ assert response.status_code == 200
- if not 'headers' in kwargs.keys():
- kwargs['headers'] = self.http_auth_headers()
+ # Find the objects in the database
+ media = MediaEntry.query.filter_by(id=data["object"]["id"]).first()
+ comment = media.get_comments()[0]
- response = test_app.post(url, data, **kwargs)
+ # Tests that it matches in the database
+ assert comment.author == self.user.id
+ assert comment.content == content
- if do_follow:
- response.follow()
+ # Test that the response is what we should be given
+ assert comment.id == comment_data["object"]["id"]
+ assert comment.content == comment_data["object"]["content"]
- return response
+ def test_profile(self, test_app):
+ """ Tests profile endpoint """
+ uri = "/api/user/{0}/profile".format(self.user.username)
+ with mock.patch("mediagoblin.decorators.oauth_required",
+ new_callable=self.mocked_oauth_required):
+ response = test_app.get(uri)
+ profile = json.loads(response.body)
- def upload_data(self, filename):
- return {'upload_files': [('file', filename)]}
+ assert response.status_code == 200
- def test_1_test_test_view(self, test_app):
- self.login(test_app)
+ assert profile["preferredUsername"] == self.user.username
+ assert profile["objectType"] == "person"
- response = test_app.get(
- '/api/test',
- headers=self.http_auth_headers())
+ assert "links" in profile
- assert response.body == \
- '{"username": "joapi", "email": "joapi@example.com"}'
+ def test_garbage_collection_task(self, test_app):
+ """ Test old media entry are removed by GC task """
+ # Create a media entry that's unprocessed and over an hour old.
+ entry_id = 72
+ now = datetime.datetime.now(pytz.UTC)
+ file_data = FileStorage(
+ stream=open(GOOD_JPG, "rb"),
+ filename="mah_test.jpg",
+ content_type="image/jpeg"
+ )
- def test_2_test_submission(self, test_app):
- self.login(test_app)
+ # Find media manager
+ media_type, media_manager = sniff_media(file_data, "mah_test.jpg")
+ entry = new_upload_entry(self.user)
+ entry.id = entry_id
+ entry.title = "Mah Image"
+ entry.slug = "slugy-slug-slug"
+ entry.media_type = 'image'
+ entry.uploaded = now - datetime.timedelta(days=2)
+ entry.save()
- response = self.do_post(
- {'title': 'Great JPG!'},
- test_app,
- **self.upload_data(GOOD_JPG))
+ # Validate the model exists
+ assert MediaEntry.query.filter_by(id=entry_id).first() is not None
- assert response.status_int == 200
+ # Call the garbage collection task
+ collect_garbage()
- assert self.db.MediaEntry.query.filter_by(title=u'Great JPG!').first()
+ # Now validate the image has been deleted
+ assert MediaEntry.query.filter_by(id=entry_id).first() is None
diff --git a/mediagoblin/tests/test_legacy_api.py b/mediagoblin/tests/test_legacy_api.py
new file mode 100644
index 00000000..4e0cbd8f
--- /dev/null
+++ b/mediagoblin/tests/test_legacy_api.py
@@ -0,0 +1,93 @@
+# GNU MediaGoblin -- federated, autonomous media hosting
+# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+import logging
+import base64
+
+import pytest
+
+from mediagoblin import mg_globals
+from mediagoblin.tools import template, pluginapi
+from mediagoblin.tests.tools import fixture_add_user
+from .resources import GOOD_JPG, GOOD_PNG, EVIL_FILE, EVIL_JPG, EVIL_PNG, \
+ BIG_BLUE
+
+
+_log = logging.getLogger(__name__)
+
+
+class TestAPI(object):
+ def setup(self):
+ self.db = mg_globals.database
+
+ self.user_password = u'4cc355_70k3N'
+ self.user = fixture_add_user(u'joapi', self.user_password,
+ privileges=[u'active',u'uploader'])
+
+ def login(self, test_app):
+ test_app.post(
+ '/auth/login/', {
+ 'username': self.user.username,
+ 'password': self.user_password})
+
+ def get_context(self, template_name):
+ return template.TEMPLATE_TEST_CONTEXT[template_name]
+
+ def http_auth_headers(self):
+ return {'Authorization': 'Basic {0}'.format(
+ base64.b64encode(':'.join([
+ self.user.username,
+ self.user_password])))}
+
+ def do_post(self, data, test_app, **kwargs):
+ url = kwargs.pop('url', '/api/submit')
+ do_follow = kwargs.pop('do_follow', False)
+
+ if not 'headers' in kwargs.keys():
+ kwargs['headers'] = self.http_auth_headers()
+
+ response = test_app.post(url, data, **kwargs)
+
+ if do_follow:
+ response.follow()
+
+ return response
+
+ def upload_data(self, filename):
+ return {'upload_files': [('file', filename)]}
+
+ def test_1_test_test_view(self, test_app):
+ self.login(test_app)
+
+ response = test_app.get(
+ '/api/test',
+ headers=self.http_auth_headers())
+
+ assert response.body == \
+ '{"username": "joapi", "email": "joapi@example.com"}'
+
+ def test_2_test_submission(self, test_app):
+ self.login(test_app)
+
+ response = self.do_post(
+ {'title': 'Great JPG!'},
+ test_app,
+ **self.upload_data(GOOD_JPG))
+
+ assert response.status_int == 200
+
+ assert self.db.MediaEntry.query.filter_by(title=u'Great JPG!').first()
diff --git a/mediagoblin/tests/test_oauth1.py b/mediagoblin/tests/test_oauth1.py
index 073c2884..568036e5 100644
--- a/mediagoblin/tests/test_oauth1.py
+++ b/mediagoblin/tests/test_oauth1.py
@@ -52,8 +52,8 @@ class TestOAuth(object):
def register_client(self, **kwargs):
""" Regiters a client with the API """
-
- kwargs["type"] = "client_associate"
+
+ kwargs["type"] = "client_associate"
kwargs["application_type"] = kwargs.get("application_type", "native")
return self.test_app.post("/api/client/register", kwargs)
@@ -63,7 +63,7 @@ class TestOAuth(object):
client_info = response.json
client = self.db.Client.query.filter_by(id=client_info["client_id"]).first()
-
+
assert response.status_int == 200
assert client is not None
@@ -81,7 +81,7 @@ class TestOAuth(object):
client_info = response.json
client = self.db.Client.query.filter_by(id=client_info["client_id"]).first()
-
+
assert client is not None
assert client.secret == client_info["client_secret"]
assert client.application_type == query["application_type"]
@@ -163,4 +163,3 @@ class TestOAuth(object):
assert request_token.client == client.id
assert request_token.used == False
assert request_token.callback == request_query["oauth_callback"]
-
diff --git a/mediagoblin/tests/tools.py b/mediagoblin/tests/tools.py
index 060dfda9..57dea7b0 100644
--- a/mediagoblin/tests/tools.py
+++ b/mediagoblin/tests/tools.py
@@ -25,13 +25,16 @@ from webtest import TestApp
from mediagoblin import mg_globals
from mediagoblin.db.models import User, MediaEntry, Collection, MediaComment, \
- CommentSubscription, CommentNotification, Privilege, CommentReport
+ CommentSubscription, CommentNotification, Privilege, CommentReport, Client, \
+ RequestToken, AccessToken
from mediagoblin.tools import testing
from mediagoblin.init.config import read_mediagoblin_config
from mediagoblin.db.base import Session
from mediagoblin.meddleware import BaseMeddleware
from mediagoblin.auth import gen_password_hash
from mediagoblin.gmg_commands.dbupdate import run_dbupdate
+from mediagoblin.oauth.views import OAUTH_ALPHABET
+from mediagoblin.tools.crypto import random_string
from datetime import datetime
@@ -343,3 +346,4 @@ def fixture_add_comment_report(comment=None, reported_user=None,
Session.expunge(comment_report)
return comment_report
+
diff --git a/mediagoblin/tools/request.py b/mediagoblin/tools/request.py
index d4739039..d2cb0f6a 100644
--- a/mediagoblin/tools/request.py
+++ b/mediagoblin/tools/request.py
@@ -16,7 +16,9 @@
import json
import logging
-from mediagoblin.db.models import User
+
+from mediagoblin.db.models import User, AccessToken
+from mediagoblin.oauth.tools.request import decode_authorization_header
_log = logging.getLogger(__name__)
@@ -31,6 +33,18 @@ def setup_user_in_request(request):
Examine a request and tack on a request.user parameter if that's
appropriate.
"""
+ # If API request the user will be associated with the access token
+ authorization = decode_authorization_header(request.headers)
+
+ if authorization.get(u"access_token"):
+ # Check authorization header.
+ token = authorization[u"oauth_token"]
+ token = AccessToken.query.filter_by(token=token).first()
+ if token is not None:
+ request.user = token.user
+ return
+
+
if 'user_id' not in request.session:
request.user = None
return
@@ -45,8 +59,8 @@ def setup_user_in_request(request):
def decode_request(request):
""" Decodes a request based on MIME-Type """
- data = request.get_data()
-
+ data = request.data
+
if request.content_type == json_encoded:
data = json.loads(data)
elif request.content_type == form_encoded or request.content_type == "":
diff --git a/mediagoblin/webfinger/views.py b/mediagoblin/webfinger/views.py
deleted file mode 100644
index 97fc3ef7..00000000
--- a/mediagoblin/webfinger/views.py
+++ /dev/null
@@ -1,117 +0,0 @@
-# GNU MediaGoblin -- federated, autonomous media hosting
-# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU Affero General Public License for more details.
-#
-# You should have received a copy of the GNU Affero General Public License
-# along with this program. If not, see <http://www.gnu.org/licenses/>.
-'''
-For references, see docstring in mediagoblin/webfinger/__init__.py
-'''
-
-import re
-
-from urlparse import urlparse
-
-from mediagoblin.tools.response import render_to_response, render_404
-
-def host_meta(request):
- '''
- Webfinger host-meta
- '''
-
- placeholder = 'MG_LRDD_PLACEHOLDER'
-
- lrdd_title = 'GNU MediaGoblin - User lookup'
-
- lrdd_template = request.urlgen(
- 'mediagoblin.webfinger.xrd',
- uri=placeholder,
- qualified=True)
-
- return render_to_response(
- request,
- 'mediagoblin/webfinger/host-meta.xml',
- {'request': request,
- 'lrdd_template': lrdd_template,
- 'lrdd_title': lrdd_title,
- 'placeholder': placeholder})
-
-MATCH_SCHEME_PATTERN = re.compile(r'^acct:')
-
-def xrd(request):
- '''
- Find user data based on a webfinger URI
- '''
- param_uri = request.GET.get('uri')
-
- if not param_uri:
- return render_404(request)
-
- '''
- :py:module:`urlparse` does not recognize usernames in URIs of the
- form ``acct:user@example.org`` or ``user@example.org``.
- '''
- if not MATCH_SCHEME_PATTERN.search(param_uri):
- # Assume the URI is in the form ``user@example.org``
- uri = 'acct://' + param_uri
- else:
- # Assumes the URI looks like ``acct:user@example.org
- uri = MATCH_SCHEME_PATTERN.sub(
- 'acct://', param_uri)
-
- parsed = urlparse(uri)
-
- xrd_subject = param_uri
-
- # TODO: Verify that the user exists
- # Q: Does webfinger support error handling in this case?
- # Returning 404 seems intuitive, need to check.
- if parsed.username:
- # The user object
- # TODO: Fetch from database instead of using the MockUser
- user = MockUser()
- user.username = parsed.username
-
- xrd_links = [
- {'attrs': {
- 'rel': 'http://microformats.org/profile/hcard',
- 'href': request.urlgen(
- 'mediagoblin.user_pages.user_home',
- user=user.username,
- qualified=True)}},
- {'attrs': {
- 'rel': 'http://schemas.google.com/g/2010#updates-from',
- 'href': request.urlgen(
- 'mediagoblin.user_pages.atom_feed',
- user=user.username,
- qualified=True)}}]
-
- xrd_alias = request.urlgen(
- 'mediagoblin.user_pages.user_home',
- user=user.username,
- qualified=True)
-
- return render_to_response(
- request,
- 'mediagoblin/webfinger/xrd.xml',
- {'request': request,
- 'subject': xrd_subject,
- 'alias': xrd_alias,
- 'links': xrd_links })
- else:
- return render_404(request)
-
-class MockUser(object):
- '''
- TEMPORARY user object
- '''
- username = None