diff options
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 |