Fix #927 - Clean up federation code after Elrond's review

- Add json_error and use inplace of json_response where appropriate.
- Add garbage_collection to config spec file.
- Fix bugs in both garbage collection task and test
- Handle /api/whoami when no user logged in and a test for such a case.
- Validate ID is correct and user has comment privilege to comment.
This commit is contained in:
Jessica Tallon 2014-07-28 23:36:39 +01:00
parent 138d934f01
commit 5e5d445890
10 changed files with 181 additions and 151 deletions

View File

@ -23,10 +23,6 @@ allow_registration = true
# Set to false to disable the ability for users to report offensive content # Set to false to disable the ability for users to report offensive content
allow_reporting = true 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 ## Uncomment this to put some user-overriding templates here
# local_templates = %(here)s/user_dev/templates/ # local_templates = %(here)s/user_dev/templates/

View File

@ -92,6 +92,10 @@ max_file_size = integer(default=None)
# Privilege scheme # Privilege scheme
user_privilege_scheme = string(default="uploader,commenter,reporter") user_privilege_scheme = string(default="uploader,commenter,reporter")
# Frequency garbage collection will run (setting to 0 or false to disable)
# Setting units are minutes.
garbage_collection = integer(default=60)
[jinja2] [jinja2]
# Jinja2 supports more directives than the minimum required by mediagoblin. # Jinja2 supports more directives than the minimum required by mediagoblin.
# This setting allows users creating custom templates to specify a list of # This setting allows users creating custom templates to specify a list of

View File

@ -684,7 +684,17 @@ class MediaComment(Base, MediaCommentMixin):
if "id" not in data["inReplyTo"]: if "id" not in data["inReplyTo"]:
return False return False
self.media_entry = data["inReplyTo"]["id"] # Validate that the ID is correct
try:
media_id = int(data["inReplyTo"]["id"])
except ValueError:
return False
media = MediaEntry.query.filter_by(id=media_id).first()
if media is None:
return False
self.media_entry = media.id
self.content = data["content"] self.content = data["content"]
return True return True

View File

@ -24,9 +24,13 @@ from mediagoblin.media_types import sniff_media
from mediagoblin.decorators import oauth_required from mediagoblin.decorators import oauth_required
from mediagoblin.federation.decorators import user_has_privilege from mediagoblin.federation.decorators import user_has_privilege
from mediagoblin.db.models import User, MediaEntry, MediaComment from mediagoblin.db.models import User, MediaEntry, MediaComment
from mediagoblin.tools.response import redirect, json_response from mediagoblin.tools.response import redirect, json_response, json_error
from mediagoblin.meddleware.csrf import csrf_exempt from mediagoblin.meddleware.csrf import csrf_exempt
from mediagoblin.submit.lib import new_upload_entry from mediagoblin.submit.lib import new_upload_entry, api_upload_request, \
api_add_to_feed
# MediaTypes
from mediagoblin.media_types.image import MEDIA_TYPE as IMAGE_MEDIA_TYPE
@oauth_required @oauth_required
def profile(request, raw=False): def profile(request, raw=False):
@ -34,10 +38,8 @@ def profile(request, raw=False):
user = request.matchdict["username"] user = request.matchdict["username"]
requested_user = User.query.filter_by(username=user) requested_user = User.query.filter_by(username=user)
# check if the user exists
if requested_user is None: if requested_user is None:
error = "No such 'user' with id '{0}'".format(user) return json_error("No such 'user' with id '{0}'".format(user), 404)
return json_response({"error": error}, status=404)
user = requested_user[0] user = requested_user[0]
@ -69,15 +71,14 @@ def uploads(request):
requested_user = User.query.filter_by(username=user) requested_user = User.query.filter_by(username=user)
if requested_user is None: if requested_user is None:
error = "No such 'user' with id '{0}'".format(user) return json_error("No such 'user' with id '{0}'".format(user), 404)
return json_response({"error": error}, status=404)
request.user = requested_user[0] request.user = requested_user[0]
if request.method == "POST": if request.method == "POST":
# Wrap the data in the werkzeug file wrapper # Wrap the data in the werkzeug file wrapper
if "Content-Type" not in request.headers: if "Content-Type" not in request.headers:
error = "Must supply 'Content-Type' header to upload media." return json_error(
return json_response({"error": error}, status=400) "Must supply 'Content-Type' header to upload media.")
mimetype = request.headers["Content-Type"] mimetype = request.headers["Content-Type"]
filename = mimetypes.guess_all_extensions(mimetype) filename = mimetypes.guess_all_extensions(mimetype)
filename = 'unknown' + filename[0] if filename else filename filename = 'unknown' + filename[0] if filename else filename
@ -90,12 +91,10 @@ def uploads(request):
# Find media manager # Find media manager
media_type, media_manager = sniff_media(file_data, filename) media_type, media_manager = sniff_media(file_data, filename)
entry = new_upload_entry(request.user) entry = new_upload_entry(request.user)
if hasattr(media_manager, "api_upload_request"): entry.media_type = IMAGE_MEDIA_TYPE
return media_manager.api_upload_request(request, file_data, entry) return 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) return json_error("Not yet implemented", 501)
@oauth_required @oauth_required
@csrf_exempt @csrf_exempt
@ -106,8 +105,7 @@ def feed(request):
# check if the user exists # check if the user exists
if requested_user is None: if requested_user is None:
error = "No such 'user' with id '{0}'".format(user) return json_error("No such 'user' with id '{0}'".format(user), 404)
return json_response({"error": error}, status=404)
request.user = requested_user[0] request.user = requested_user[0]
if request.data: if request.data:
@ -118,11 +116,16 @@ def feed(request):
if request.method == "POST" and data["verb"] == "post": if request.method == "POST" and data["verb"] == "post":
obj = data.get("object", None) obj = data.get("object", None)
if obj is None: if obj is None:
error = {"error": "Could not find 'object' element."} return json_error("Could not find 'object' element.")
return json_response(error, status=400)
if obj.get("objectType", None) == "comment": if obj.get("objectType", None) == "comment":
# post a comment # post a comment
if not request.user.has_privilege(u'commenter'):
return json_error(
"Privilege 'commenter' required to comment.",
status=403
)
comment = MediaComment(author=request.user.id) comment = MediaComment(author=request.user.id)
comment.unserialize(data["object"]) comment.unserialize(data["object"])
comment.save() comment.save()
@ -134,15 +137,19 @@ def feed(request):
media_id = int(data["object"]["id"]) media_id = int(data["object"]["id"])
media = MediaEntry.query.filter_by(id=media_id) media = MediaEntry.query.filter_by(id=media_id)
if media is None: if media is None:
error = "No such 'image' with id '{0}'".format(id=media_id) return json_response(
return json_response(error, status=404) "No such 'image' with id '{0}'".format(id=media_id),
status=404
)
media = media.first() media = media.first()
if not media.unserialize(data["object"]): if not media.unserialize(data["object"]):
error = "Invalid 'image' with id '{0}'".format(media_id) return json_error(
return json_response({"error": error}, status=400) "Invalid 'image' with id '{0}'".format(media_id)
)
media.save() media.save()
media.media_manager.api_add_to_feed(request, media) api_add_to_feed(request, media)
return json_response({ return json_response({
"verb": "post", "verb": "post",
@ -151,46 +158,46 @@ def feed(request):
elif obj.get("objectType", None) is None: elif obj.get("objectType", None) is None:
# They need to tell us what type of object they're giving us. # They need to tell us what type of object they're giving us.
error = {"error": "No objectType specified."} return json_error("No objectType specified.")
return json_response(error, status=400)
else: else:
# Oh no! We don't know about this type of object (yet) # Oh no! We don't know about this type of object (yet)
error_message = "Unknown object type '{0}'.".format( object_type = obj.get("objectType", None)
obj.get("objectType", None) return json_error("Unknown object type '{0}'.".format(object_type))
)
error = {"error": error_message}
return json_response(error, status=400)
elif request.method in ["PUT", "POST"] and data["verb"] == "update": elif request.method in ["PUT", "POST"] and data["verb"] == "update":
# Check we've got a valid object # Check we've got a valid object
obj = data.get("object", None) obj = data.get("object", None)
if obj is None: if obj is None:
error = {"error": "Could not find 'object' element."} return json_error("Could not find 'object' element.")
return json_response(error, status=400)
if "objectType" not in obj: if "objectType" not in obj:
error = {"error": "No objectType specified."} return json_error("No objectType specified.")
return json_response(error, status=400)
if "id" not in obj: if "id" not in obj:
error = {"error": "Object ID has not been specified."} return json_error("Object ID has not been specified.")
return json_response(error, status=400)
obj_id = obj["id"] obj_id = obj["id"]
# Now try and find object # Now try and find object
if obj["objectType"] == "comment": if obj["objectType"] == "comment":
if not request.user.has_privilege(u'commenter'):
return json_error(
"Privilege 'commenter' required to comment.",
status=403
)
comment = MediaComment.query.filter_by(id=obj_id) comment = MediaComment.query.filter_by(id=obj_id)
if comment is None: if comment is None:
error = "No such 'comment' with id '{0}'.".format(obj_id) return json_error(
return json_response({"error": error}, status=400) "No such 'comment' with id '{0}'.".format(obj_id)
)
comment = comment[0] comment = comment[0]
if not comment.unserialize(data["object"]): if not comment.unserialize(data["object"]):
error = "Invalid 'comment' with id '{0}'".format(obj_id) return json_error(
return json_response({"error": error}, status=400) "Invalid 'comment' with id '{0}'".format(obj_id)
)
comment.save() comment.save()
@ -203,13 +210,15 @@ def feed(request):
elif obj["objectType"] == "image": elif obj["objectType"] == "image":
image = MediaEntry.query.filter_by(id=obj_id) image = MediaEntry.query.filter_by(id=obj_id)
if image is None: if image is None:
error = "No such 'image' with the id '{0}'.".format(obj_id) return json_error(
return json_response({"error": error}, status=400) "No such 'image' with the id '{0}'.".format(obj_id)
)
image = image[0] image = image[0]
if not image.unserialize(obj): if not image.unserialize(obj):
"Invalid 'image' with id '{0}'".format(obj_id) return json_error(
return json_response({"error": error}, status=400) "Invalid 'image' with id '{0}'".format(obj_id)
)
image.save() image.save()
activity = { activity = {
@ -219,9 +228,10 @@ def feed(request):
return json_response(activity) return json_response(activity)
elif request.method != "GET": elif request.method != "GET":
# Currently unsupported return json_error(
error = "Unsupported HTTP method {0}".format(request.method) "Unsupported HTTP method {0}".format(request.method),
return json_response({"error": error}, status=501) status=501
)
feed_url = request.urlgen( feed_url = request.urlgen(
"mediagoblin.federation.feed", "mediagoblin.federation.feed",
@ -255,7 +265,8 @@ def feed(request):
} }
# Now lookup the user's feed. # Look up all the media to put in the feed (this will be changed
# when we get real feeds/inboxes/outboxes/activites)
for media in MediaEntry.query.all(): for media in MediaEntry.query.all():
item = { item = {
"verb": "post", "verb": "post",
@ -283,21 +294,22 @@ def object(request, raw_obj=False):
request.matchdict["id"], request.matchdict["id"],
object_type object_type
) )
return json_response({"error": error}, status=400) return json_error(error)
if object_type not in ["image"]: if object_type not in ["image"]:
error = "Unknown type: {0}".format(object_type)
# not sure why this is 404, maybe ask evan. Maybe 400? # not sure why this is 404, maybe ask evan. Maybe 400?
return json_response({"error": error}, status=404) return json_error("Unknown type: {0}".format(object_type), status=404)
media = MediaEntry.query.filter_by(id=object_id).first() media = MediaEntry.query.filter_by(id=object_id).first()
if media is None: if media is None:
# no media found with that uuid
error = "Can't find '{0}' with ID '{1}'".format( error = "Can't find '{0}' with ID '{1}'".format(
object_type, object_type,
object_id object_id
) )
return json_response({"error": error}, status=404) return json_error(
"Can't find '{0}' with ID '{1}'".format(object_type, object_id),
status=404
)
if raw_obj: if raw_obj:
return media return media
@ -371,6 +383,9 @@ def host_meta(request):
def whoami(request): def whoami(request):
""" /api/whoami - HTTP redirect to API profile """ """ /api/whoami - HTTP redirect to API profile """
if request.user is None:
return json_error("Not logged in.", status=401)
profile = request.urlgen( profile = request.urlgen(
"mediagoblin.federation.user.profile", "mediagoblin.federation.user.profile",
username=request.user.username, username=request.user.username,

View File

@ -19,9 +19,6 @@ import logging
from mediagoblin.media_types import MediaManagerBase from mediagoblin.media_types import MediaManagerBase
from mediagoblin.media_types.image.processing import sniff_handler, \ from mediagoblin.media_types.image.processing import sniff_handler, \
ImageProcessingManager 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__) _log = logging.getLogger(__name__)
@ -58,38 +55,6 @@ class ImageMediaManager(MediaManagerBase):
except (KeyError, ValueError): except (KeyError, ValueError):
return None 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): def get_media_type_and_manager(ext):
if ext in ACCEPTED_EXTENSIONS: if ext in ACCEPTED_EXTENSIONS:
return MEDIA_TYPE, ImageMediaManager return MEDIA_TYPE, ImageMediaManager

View File

@ -22,6 +22,7 @@ from werkzeug.utils import secure_filename
from werkzeug.datastructures import FileStorage from werkzeug.datastructures import FileStorage
from mediagoblin import mg_globals from mediagoblin import mg_globals
from mediagoblin.tools.response import json_response
from mediagoblin.tools.text import convert_to_tag_list_of_dicts from mediagoblin.tools.text import convert_to_tag_list_of_dicts
from mediagoblin.db.models import MediaEntry, ProcessingMetaData from mediagoblin.db.models import MediaEntry, ProcessingMetaData
from mediagoblin.processing import mark_entry_failed from mediagoblin.processing import mark_entry_failed
@ -259,3 +260,33 @@ def run_process_media(entry, feed_url=None,
mark_entry_failed(entry.id, exc) mark_entry_failed(entry.id, exc)
# re-raise the exception # re-raise the exception
raise raise
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.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))
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))

View File

@ -16,34 +16,23 @@
import celery import celery
import datetime import datetime
import logging
import pytz import pytz
from mediagoblin.db.models import MediaEntry from mediagoblin.db.models import MediaEntry
_log = logging.getLogger(__name__)
logging.basicConfig()
_log.setLevel(logging.DEBUG)
@celery.task() @celery.task()
def collect_garbage(): def collect_garbage():
""" """
Garbage collection to clean up media 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()
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.
"""
cuttoff = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=1)
garbage = MediaEntry.query.filter(MediaEntry.created < cuttoff)
garbage = garbage.filter(MediaEntry.state == "unprocessed")
for entry in garbage.all():
entry.delete()

View File

@ -14,22 +14,16 @@
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import json import json
import datetime
import mock import mock
import pytz
import pytest import pytest
from webtest import AppError from webtest import AppError
from werkzeug.datastructures import FileStorage
from .resources import GOOD_JPG from .resources import GOOD_JPG
from mediagoblin import mg_globals from mediagoblin import mg_globals
from mediagoblin.media_types import sniff_media
from mediagoblin.db.models import User, MediaEntry from mediagoblin.db.models import User, MediaEntry
from mediagoblin.submit.lib import new_upload_entry
from mediagoblin.tests.tools import fixture_add_user from mediagoblin.tests.tools import fixture_add_user
from mediagoblin.federation.task import collect_garbage
from mediagoblin.moderation.tools import take_away_privileges from mediagoblin.moderation.tools import take_away_privileges
class TestAPI(object): class TestAPI(object):
@ -40,7 +34,7 @@ class TestAPI(object):
self.test_app = test_app self.test_app = test_app
self.db = mg_globals.database self.db = mg_globals.database
self.user = fixture_add_user(privileges=[u'active', u'uploader']) self.user = fixture_add_user(privileges=[u'active', u'uploader', u'commenter'])
def _activity_to_feed(self, test_app, activity, headers=None): def _activity_to_feed(self, test_app, activity, headers=None):
""" Posts an activity to the user's feed """ """ Posts an activity to the user's feed """
@ -265,32 +259,9 @@ class TestAPI(object):
assert "links" in profile assert "links" in profile
def test_garbage_collection_task(self, test_app): def test_whoami_without_login(self, test_app):
""" Test old media entry are removed by GC task """ """ Test that whoami endpoint returns error when not logged in """
# Create a media entry that's unprocessed and over an hour old. with pytest.raises(AppError) as excinfo:
entry_id = 72 response = test_app.get("/api/whoami")
now = datetime.datetime.now(pytz.UTC)
file_data = FileStorage(
stream=open(GOOD_JPG, "rb"),
filename="mah_test.jpg",
content_type="image/jpeg"
)
# Find media manager assert "401 UNAUTHORIZED" in excinfo.value.message
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()
# Validate the model exists
assert MediaEntry.query.filter_by(id=entry_id).first() is not None
# Call the garbage collection task
collect_garbage()
# Now validate the image has been deleted
assert MediaEntry.query.filter_by(id=entry_id).first() is None

View File

@ -14,7 +14,16 @@
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import pytz
import datetime
from werkzeug.datastructures import FileStorage
from .resources import GOOD_JPG
from mediagoblin.db.base import Session from mediagoblin.db.base import Session
from mediagoblin.media_types import sniff_media
from mediagoblin.submit.lib import new_upload_entry
from mediagoblin.submit.task import collect_garbage
from mediagoblin.db.models import User, MediaEntry, MediaComment from mediagoblin.db.models import User, MediaEntry, MediaComment
from mediagoblin.tests.tools import fixture_add_user, fixture_media_entry from mediagoblin.tests.tools import fixture_add_user, fixture_media_entry
@ -91,3 +100,35 @@ def test_media_deletes_broken_attachment(test_app):
MediaEntry.query.get(media.id).delete() MediaEntry.query.get(media.id).delete()
User.query.get(user_a.id).delete() User.query.get(user_a.id).delete()
def test_garbage_collection_task(test_app):
""" Test old media entry are removed by GC task """
user = fixture_add_user()
# 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"
)
# Find media manager
media_type, media_manager = sniff_media(file_data, "mah_test.jpg")
entry = new_upload_entry(user)
entry.id = entry_id
entry.title = "Mah Image"
entry.slug = "slugy-slug-slug"
entry.media_type = 'image'
entry.created = now - datetime.timedelta(days=2)
entry.save()
# Validate the model exists
assert MediaEntry.query.filter_by(id=entry_id).first() is not None
# Call the garbage collection task
collect_garbage()
# Now validate the image has been deleted
assert MediaEntry.query.filter_by(id=entry_id).first() is None

View File

@ -157,6 +157,14 @@ def json_response(serializable, _disable_cors=False, *args, **kw):
return response return response
def json_error(error_str, status=400, *args, **kwargs):
"""
This is like json_response but takes an error message in and formats
it in {"error": error_str}. This also sets the default HTTP status
code to 400.
"""
return json_response({"error": error_str}, status=status, *args, **kwargs)
def form_response(data, *args, **kwargs): def form_response(data, *args, **kwargs):
""" """
Responds using application/x-www-form-urlencoded and returns a werkzeug Responds using application/x-www-form-urlencoded and returns a werkzeug