diff options
Diffstat (limited to 'mediagoblin/plugins')
34 files changed, 2759 insertions, 0 deletions
diff --git a/mediagoblin/plugins/README b/mediagoblin/plugins/README new file mode 100644 index 00000000..a2b92334 --- /dev/null +++ b/mediagoblin/plugins/README @@ -0,0 +1,6 @@ +======== + README +======== + +This directory holds the MediaGoblin core plugins. These plugins are not +enabled by default. See documentation for enabling plugins. diff --git a/mediagoblin/plugins/__init__.py b/mediagoblin/plugins/__init__.py new file mode 100644 index 00000000..719b56e7 --- /dev/null +++ b/mediagoblin/plugins/__init__.py @@ -0,0 +1,16 @@ +# 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/>. + diff --git a/mediagoblin/plugins/api/__init__.py b/mediagoblin/plugins/api/__init__.py new file mode 100644 index 00000000..1eddd9e0 --- /dev/null +++ b/mediagoblin/plugins/api/__init__.py @@ -0,0 +1,47 @@ +# 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 os +import logging + +from mediagoblin.tools import pluginapi + +_log = logging.getLogger(__name__) + +PLUGIN_DIR = os.path.dirname(__file__) + +def setup_plugin(): + _log.info('Setting up API...') + + config = pluginapi.get_config(__name__) + + _log.debug('API config: {0}'.format(config)) + + routes = [ + ('mediagoblin.plugins.api.test', + '/api/test', + 'mediagoblin.plugins.api.views:api_test'), + ('mediagoblin.plugins.api.entries', + '/api/entries', + 'mediagoblin.plugins.api.views:get_entries'), + ('mediagoblin.plugins.api.post_entry', + '/api/submit', + 'mediagoblin.plugins.api.views:post_entry')] + + pluginapi.register_routes(routes) + +hooks = { + 'setup': setup_plugin} diff --git a/mediagoblin/plugins/api/tools.py b/mediagoblin/plugins/api/tools.py new file mode 100644 index 00000000..92411f4b --- /dev/null +++ b/mediagoblin/plugins/api/tools.py @@ -0,0 +1,164 @@ +# 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 json + +from functools import wraps +from urlparse import urljoin +from werkzeug.exceptions import Forbidden +from werkzeug.wrappers import Response +from mediagoblin import mg_globals +from mediagoblin.tools.pluginapi import PluginManager +from mediagoblin.storage.filestorage import BasicFileStorage + +_log = logging.getLogger(__name__) + + +class Auth(object): + ''' + An object with two significant methods, 'trigger' and 'run'. + + Using a similar object to this, plugins can register specific + authentication logic, for example the GET param 'access_token' for OAuth. + + - trigger: Analyze the 'request' argument, return True if you think you + can handle the request, otherwise return False + - run: The authentication logic, set the request.user object to the user + you intend to authenticate and return True, otherwise return False. + + If run() returns False, an HTTP 403 Forbidden error will be shown. + + You may also display custom errors, just raise them within the run() + method. + ''' + def trigger(self, request): + raise NotImplemented() + + def __call__(self, request, *args, **kw): + raise NotImplemented() + + +def json_response(serializable, _disable_cors=False, *args, **kw): + ''' + Serializes a json objects and returns a werkzeug Response object with the + serialized value as the response body and Content-Type: application/json. + + :param serializable: A json-serializable object + + Any extra arguments and keyword arguments are passed to the + Response.__init__ method. + ''' + response = Response(json.dumps(serializable), *args, content_type='application/json', **kw) + + if not _disable_cors: + cors_headers = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, X-Requested-With'} + for key, value in cors_headers.iteritems(): + response.headers.set(key, value) + + return response + + +def get_entry_serializable(entry, urlgen): + ''' + Returns a serializable dict() of a MediaEntry instance. + + :param entry: A MediaEntry instance + :param urlgen: An urlgen instance, can be found on the request object passed + to views. + ''' + return { + 'user': entry.get_uploader.username, + 'user_id': entry.get_uploader.id, + 'user_bio': entry.get_uploader.bio, + 'user_bio_html': entry.get_uploader.bio_html, + 'user_permalink': urlgen('mediagoblin.user_pages.user_home', + user=entry.get_uploader.username, + qualified=True), + 'id': entry.id, + 'created': entry.created.isoformat(), + 'title': entry.title, + 'license': entry.license, + 'description': entry.description, + 'description_html': entry.description_html, + 'media_type': entry.media_type, + 'state': entry.state, + 'permalink': entry.url_for_self(urlgen, qualified=True), + 'media_files': get_media_file_paths(entry.media_files, urlgen)} + + +def get_media_file_paths(media_files, urlgen): + ''' + Returns a dictionary of media files with `file_handle` => `qualified URL` + + :param media_files: dict-like object consisting of `file_handle => `listy + filepath` pairs. + :param urlgen: An urlgen object, usually found on request.urlgen. + ''' + media_urls = {} + + for key, val in media_files.items(): + if isinstance(mg_globals.public_store, BasicFileStorage): + # BasicFileStorage does not provide a qualified URI + media_urls[key] = urljoin( + urlgen('index', qualified=True), + mg_globals.public_store.file_url(val)) + else: + media_urls[key] = mg_globals.public_store.file_url(val) + + return media_urls + + +def api_auth(controller): + ''' + Decorator, allows plugins to register auth methods that will then be + evaluated against the request, finally a worthy authenticator object is + chosen and used to decide whether to grant or deny access. + ''' + @wraps(controller) + def wrapper(request, *args, **kw): + auth_candidates = [] + + for auth in PluginManager().get_hook_callables('auth'): + if auth.trigger(request): + _log.debug('{0} believes it is capable of authenticating this request.'.format(auth)) + auth_candidates.append(auth) + + # If we can't find any authentication methods, we should not let them + # pass. + if not auth_candidates: + raise Forbidden() + + # For now, just select the first one in the list + auth = auth_candidates[0] + + _log.debug('Using {0} to authorize request {1}'.format( + auth, request.url)) + + if not auth(request, *args, **kw): + if getattr(auth, 'errors', []): + return json_response({ + 'status': 403, + 'errors': auth.errors}) + + raise Forbidden() + + return controller(request, *args, **kw) + + return wrapper diff --git a/mediagoblin/plugins/api/views.py b/mediagoblin/plugins/api/views.py new file mode 100644 index 00000000..9159fe65 --- /dev/null +++ b/mediagoblin/plugins/api/views.py @@ -0,0 +1,122 @@ +# 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 logging + +from os.path import splitext +from werkzeug.exceptions import BadRequest, Forbidden +from werkzeug.wrappers import Response + +from mediagoblin.decorators import require_active_login +from mediagoblin.meddleware.csrf import csrf_exempt +from mediagoblin.media_types import sniff_media +from mediagoblin.plugins.api.tools import api_auth, get_entry_serializable, \ + json_response +from mediagoblin.submit.lib import check_file_field, prepare_queue_task, \ + run_process_media, new_upload_entry + +_log = logging.getLogger(__name__) + + +@csrf_exempt +@api_auth +@require_active_login +def post_entry(request): + _log.debug('Posting entry') + + if request.method == 'OPTIONS': + return json_response({'status': 200}) + + if request.method != 'POST': + _log.debug('Must POST against post_entry') + raise BadRequest() + + if not check_file_field(request, 'file'): + _log.debug('File field not found') + raise BadRequest() + + media_file = request.files['file'] + + media_type, media_manager = sniff_media(media_file) + + entry = new_upload_entry(request.user) + entry.media_type = unicode(media_type) + entry.title = unicode(request.form.get('title') + or splitext(media_file.filename)[0]) + + entry.description = unicode(request.form.get('description')) + entry.license = unicode(request.form.get('license', '')) + + entry.generate_slug() + + # queue appropriately + queue_file = prepare_queue_task(request.app, entry, media_file.filename) + + with queue_file: + queue_file.write(request.files['file'].stream.read()) + + # Save now so we have this data before kicking off processing + entry.save() + + if request.form.get('callback_url'): + metadata = request.db.ProcessingMetaData() + metadata.media_entry = entry + metadata.callback_url = unicode(request.form['callback_url']) + metadata.save() + + # Pass off to processing + # + # (... don't change entry after this point to avoid race + # conditions with changes to the document via processing code) + feed_url = request.urlgen( + 'mediagoblin.user_pages.atom_feed', + qualified=True, user=request.user.username) + run_process_media(entry, feed_url) + + return json_response(get_entry_serializable(entry, request.urlgen)) + + +@api_auth +@require_active_login +def api_test(request): + user_data = { + 'username': request.user.username, + 'email': request.user.email} + + # TODO: This is the *only* thing using Response() here, should that + # not simply use json_response()? + return Response(json.dumps(user_data)) + + +def get_entries(request): + entries = request.db.MediaEntry.query + + # TODO: Make it possible to fetch unprocessed media, or media in-processing + entries = entries.filter_by(state=u'processed') + + # TODO: Add sort order customization + entries = entries.order_by(request.db.MediaEntry.created.desc()) + + # TODO: Fetch default and upper limit from config + entries = entries.limit(int(request.GET.get('limit') or 10)) + + entries_serializable = [] + + for entry in entries: + entries_serializable.append(get_entry_serializable(entry, request.urlgen)) + + return json_response(entries_serializable) diff --git a/mediagoblin/plugins/flatpagesfile/README.rst b/mediagoblin/plugins/flatpagesfile/README.rst new file mode 100644 index 00000000..59cd6217 --- /dev/null +++ b/mediagoblin/plugins/flatpagesfile/README.rst @@ -0,0 +1,163 @@ +.. _flatpagesfile-chapter: + +====================== + flatpagesfile plugin +====================== + +This is the flatpages file plugin. It allows you to add pages to your +MediaGoblin instance which are not generated from user content. For +example, this is useful for these pages: + +* About this site +* Terms of service +* Privacy policy +* How to get an account here +* ... + + +How to configure +================ + +Add the following to your MediaGoblin .ini file in the ``[plugins]`` +section:: + + [[mediagoblin.plugins.flatpagesfile]] + + +This tells MediaGoblin to load the flatpagesfile plugin. This is the +subsection that you'll do all flatpagesfile plugin configuration in. + + +How to add pages +================ + +To add a new page to your site, you need to do two things: + +1. add a route to the MediaGoblin .ini file in the flatpagesfile + subsection + +2. write a template that will get served when that route is requested + + +Routes +------ + +First, let's talk about the route. + +A route is a key/value in your configuration file. + +The key for the route is the route name You can use this with `url()` +in templates to have MediaGoblin automatically build the urls for +you. It's very handy. + +It should be "unique" and it should be alphanumeric characters and +hyphens. I wouldn't put spaces in there. + +Examples: ``flatpages-about``, ``about-view``, ``contact-view``, ... + +The value has two parts separated by commas: + +1. **route path**: This is the url that this route matches. + + Examples: ``/about``, ``/contact``, ``/pages/about``, ... + + You can do anything with this that you can do with the routepath + parameter of `routes.Route`. For more details, see `the routes + documentation <http://routes.readthedocs.org/en/latest/>`_. + + Example: ``/siteadmin/{adminname:\w+}`` + + .. Note:: + + If you're doing something fancy, enclose the route in single + quotes. + + For example: ``'/siteadmin/{adminname:\w+}'`` + +2. **template**: The template to use for this url. The template is in + the flatpagesfile template directory, so you just need to specify + the file name. + + Like with other templates, if it's an HTML file, it's good to use + the ``.html`` extensions. + + Examples: ``index.html``, ``about.html``, ``contact.html``, ... + + +Here's an example configuration that adds two flat pages: one for an +"About this site" page and one for a "Terms of service" page:: + + [[mediagoblin.plugins.flatpagesfile]] + about-view = '/about', about.html + terms-view = '/terms', terms.html + + +.. Note:: + + The order in which you define the routes in the config file is the + order in which they're checked for incoming requests. + + +Templates +--------- + +To add pages, you must edit template files on the file system in your +`local_templates` directory. + +The directory structure looks kind of like this:: + + local_templates + |- flatpagesfile + |- flatpage1.html + |- flatpage2.html + |- ... + + +The ``.html`` file contains the content of your page. It's just a +template like all the other templates you have. + +Here's an example that extends the `flatpagesfile/base.html` +template:: + + {% extends "flatpagesfile/base.html" %} + {% block mediagoblin_content %} + <h1>About this site</h1> + <p> + This site is a MediaGoblin instance set up to host media for + me, my family and my friends. + </p> + {% endblock %} + + +.. Note:: + + If you have a bunch of flatpages that kind of look like one + another, take advantage of Jinja2 template extending and create a + base template that the others extend. + + +Recipes +======= + +Url variables +------------- + +You can handle urls like ``/about/{name}`` and access the name that's +passed in in the template. + +Sample route:: + + about-page = '/about/{name}', about.html + +Sample template:: + + {% extends "flatpagesfile/base.html" %} + {% block mediagoblin_content %} + + <h1>About page for {{ request.matchdict['name'] }}</h1> + + {% endblock %} + +See the `the routes documentation +<http://routes.readthedocs.org/en/latest/>`_ for syntax details for +the route. Values will end up in the ``request.matchdict`` dict. diff --git a/mediagoblin/plugins/flatpagesfile/__init__.py b/mediagoblin/plugins/flatpagesfile/__init__.py new file mode 100644 index 00000000..3d797809 --- /dev/null +++ b/mediagoblin/plugins/flatpagesfile/__init__.py @@ -0,0 +1,78 @@ +# 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 os + +import jinja2 + +from mediagoblin.tools import pluginapi +from mediagoblin.tools.response import render_to_response + + +PLUGIN_DIR = os.path.dirname(__file__) + + +_log = logging.getLogger(__name__) + + +@jinja2.contextfunction +def print_context(c): + s = [] + for key, val in c.items(): + s.append('%s: %s' % (key, repr(val))) + return '\n'.join(s) + + +def flatpage_handler_builder(template): + """Flatpage view generator + + Given a template, generates the controller function for handling that + route. + + """ + def _flatpage_handler_builder(request): + return render_to_response( + request, 'flatpagesfile/%s' % template, + {'request': request}) + return _flatpage_handler_builder + + +def setup_plugin(): + config = pluginapi.get_config('mediagoblin.plugins.flatpagesfile') + + _log.info('Setting up flatpagesfile....') + + # Register the template path. + pluginapi.register_template_path(os.path.join(PLUGIN_DIR, 'templates')) + + pages = config.items() + + routes = [] + for name, (url, template) in pages: + name = 'flatpagesfile.%s' % name.strip() + controller = flatpage_handler_builder(template) + routes.append( + (name, url, controller)) + + pluginapi.register_routes(routes) + _log.info('Done setting up flatpagesfile!') + + +hooks = { + 'setup': setup_plugin + } diff --git a/mediagoblin/plugins/flatpagesfile/templates/flatpagesfile/base.html b/mediagoblin/plugins/flatpagesfile/templates/flatpagesfile/base.html new file mode 100644 index 00000000..1cf9dd9d --- /dev/null +++ b/mediagoblin/plugins/flatpagesfile/templates/flatpagesfile/base.html @@ -0,0 +1,18 @@ +{# +# 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/>. +-#} +{% extends "mediagoblin/base.html" %} diff --git a/mediagoblin/plugins/geolocation/__init__.py b/mediagoblin/plugins/geolocation/__init__.py new file mode 100644 index 00000000..5d14590e --- /dev/null +++ b/mediagoblin/plugins/geolocation/__init__.py @@ -0,0 +1,35 @@ +# 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 import pluginapi +import os + +PLUGIN_DIR = os.path.dirname(__file__) + +def setup_plugin(): + config = pluginapi.get_config('mediagoblin.plugins.geolocation') + + # Register the template path. + pluginapi.register_template_path(os.path.join(PLUGIN_DIR, 'templates')) + + pluginapi.register_template_hooks( + {"image_sideinfo": "mediagoblin/plugins/geolocation/map.html", + "image_head": "mediagoblin/plugins/geolocation/map_js_head.html"}) + + +hooks = { + 'setup': setup_plugin + } diff --git a/mediagoblin/plugins/geolocation/templates/mediagoblin/plugins/geolocation/map.html b/mediagoblin/plugins/geolocation/templates/mediagoblin/plugins/geolocation/map.html new file mode 100644 index 00000000..70f837ff --- /dev/null +++ b/mediagoblin/plugins/geolocation/templates/mediagoblin/plugins/geolocation/map.html @@ -0,0 +1,59 @@ +{# +# 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/>. +#} + +{% block geolocation_map %} + {% if media.media_data.gps_latitude is defined + and media.media_data.gps_latitude + and media.media_data.gps_longitude is defined + and media.media_data.gps_longitude %} + <h3>{% trans %}Location{% endtrans %}</h3> + <div> + {%- set lon = media.media_data.gps_longitude %} + {%- set lat = media.media_data.gps_latitude %} + {%- set osm_url = "http://openstreetmap.org/?mlat={lat}&mlon={lon}".format(lat=lat, lon=lon) %} + <div id="tile-map" style="width: 100%; height: 196px;"> + <input type="hidden" id="gps-longitude" + value="{{ lon }}" /> + <input type="hidden" id="gps-latitude" + value="{{ lat }}" /> + </div> + <script> <!-- pop up full OSM license when clicked --> + $(document).ready(function(){ + $("#osm_license_link").click(function () { + $("#osm_attrib").slideToggle("slow"); + }); + }); + </script> + <div id="osm_attrib" class="hidden"><ul><li> + Data ©<a + href="http://www.openstreetmap.org/copyright">OpenStreetMap</a> + contributors + </li><li>Imaging ©<a + href="http://mapquest.com">MapQuest</a></li><li>Maps powered by + <a href="http://leafletjs.com/"> Leaflet</a></li></ul> + </div> + <p> + <small> + {% trans -%} + View on <a href="{{ osm_url }}">OpenStreetMap</a> + {%- endtrans %} + </small> + </p> + </div> + {% endif %} +{% endblock %} diff --git a/mediagoblin/plugins/geolocation/templates/mediagoblin/plugins/geolocation/map_js_head.html b/mediagoblin/plugins/geolocation/templates/mediagoblin/plugins/geolocation/map_js_head.html new file mode 100644 index 00000000..aca0f730 --- /dev/null +++ b/mediagoblin/plugins/geolocation/templates/mediagoblin/plugins/geolocation/map_js_head.html @@ -0,0 +1,25 @@ +{# +# 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/>. +#} + +<link rel="stylesheet" + href="{{ request.staticdirect('/extlib/leaflet/leaflet.css') }}" /> + +<script type="text/javascript" + src="{{ request.staticdirect('/extlib/leaflet/leaflet.js') }}"></script> +<script type="text/javascript" + src="{{ request.staticdirect('/js/geolocation-map.js') }}"></script> diff --git a/mediagoblin/plugins/httpapiauth/__init__.py b/mediagoblin/plugins/httpapiauth/__init__.py new file mode 100644 index 00000000..2b2d593c --- /dev/null +++ b/mediagoblin/plugins/httpapiauth/__init__.py @@ -0,0 +1,58 @@ +# 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 + +from werkzeug.exceptions import Unauthorized + +from mediagoblin.auth.tools import check_login_simple +from mediagoblin.plugins.api.tools import Auth + +_log = logging.getLogger(__name__) + + +def setup_http_api_auth(): + _log.info('Setting up HTTP API Auth...') + + +class HTTPAuth(Auth): + def trigger(self, request): + if request.authorization: + return True + + return False + + def __call__(self, request, *args, **kw): + _log.debug('Trying to authorize the user agent via HTTP Auth') + if not request.authorization: + return False + + user = check_login_simple(unicode(request.authorization['username']), + request.authorization['password']) + + if user: + request.user = user + return True + else: + raise Unauthorized() + + return False + + + +hooks = { + 'setup': setup_http_api_auth, + 'auth': HTTPAuth()} diff --git a/mediagoblin/plugins/oauth/README.rst b/mediagoblin/plugins/oauth/README.rst new file mode 100644 index 00000000..753b180f --- /dev/null +++ b/mediagoblin/plugins/oauth/README.rst @@ -0,0 +1,148 @@ +============== + OAuth plugin +============== + +.. warning:: + In its current state. This plugin has received no security audit. + Development has been entirely focused on Making It Work(TM). Use this + plugin with caution. + + Additionally, this and the API may break... consider it pre-alpha. + There's also a known issue that the OAuth client doesn't do + refresh tokens so this might result in issues for users. + +The OAuth plugin enables third party web applications to authenticate as one or +more GNU MediaGoblin users in a safe way in order retrieve, create and update +content stored on the GNU MediaGoblin instance. + +The OAuth plugin is based on the `oauth v2.25 draft`_ and is pointing by using +the ``oauthlib.oauth2.draft25.WebApplicationClient`` from oauthlib_ to a +mediagoblin instance and building the OAuth 2 provider logic around the client. + +There are surely some aspects of the OAuth v2.25 draft that haven't made it +into this plugin due to the technique used to develop it. + +.. _`oauth v2.25 draft`: http://tools.ietf.org/html/draft-ietf-oauth-v2-25 +.. _oauthlib: http://pypi.python.org/pypi/oauthlib + + +Set up the OAuth plugin +======================= + +1. Add the following to your MediaGoblin .ini file in the ``[plugins]`` section:: + + [[mediagoblin.plugins.oauth]] + +2. Run:: + + gmg dbupdate + + in order to create and apply migrations to any database tables that the + plugin requires. + +.. note:: + This only enables the OAuth plugin. To be able to let clients fetch data + from the MediaGoblin instance you should also enable the API plugin or some + other plugin that supports authenticating with OAuth credentials. + + +Authenticate against GNU MediaGoblin +==================================== + +.. note:: + As mentioned in `capabilities`_ GNU MediaGoblin currently only supports the + `Authorization Code Grant`_ procedure for obtaining an OAuth access token. + +Authorization Code Grant +------------------------ + +.. note:: + As mentioned in `incapabilities`_ GNU MediaGoblin currently does not + support `client registration`_ + +The `authorization code grant`_ works in the following way: + +`Definitions` + + Authorization server + The GNU MediaGoblin instance + Resource server + Also the GNU MediaGoblin instance ;) + Client + The web application intended to use the data + Redirect uri + An URI pointing to a page controlled by the *client* + Resource owner + The GNU MediaGoblin user who's resources the client requests access to + User agent + Commonly the GNU MediaGoblin user's web browser + Authorization code + An intermediate token that is exchanged for an *access token* + Access token + A secret token that the *client* uses to authenticate itself agains the + *resource server* as a specific *resource owner*. + + +Brief description of the procedure +++++++++++++++++++++++++++++++++++ + +1. The *client* requests an *authorization code* from the *authorization + server* by redirecting the *user agent* to the `Authorization Endpoint`_. + Which parameters should be included in the redirect are covered later in + this document. +2. The *authorization server* authenticates the *resource owner* and redirects + the *user agent* back to the *redirect uri* (covered later in this + document). +3. The *client* receives the request from the *user agent*, attached is the + *authorization code*. +4. The *client* requests an *access token* from the *authorization server* +5. \?\?\?\?\? +6. Profit! + + +Detailed description of the procedure ++++++++++++++++++++++++++++++++++++++ + +TBD, in the meantime here is a proof-of-concept GNU MediaGoblin client: + +https://github.com/jwandborg/omgmg/ + +and here are some detailed descriptions from other OAuth 2 +providers: + +- https://developers.google.com/accounts/docs/OAuth2WebServer +- https://developers.facebook.com/docs/authentication/server-side/ + +and if you're unsure about anything, there's the `OAuth v2.25 draft +<http://tools.ietf.org/html/draft-ietf-oauth-v2-25>`_, the `OAuth plugin +source code +<http://gitorious.org/mediagoblin/mediagoblin/trees/master/mediagoblin/plugins/oauth>`_ +and the `#mediagoblin IRC channel <http://mediagoblin.org/pages/join.html#irc>`_. + + +Capabilities +============ + +- `Authorization endpoint`_ - Located at ``/oauth/authorize`` +- `Token endpoint`_ - Located at ``/oauth/access_token`` +- `Authorization Code Grant`_ +- `Client Registration`_ + +.. _`Authorization endpoint`: http://tools.ietf.org/html/draft-ietf-oauth-v2-25#section-3.1 +.. _`Token endpoint`: http://tools.ietf.org/html/draft-ietf-oauth-v2-25#section-3.2 +.. _`Authorization Code Grant`: http://tools.ietf.org/html/draft-ietf-oauth-v2-25#section-4.1 +.. _`Client Registration`: http://tools.ietf.org/html/draft-ietf-oauth-v2-25#section-2 + +Incapabilities +============== + +- Only `bearer tokens`_ are issued. +- `Implicit Grant`_ +- `Force TLS for token endpoint`_ - This one is up the the siteadmin +- Authorization `scope`_ and `state` +- ... + +.. _`bearer tokens`: http://tools.ietf.org/html/draft-ietf-oauth-v2-bearer-08 +.. _`scope`: http://tools.ietf.org/html/draft-ietf-oauth-v2-25#section-3.3 +.. _`Implicit Grant`: http://tools.ietf.org/html/draft-ietf-oauth-v2-25#section-4.2 +.. _`Force TLS for token endpoint`: http://tools.ietf.org/html/draft-ietf-oauth-v2-25#section-3.2 diff --git a/mediagoblin/plugins/oauth/__init__.py b/mediagoblin/plugins/oauth/__init__.py new file mode 100644 index 00000000..5762379d --- /dev/null +++ b/mediagoblin/plugins/oauth/__init__.py @@ -0,0 +1,109 @@ +# 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 os +import logging + +from mediagoblin.tools import pluginapi +from mediagoblin.plugins.oauth.models import OAuthToken, OAuthClient, \ + OAuthUserClient +from mediagoblin.plugins.api.tools import Auth + +_log = logging.getLogger(__name__) + +PLUGIN_DIR = os.path.dirname(__file__) + + +def setup_plugin(): + config = pluginapi.get_config('mediagoblin.plugins.oauth') + + _log.info('Setting up OAuth...') + _log.debug('OAuth config: {0}'.format(config)) + + routes = [ + ('mediagoblin.plugins.oauth.authorize', + '/oauth/authorize', + 'mediagoblin.plugins.oauth.views:authorize'), + ('mediagoblin.plugins.oauth.authorize_client', + '/oauth/client/authorize', + 'mediagoblin.plugins.oauth.views:authorize_client'), + ('mediagoblin.plugins.oauth.access_token', + '/oauth/access_token', + 'mediagoblin.plugins.oauth.views:access_token'), + ('mediagoblin.plugins.oauth.list_connections', + '/oauth/client/connections', + 'mediagoblin.plugins.oauth.views:list_connections'), + ('mediagoblin.plugins.oauth.register_client', + '/oauth/client/register', + 'mediagoblin.plugins.oauth.views:register_client'), + ('mediagoblin.plugins.oauth.list_clients', + '/oauth/client/list', + 'mediagoblin.plugins.oauth.views:list_clients')] + + pluginapi.register_routes(routes) + pluginapi.register_template_path(os.path.join(PLUGIN_DIR, 'templates')) + + +class OAuthAuth(Auth): + def trigger(self, request): + if 'access_token' in request.GET: + return True + + return False + + def __call__(self, request, *args, **kw): + self.errors = [] + # TODO: Add suport for client credentials authorization + client_id = request.GET.get('client_id') # TODO: Not used + client_secret = request.GET.get('client_secret') # TODO: Not used + access_token = request.GET.get('access_token') + + _log.debug('Authorizing request {0}'.format(request.url)) + + if access_token: + token = OAuthToken.query.filter(OAuthToken.token == access_token)\ + .first() + + if not token: + self.errors.append('Invalid access token') + return False + + _log.debug('Access token: {0}'.format(token)) + _log.debug('Client: {0}'.format(token.client)) + + relation = OAuthUserClient.query.filter( + (OAuthUserClient.user == token.user) + & (OAuthUserClient.client == token.client) + & (OAuthUserClient.state == u'approved')).first() + + _log.debug('Relation: {0}'.format(relation)) + + if not relation: + self.errors.append( + u'Client has not been approved by the resource owner') + return False + + request.user = token.user + return True + + self.errors.append(u'No access_token specified') + + return False + +hooks = { + 'setup': setup_plugin, + 'auth': OAuthAuth() + } diff --git a/mediagoblin/plugins/oauth/forms.py b/mediagoblin/plugins/oauth/forms.py new file mode 100644 index 00000000..5edd992a --- /dev/null +++ b/mediagoblin/plugins/oauth/forms.py @@ -0,0 +1,69 @@ +# 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 wtforms + +from urlparse import urlparse + +from mediagoblin.tools.extlib.wtf_html5 import URLField +from mediagoblin.tools.translate import lazy_pass_to_ugettext as _ + + +class AuthorizationForm(wtforms.Form): + client_id = wtforms.HiddenField(u'', + validators=[wtforms.validators.Required()]) + next = wtforms.HiddenField(u'', validators=[wtforms.validators.Required()]) + allow = wtforms.SubmitField(_(u'Allow')) + deny = wtforms.SubmitField(_(u'Deny')) + + +class ClientRegistrationForm(wtforms.Form): + name = wtforms.TextField(_('Name'), [wtforms.validators.Required()], + description=_('The name of the OAuth client')) + description = wtforms.TextAreaField(_('Description'), + [wtforms.validators.Length(min=0, max=500)], + description=_('''This will be visible to users allowing your + application to authenticate as them.''')) + type = wtforms.SelectField(_('Type'), + [wtforms.validators.Required()], + choices=[ + ('confidential', 'Confidential'), + ('public', 'Public')], + description=_('''<strong>Confidential</strong> - The client can + make requests to the GNU MediaGoblin instance that can not be + intercepted by the user agent (e.g. server-side client).<br /> + <strong>Public</strong> - The client can't make confidential + requests to the GNU MediaGoblin instance (e.g. client-side + JavaScript client).''')) + + redirect_uri = URLField(_('Redirect URI'), + [wtforms.validators.Optional(), wtforms.validators.URL()], + description=_('''The redirect URI for the applications, this field + is <strong>required</strong> for public clients.''')) + + def __init__(self, *args, **kw): + wtforms.Form.__init__(self, *args, **kw) + + def validate(self): + if not wtforms.Form.validate(self): + return False + + if self.type.data == 'public' and not self.redirect_uri.data: + self.redirect_uri.errors.append( + _('This field is required for public clients')) + return False + + return True diff --git a/mediagoblin/plugins/oauth/migrations.py b/mediagoblin/plugins/oauth/migrations.py new file mode 100644 index 00000000..d7b89da3 --- /dev/null +++ b/mediagoblin/plugins/oauth/migrations.py @@ -0,0 +1,158 @@ +# 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 datetime import datetime, timedelta +from sqlalchemy import (MetaData, Table, Column, + Integer, Unicode, Enum, DateTime, ForeignKey) +from sqlalchemy.ext.declarative import declarative_base + +from mediagoblin.db.migration_tools import RegisterMigration +from mediagoblin.db.models import User + + +MIGRATIONS = {} + + +class OAuthClient_v0(declarative_base()): + __tablename__ = 'oauth__client' + + id = Column(Integer, primary_key=True) + created = Column(DateTime, nullable=False, + default=datetime.now) + + name = Column(Unicode) + description = Column(Unicode) + + identifier = Column(Unicode, unique=True, index=True) + secret = Column(Unicode, index=True) + + owner_id = Column(Integer, ForeignKey(User.id)) + redirect_uri = Column(Unicode) + + type = Column(Enum( + u'confidential', + u'public', + name=u'oauth__client_type')) + + +class OAuthUserClient_v0(declarative_base()): + __tablename__ = 'oauth__user_client' + id = Column(Integer, primary_key=True) + + user_id = Column(Integer, ForeignKey(User.id)) + client_id = Column(Integer, ForeignKey(OAuthClient_v0.id)) + + state = Column(Enum( + u'approved', + u'rejected', + name=u'oauth__relation_state')) + + +class OAuthToken_v0(declarative_base()): + __tablename__ = 'oauth__tokens' + + id = Column(Integer, primary_key=True) + created = Column(DateTime, nullable=False, + default=datetime.now) + expires = Column(DateTime, nullable=False, + default=lambda: datetime.now() + timedelta(days=30)) + token = Column(Unicode, index=True) + refresh_token = Column(Unicode, index=True) + + user_id = Column(Integer, ForeignKey(User.id), nullable=False, + index=True) + + client_id = Column(Integer, ForeignKey(OAuthClient_v0.id), nullable=False) + + def __repr__(self): + return '<{0} #{1} expires {2} [{3}, {4}]>'.format( + self.__class__.__name__, + self.id, + self.expires.isoformat(), + self.user, + self.client) + + +class OAuthCode_v0(declarative_base()): + __tablename__ = 'oauth__codes' + + id = Column(Integer, primary_key=True) + created = Column(DateTime, nullable=False, + default=datetime.now) + expires = Column(DateTime, nullable=False, + default=lambda: datetime.now() + timedelta(minutes=5)) + code = Column(Unicode, index=True) + + user_id = Column(Integer, ForeignKey(User.id), nullable=False, + index=True) + + client_id = Column(Integer, ForeignKey(OAuthClient_v0.id), nullable=False) + + +class OAuthRefreshToken_v0(declarative_base()): + __tablename__ = 'oauth__refresh_tokens' + + id = Column(Integer, primary_key=True) + created = Column(DateTime, nullable=False, + default=datetime.now) + + token = Column(Unicode, index=True) + + user_id = Column(Integer, ForeignKey(User.id), nullable=False) + + # XXX: Is it OK to use OAuthClient_v0.id in this way? + client_id = Column(Integer, ForeignKey(OAuthClient_v0.id), nullable=False) + + +@RegisterMigration(1, MIGRATIONS) +def remove_and_replace_token_and_code(db): + metadata = MetaData(bind=db.bind) + + token_table = Table('oauth__tokens', metadata, autoload=True, + autoload_with=db.bind) + + token_table.drop() + + code_table = Table('oauth__codes', metadata, autoload=True, + autoload_with=db.bind) + + code_table.drop() + + OAuthClient_v0.__table__.create(db.bind) + OAuthUserClient_v0.__table__.create(db.bind) + OAuthToken_v0.__table__.create(db.bind) + OAuthCode_v0.__table__.create(db.bind) + + db.commit() + + +@RegisterMigration(2, MIGRATIONS) +def remove_refresh_token_field(db): + metadata = MetaData(bind=db.bind) + + token_table = Table('oauth__tokens', metadata, autoload=True, + autoload_with=db.bind) + + refresh_token = token_table.columns['refresh_token'] + + refresh_token.drop() + db.commit() + +@RegisterMigration(3, MIGRATIONS) +def create_refresh_token_table(db): + OAuthRefreshToken_v0.__table__.create(db.bind) + + db.commit() diff --git a/mediagoblin/plugins/oauth/models.py b/mediagoblin/plugins/oauth/models.py new file mode 100644 index 00000000..439424d3 --- /dev/null +++ b/mediagoblin/plugins/oauth/models.py @@ -0,0 +1,192 @@ +# 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 datetime import datetime, timedelta + + +from sqlalchemy import ( + Column, Unicode, Integer, DateTime, ForeignKey, Enum) +from sqlalchemy.orm import relationship, backref +from mediagoblin.db.base import Base +from mediagoblin.db.models import User +from mediagoblin.plugins.oauth.tools import generate_identifier, \ + generate_secret, generate_token, generate_code, generate_refresh_token + +# Don't remove this, I *think* it applies sqlalchemy-migrate functionality onto +# the models. +from migrate import changeset + + +class OAuthClient(Base): + __tablename__ = 'oauth__client' + + id = Column(Integer, primary_key=True) + created = Column(DateTime, nullable=False, + default=datetime.now) + + name = Column(Unicode) + description = Column(Unicode) + + identifier = Column(Unicode, unique=True, index=True, + default=generate_identifier) + secret = Column(Unicode, index=True, default=generate_secret) + + owner_id = Column(Integer, ForeignKey(User.id)) + owner = relationship( + User, + backref=backref('registered_clients', cascade='all, delete-orphan')) + + redirect_uri = Column(Unicode) + + type = Column(Enum( + u'confidential', + u'public', + name=u'oauth__client_type')) + + def update_secret(self): + self.secret = generate_secret() + + def __repr__(self): + return '<{0} {1}:{2} ({3})>'.format( + self.__class__.__name__, + self.id, + self.name.encode('ascii', 'replace'), + self.owner.username.encode('ascii', 'replace')) + + +class OAuthUserClient(Base): + __tablename__ = 'oauth__user_client' + id = Column(Integer, primary_key=True) + + user_id = Column(Integer, ForeignKey(User.id)) + user = relationship( + User, + backref=backref('oauth_client_relations', + cascade='all, delete-orphan')) + + client_id = Column(Integer, ForeignKey(OAuthClient.id)) + client = relationship( + OAuthClient, + backref=backref('oauth_user_relations', cascade='all, delete-orphan')) + + state = Column(Enum( + u'approved', + u'rejected', + name=u'oauth__relation_state')) + + def __repr__(self): + return '<{0} #{1} {2} [{3}, {4}]>'.format( + self.__class__.__name__, + self.id, + self.state.encode('ascii', 'replace'), + self.user, + self.client) + + +class OAuthToken(Base): + __tablename__ = 'oauth__tokens' + + id = Column(Integer, primary_key=True) + created = Column(DateTime, nullable=False, + default=datetime.now) + expires = Column(DateTime, nullable=False, + default=lambda: datetime.now() + timedelta(days=30)) + token = Column(Unicode, index=True, default=generate_token) + + user_id = Column(Integer, ForeignKey(User.id), nullable=False, + index=True) + user = relationship( + User, + backref=backref('oauth_tokens', cascade='all, delete-orphan')) + + client_id = Column(Integer, ForeignKey(OAuthClient.id), nullable=False) + client = relationship( + OAuthClient, + backref=backref('oauth_tokens', cascade='all, delete-orphan')) + + def __repr__(self): + return '<{0} #{1} expires {2} [{3}, {4}]>'.format( + self.__class__.__name__, + self.id, + self.expires.isoformat(), + self.user, + self.client) + +class OAuthRefreshToken(Base): + __tablename__ = 'oauth__refresh_tokens' + + id = Column(Integer, primary_key=True) + created = Column(DateTime, nullable=False, + default=datetime.now) + + token = Column(Unicode, index=True, + default=generate_refresh_token) + + user_id = Column(Integer, ForeignKey(User.id), nullable=False) + + user = relationship(User, backref=backref('oauth_refresh_tokens', + cascade='all, delete-orphan')) + + client_id = Column(Integer, ForeignKey(OAuthClient.id), nullable=False) + client = relationship(OAuthClient, + backref=backref( + 'oauth_refresh_tokens', + cascade='all, delete-orphan')) + + def __repr__(self): + return '<{0} #{1} [{3}, {4}]>'.format( + self.__class__.__name__, + self.id, + self.user, + self.client) + + +class OAuthCode(Base): + __tablename__ = 'oauth__codes' + + id = Column(Integer, primary_key=True) + created = Column(DateTime, nullable=False, + default=datetime.now) + expires = Column(DateTime, nullable=False, + default=lambda: datetime.now() + timedelta(minutes=5)) + code = Column(Unicode, index=True, default=generate_code) + + user_id = Column(Integer, ForeignKey(User.id), nullable=False, + index=True) + user = relationship(User, backref=backref('oauth_codes', + cascade='all, delete-orphan')) + + client_id = Column(Integer, ForeignKey(OAuthClient.id), nullable=False) + client = relationship(OAuthClient, backref=backref( + 'oauth_codes', + cascade='all, delete-orphan')) + + def __repr__(self): + return '<{0} #{1} expires {2} [{3}, {4}]>'.format( + self.__class__.__name__, + self.id, + self.expires.isoformat(), + self.user, + self.client) + + +MODELS = [ + OAuthToken, + OAuthRefreshToken, + OAuthCode, + OAuthClient, + OAuthUserClient] diff --git a/mediagoblin/plugins/oauth/templates/oauth/authorize.html b/mediagoblin/plugins/oauth/templates/oauth/authorize.html new file mode 100644 index 00000000..8a00c925 --- /dev/null +++ b/mediagoblin/plugins/oauth/templates/oauth/authorize.html @@ -0,0 +1,31 @@ +{# +# 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. +#, se, seee +# 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/>. +-#} +{% extends "mediagoblin/base.html" %} +{% import "mediagoblin/utils/wtforms.html" as wtforms_util %} + +{% block mediagoblin_content %} +<form action="{{ request.urlgen('mediagoblin.plugins.oauth.authorize_client') }}" + method="POST"> + <div class="form_box_xl"> + {{ csrf_token }} + <h2>Authorize {{ client.name }}?</h2> + <p class="client-description">{{ client.description }}</p> + {{ wtforms_util.render_divs(form) }} + </div> +</form> +{% endblock %} diff --git a/mediagoblin/plugins/oauth/templates/oauth/client/connections.html b/mediagoblin/plugins/oauth/templates/oauth/client/connections.html new file mode 100644 index 00000000..63b0230a --- /dev/null +++ b/mediagoblin/plugins/oauth/templates/oauth/client/connections.html @@ -0,0 +1,34 @@ +{# +# 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/>. +-#} +{% extends "mediagoblin/base.html" %} +{% import "/mediagoblin/utils/wtforms.html" as wtforms_util %} + +{% block mediagoblin_content %} +<h1>{% trans %}OAuth client connections{% endtrans %}</h1> +{% if connections %} +<ul> + {% for connection in connections %} + <li><span title="{{ connection.client.description }}">{{ + connection.client.name }}</span> - {{ connection.state }} + </li> + {% endfor %} +</ul> +{% else %} +<p>You haven't connected using an OAuth client before.</p> +{% endif %} +{% endblock %} diff --git a/mediagoblin/plugins/oauth/templates/oauth/client/list.html b/mediagoblin/plugins/oauth/templates/oauth/client/list.html new file mode 100644 index 00000000..21024bb7 --- /dev/null +++ b/mediagoblin/plugins/oauth/templates/oauth/client/list.html @@ -0,0 +1,45 @@ +{# +# 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/>. +-#} +{% extends "mediagoblin/base.html" %} +{% import "/mediagoblin/utils/wtforms.html" as wtforms_util %} + +{% block mediagoblin_content %} +<h1>{% trans %}Your OAuth clients{% endtrans %}</h1> +{% if clients %} +<ul> + {% for client in clients %} + <li>{{ client.name }} + <dl> + <dt>Type</dt> + <dd>{{ client.type }}</dd> + <dt>Description</dt> + <dd>{{ client.description }}</dd> + <dt>Identifier</dt> + <dd>{{ client.identifier }}</dd> + <dt>Secret</dt> + <dd>{{ client.secret }}</dd> + <dt>Redirect URI<dt> + <dd>{{ client.redirect_uri }}</dd> + </dl> + </li> + {% endfor %} +</ul> +{% else %} +<p>You don't have any clients yet. <a href="{{ request.urlgen('mediagoblin.plugins.oauth.register_client') }}">Add one</a>.</p> +{% endif %} +{% endblock %} diff --git a/mediagoblin/plugins/oauth/templates/oauth/client/register.html b/mediagoblin/plugins/oauth/templates/oauth/client/register.html new file mode 100644 index 00000000..6fd700d3 --- /dev/null +++ b/mediagoblin/plugins/oauth/templates/oauth/client/register.html @@ -0,0 +1,34 @@ +{# +# 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/>. +-#} +{% extends "mediagoblin/base.html" %} +{% import "/mediagoblin/utils/wtforms.html" as wtforms_util %} + +{% block mediagoblin_content %} +<form action="{{ request.urlgen('mediagoblin.plugins.oauth.register_client') }}" + method="POST"> + <div class="form_box_xl"> + <h1>Register OAuth client</h1> + {{ wtforms_util.render_divs(form) }} + <div class="form_submit_buttons"> + {{ csrf_token }} + <input type="submit" value="{% trans %}Add{% endtrans %}" + class="button_form" /> + </div> + </div> +</form> +{% endblock %} diff --git a/mediagoblin/plugins/oauth/tools.py b/mediagoblin/plugins/oauth/tools.py new file mode 100644 index 00000000..27ff32b4 --- /dev/null +++ b/mediagoblin/plugins/oauth/tools.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +# 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 uuid + +from random import getrandbits + +from datetime import datetime + +from functools import wraps + +from mediagoblin.plugins.api.tools import json_response + + +def require_client_auth(controller): + ''' + View decorator + + - Requires the presence of ``?client_id`` + ''' + # Avoid circular import + from mediagoblin.plugins.oauth.models import OAuthClient + + @wraps(controller) + def wrapper(request, *args, **kw): + if not request.GET.get('client_id'): + return json_response({ + 'status': 400, + 'errors': [u'No client identifier in URL']}, + _disable_cors=True) + + client = OAuthClient.query.filter( + OAuthClient.identifier == request.GET.get('client_id')).first() + + if not client: + return json_response({ + 'status': 400, + 'errors': [u'No such client identifier']}, + _disable_cors=True) + + return controller(request, client) + + return wrapper + + +def create_token(client, user): + ''' + Create an OAuthToken and an OAuthRefreshToken entry in the database + + Returns the data structure expected by the OAuth clients. + ''' + from mediagoblin.plugins.oauth.models import OAuthToken, OAuthRefreshToken + + token = OAuthToken() + token.user = user + token.client = client + token.save() + + refresh_token = OAuthRefreshToken() + refresh_token.user = user + refresh_token.client = client + refresh_token.save() + + # expire time of token in full seconds + # timedelta.total_seconds is python >= 2.7 or we would use that + td = token.expires - datetime.now() + exp_in = 86400*td.days + td.seconds # just ignore µsec + + return {'access_token': token.token, 'token_type': 'bearer', + 'refresh_token': refresh_token.token, 'expires_in': exp_in} + + +def generate_identifier(): + ''' Generates a ``uuid.uuid4()`` ''' + return unicode(uuid.uuid4()) + + +def generate_token(): + ''' Uses generate_identifier ''' + return generate_identifier() + + +def generate_refresh_token(): + ''' Uses generate_identifier ''' + return generate_identifier() + + +def generate_code(): + ''' Uses generate_identifier ''' + return generate_identifier() + + +def generate_secret(): + ''' + Generate a long string of pseudo-random characters + ''' + # XXX: We might not want it to use bcrypt, since bcrypt takes its time to + # generate the result. + return unicode(getrandbits(192)) + diff --git a/mediagoblin/plugins/oauth/views.py b/mediagoblin/plugins/oauth/views.py new file mode 100644 index 00000000..d6fd314f --- /dev/null +++ b/mediagoblin/plugins/oauth/views.py @@ -0,0 +1,254 @@ +# -*- coding: utf-8 -*- +# 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 + +from urllib import urlencode + +from werkzeug.exceptions import BadRequest + +from mediagoblin.tools.response import render_to_response, redirect +from mediagoblin.decorators import require_active_login +from mediagoblin.messages import add_message, SUCCESS +from mediagoblin.tools.translate import pass_to_ugettext as _ +from mediagoblin.plugins.oauth.models import OAuthCode, OAuthClient, \ + OAuthUserClient, OAuthRefreshToken +from mediagoblin.plugins.oauth.forms import ClientRegistrationForm, \ + AuthorizationForm +from mediagoblin.plugins.oauth.tools import require_client_auth, \ + create_token +from mediagoblin.plugins.api.tools import json_response + +_log = logging.getLogger(__name__) + + +@require_active_login +def register_client(request): + ''' + Register an OAuth client + ''' + form = ClientRegistrationForm(request.form) + + if request.method == 'POST' and form.validate(): + client = OAuthClient() + client.name = unicode(form.name.data) + client.description = unicode(form.description.data) + client.type = unicode(form.type.data) + client.owner_id = request.user.id + client.redirect_uri = unicode(form.redirect_uri.data) + + client.save() + + add_message(request, SUCCESS, _('The client {0} has been registered!')\ + .format( + client.name)) + + return redirect(request, 'mediagoblin.plugins.oauth.list_clients') + + return render_to_response( + request, + 'oauth/client/register.html', + {'form': form}) + + +@require_active_login +def list_clients(request): + clients = request.db.OAuthClient.query.filter( + OAuthClient.owner_id == request.user.id).all() + return render_to_response(request, 'oauth/client/list.html', + {'clients': clients}) + + +@require_active_login +def list_connections(request): + connections = OAuthUserClient.query.filter( + OAuthUserClient.user == request.user).all() + return render_to_response(request, 'oauth/client/connections.html', + {'connections': connections}) + + +@require_active_login +def authorize_client(request): + form = AuthorizationForm(request.form) + + client = OAuthClient.query.filter(OAuthClient.id == + form.client_id.data).first() + + if not client: + _log.error('No such client id as received from client authorization \ +form.') + raise BadRequest() + + if form.validate(): + relation = OAuthUserClient() + relation.user_id = request.user.id + relation.client_id = form.client_id.data + if form.allow.data: + relation.state = u'approved' + elif form.deny.data: + relation.state = u'rejected' + else: + raise BadRequest() + + relation.save() + + return redirect(request, location=form.next.data) + + return render_to_response( + request, + 'oauth/authorize.html', + {'form': form, + 'client': client}) + + +@require_client_auth +@require_active_login +def authorize(request, client): + # TODO: Get rid of the JSON responses in this view, it's called by the + # user-agent, not the client. + user_client_relation = OAuthUserClient.query.filter( + (OAuthUserClient.user == request.user) + & (OAuthUserClient.client == client)) + + if user_client_relation.filter(OAuthUserClient.state == + u'approved').count(): + redirect_uri = None + + if client.type == u'public': + if not client.redirect_uri: + return json_response({ + 'status': 400, + 'errors': + [u'Public clients should have a redirect_uri pre-set.']}, + _disable_cors=True) + + redirect_uri = client.redirect_uri + + if client.type == u'confidential': + redirect_uri = request.GET.get('redirect_uri', client.redirect_uri) + if not redirect_uri: + return json_response({ + 'status': 400, + 'errors': [u'No redirect_uri supplied!']}, + _disable_cors=True) + + code = OAuthCode() + code.user = request.user + code.client = client + code.save() + + redirect_uri = ''.join([ + redirect_uri, + '?', + urlencode({'code': code.code})]) + + _log.debug('Redirecting to {0}'.format(redirect_uri)) + + return redirect(request, location=redirect_uri) + else: + # Show prompt to allow client to access data + # - on accept: send the user agent back to the redirect_uri with the + # code parameter + # - on deny: send the user agent back to the redirect uri with error + # information + form = AuthorizationForm(request.form) + form.client_id.data = client.id + form.next.data = request.url + return render_to_response( + request, + 'oauth/authorize.html', + {'form': form, + 'client': client}) + + +def access_token(request): + ''' + Access token endpoint provides access tokens to any clients that have the + right grants/credentials + ''' + + client = None + user = None + + if request.GET.get('code'): + # Validate the code arg, then get the client object from the db. + code = OAuthCode.query.filter(OAuthCode.code == + request.GET.get('code')).first() + + if not code: + return json_response({ + 'error': 'invalid_request', + 'error_description': + 'Invalid code.'}) + + client = code.client + user = code.user + + elif request.args.get('refresh_token'): + # Validate a refresh token, then get the client object from the db. + refresh_token = OAuthRefreshToken.query.filter( + OAuthRefreshToken.token == + request.args.get('refresh_token')).first() + + if not refresh_token: + return json_response({ + 'error': 'invalid_request', + 'error_description': + 'Invalid refresh token.'}) + + client = refresh_token.client + user = refresh_token.user + + if client: + client_identifier = request.GET.get('client_id') + + if not client_identifier: + return json_response({ + 'error': 'invalid_request', + 'error_description': + 'Missing client_id in request.'}) + + if not client_identifier == client.identifier: + return json_response({ + 'error': 'invalid_client', + 'error_description': + 'Mismatching client credentials.'}) + + if client.type == u'confidential': + client_secret = request.GET.get('client_secret') + + if not client_secret: + return json_response({ + 'error': 'invalid_request', + 'error_description': + 'Missing client_secret in request.'}) + + if not client_secret == client.secret: + return json_response({ + 'error': 'invalid_client', + 'error_description': + 'Mismatching client credentials.'}) + + + access_token_data = create_token(client, user) + + return json_response(access_token_data, _disable_cors=True) + + return json_response({ + 'error': 'invalid_request', + 'error_description': + 'Missing `code` or `refresh_token` parameter in request.'}) diff --git a/mediagoblin/plugins/piwigo/README.rst b/mediagoblin/plugins/piwigo/README.rst new file mode 100644 index 00000000..0c71ffbc --- /dev/null +++ b/mediagoblin/plugins/piwigo/README.rst @@ -0,0 +1,23 @@ +=================== + piwigo api plugin +=================== + +.. danger:: + This plugin does not work. + It might make your instance unstable or even insecure. + So do not use it, unless you want to help to develop it. + +.. warning:: + You should not depend on this plugin in any way for now. + It might even go away without any notice. + +Okay, so if you still want to test this plugin, +add the following to your mediagoblin_local.ini: + +.. code-block:: ini + + [plugins] + [[mediagoblin.plugins.piwigo]] + +Then try to connect using some piwigo client. +There should be some logging, that might help. diff --git a/mediagoblin/plugins/piwigo/__init__.py b/mediagoblin/plugins/piwigo/__init__.py new file mode 100644 index 00000000..c4da708a --- /dev/null +++ b/mediagoblin/plugins/piwigo/__init__.py @@ -0,0 +1,42 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2013 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 + +from mediagoblin.tools import pluginapi +from mediagoblin.tools.session import SessionManager +from .tools import PWGSession + +_log = logging.getLogger(__name__) + + +def setup_plugin(): + _log.info('Setting up piwigo...') + + routes = [ + ('mediagoblin.plugins.piwigo.wsphp', + '/api/piwigo/ws.php', + 'mediagoblin.plugins.piwigo.views:ws_php'), + ] + + pluginapi.register_routes(routes) + + PWGSession.session_manager = SessionManager("pwg_id", "plugins.piwigo") + + +hooks = { + 'setup': setup_plugin +} diff --git a/mediagoblin/plugins/piwigo/forms.py b/mediagoblin/plugins/piwigo/forms.py new file mode 100644 index 00000000..fb04aa6a --- /dev/null +++ b/mediagoblin/plugins/piwigo/forms.py @@ -0,0 +1,44 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2013 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 wtforms + + +class AddSimpleForm(wtforms.Form): + image = wtforms.FileField() + name = wtforms.TextField( + validators=[wtforms.validators.Length(min=0, max=500)]) + comment = wtforms.TextField() + # tags = wtforms.FieldList(wtforms.TextField()) + category = wtforms.IntegerField() + level = wtforms.IntegerField() + + +_md5_validator = wtforms.validators.Regexp(r"^[0-9a-fA-F]{32}$") + + +class AddForm(wtforms.Form): + original_sum = wtforms.TextField(None, + [_md5_validator, + wtforms.validators.Required()]) + thumbnail_sum = wtforms.TextField(None, + [wtforms.validators.Optional(), + _md5_validator]) + file_sum = wtforms.TextField(None, [_md5_validator]) + name = wtforms.TextField() + date_creation = wtforms.TextField() + categories = wtforms.TextField() diff --git a/mediagoblin/plugins/piwigo/tools.py b/mediagoblin/plugins/piwigo/tools.py new file mode 100644 index 00000000..484ea531 --- /dev/null +++ b/mediagoblin/plugins/piwigo/tools.py @@ -0,0 +1,165 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2013 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 collections import namedtuple +import logging + +import six +import lxml.etree as ET +from werkzeug.exceptions import MethodNotAllowed, BadRequest + +from mediagoblin.tools.request import setup_user_in_request +from mediagoblin.tools.response import Response + + +_log = logging.getLogger(__name__) + + +PwgError = namedtuple("PwgError", ["code", "msg"]) + + +class PwgNamedArray(list): + def __init__(self, l, item_name, as_attrib=()): + self.item_name = item_name + self.as_attrib = as_attrib + list.__init__(self, l) + + def fill_element_xml(self, el): + for it in self: + n = ET.SubElement(el, self.item_name) + if isinstance(it, dict): + _fill_element_dict(n, it, self.as_attrib) + else: + _fill_element(n, it) + + +def _fill_element_dict(el, data, as_attr=()): + for k, v in data.iteritems(): + if k in as_attr: + if not isinstance(v, six.string_types): + v = str(v) + el.set(k, v) + else: + n = ET.SubElement(el, k) + _fill_element(n, v) + + +def _fill_element(el, data): + if isinstance(data, bool): + if data: + el.text = "1" + else: + el.text = "0" + elif isinstance(data, six.string_types): + el.text = data + elif isinstance(data, int): + el.text = str(data) + elif isinstance(data, dict): + _fill_element_dict(el, data) + elif isinstance(data, PwgNamedArray): + data.fill_element_xml(el) + else: + _log.warn("Can't convert to xml: %r", data) + + +def response_xml(result): + r = ET.Element("rsp") + r.set("stat", "ok") + status = None + if isinstance(result, PwgError): + r.set("stat", "fail") + err = ET.SubElement(r, "err") + err.set("code", str(result.code)) + err.set("msg", result.msg) + if result.code >= 100 and result.code < 600: + status = result.code + else: + _fill_element(r, result) + return Response(ET.tostring(r, encoding="utf-8", xml_declaration=True), + mimetype='text/xml', status=status) + + +class CmdTable(object): + _cmd_table = {} + + def __init__(self, cmd_name, only_post=False): + assert not cmd_name in self._cmd_table + self.cmd_name = cmd_name + self.only_post = only_post + + def __call__(self, to_be_wrapped): + assert not self.cmd_name in self._cmd_table + self._cmd_table[self.cmd_name] = (to_be_wrapped, self.only_post) + return to_be_wrapped + + @classmethod + def find_func(cls, request): + if request.method == "GET": + cmd_name = request.args.get("method") + else: + cmd_name = request.form.get("method") + entry = cls._cmd_table.get(cmd_name) + if not entry: + return entry + _log.debug("Found method %s", cmd_name) + func, only_post = entry + if only_post and request.method != "POST": + _log.warn("Method %s only allowed for POST", cmd_name) + raise MethodNotAllowed() + return func + + +def check_form(form): + if not form.validate(): + _log.error("form validation failed for form %r", form) + for f in form: + if len(f.errors): + _log.error("Errors for %s: %r", f.name, f.errors) + raise BadRequest() + dump = [] + for f in form: + dump.append("%s=%r" % (f.name, f.data)) + _log.debug("form: %s", " ".join(dump)) + + +class PWGSession(object): + session_manager = None + + def __init__(self, request): + self.request = request + self.in_pwg_session = False + + def __enter__(self): + # Backup old state + self.old_session = self.request.session + self.old_user = self.request.user + # Load piwigo session into state + self.request.session = self.session_manager.load_session_from_cookie( + self.request) + setup_user_in_request(self.request) + self.in_pwg_session = True + return self + + def __exit__(self, *args): + # Restore state + self.request.session = self.old_session + self.request.user = self.old_user + self.in_pwg_session = False + + def save_to_cookie(self, response): + assert self.in_pwg_session + self.session_manager.save_session_to_cookie(self.request.session, + self.request, response) diff --git a/mediagoblin/plugins/piwigo/views.py b/mediagoblin/plugins/piwigo/views.py new file mode 100644 index 00000000..ca723189 --- /dev/null +++ b/mediagoblin/plugins/piwigo/views.py @@ -0,0 +1,249 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2013 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 re +from os.path import splitext +import shutil + +from werkzeug.exceptions import MethodNotAllowed, BadRequest, NotImplemented +from werkzeug.wrappers import BaseResponse + +from mediagoblin.meddleware.csrf import csrf_exempt +from mediagoblin.auth.tools import check_login_simple +from mediagoblin.media_types import sniff_media +from mediagoblin.submit.lib import check_file_field, prepare_queue_task, \ + run_process_media, new_upload_entry + +from mediagoblin.user_pages.lib import add_media_to_collection +from mediagoblin.db.models import Collection + +from .tools import CmdTable, response_xml, check_form, \ + PWGSession, PwgNamedArray, PwgError +from .forms import AddSimpleForm, AddForm + + +_log = logging.getLogger(__name__) + + +@CmdTable("pwg.session.login", True) +def pwg_login(request): + username = request.form.get("username") + password = request.form.get("password") + user = check_login_simple(username, password) + if not user: + return PwgError(999, 'Invalid username/password') + request.session["user_id"] = user.id + request.session.save() + return True + + +@CmdTable("pwg.session.logout") +def pwg_logout(request): + _log.info("Logout") + request.session.delete() + return True + + +@CmdTable("pwg.getVersion") +def pwg_getversion(request): + return "2.5.0 (MediaGoblin)" + + +@CmdTable("pwg.session.getStatus") +def pwg_session_getStatus(request): + if request.user: + username = request.user.username + else: + username = "guest" + return {'username': username} + + +@CmdTable("pwg.categories.getList") +def pwg_categories_getList(request): + catlist = [{'id': -29711, + 'uppercats': "-29711", + 'name': "All my images"}] + + if request.user: + collections = Collection.query.filter_by( + get_creator=request.user).order_by(Collection.title) + + for c in collections: + catlist.append({'id': c.id, + 'uppercats': str(c.id), + 'name': c.title, + 'comment': c.description + }) + + return { + 'categories': PwgNamedArray( + catlist, + 'category', + ( + 'id', + 'url', + 'nb_images', + 'total_nb_images', + 'nb_categories', + 'date_last', + 'max_date_last', + ) + ) + } + + +@CmdTable("pwg.images.exist") +def pwg_images_exist(request): + return {} + + +@CmdTable("pwg.images.addSimple", True) +def pwg_images_addSimple(request): + form = AddSimpleForm(request.form) + if not form.validate(): + _log.error("addSimple: form failed") + raise BadRequest() + dump = [] + for f in form: + dump.append("%s=%r" % (f.name, f.data)) + _log.info("addSimple: %r %s %r", request.form, " ".join(dump), + request.files) + + if not check_file_field(request, 'image'): + raise BadRequest() + + filename = request.files['image'].filename + + # Sniff the submitted media to determine which + # media plugin should handle processing + media_type, media_manager = sniff_media( + request.files['image']) + + # create entry and save in database + entry = new_upload_entry(request.user) + entry.media_type = unicode(media_type) + entry.title = ( + unicode(form.name.data) + or unicode(splitext(filename)[0])) + + entry.description = unicode(form.comment.data) + + ''' + # Process the user's folksonomy "tags" + entry.tags = convert_to_tag_list_of_dicts( + form.tags.data) + ''' + + # Generate a slug from the title + entry.generate_slug() + + queue_file = prepare_queue_task(request.app, entry, filename) + + with queue_file: + shutil.copyfileobj(request.files['image'].stream, + queue_file, + length=4 * 1048576) + + # Save now so we have this data before kicking off processing + entry.save() + + # Pass off to processing + # + # (... don't change entry after this point to avoid race + # conditions with changes to the document via processing code) + feed_url = request.urlgen( + 'mediagoblin.user_pages.atom_feed', + qualified=True, user=request.user.username) + run_process_media(entry, feed_url) + + collection_id = form.category.data + if collection_id > 0: + collection = Collection.query.get(collection_id) + if collection is not None and collection.creator == request.user.id: + add_media_to_collection(collection, entry, "") + + return {'image_id': entry.id, 'url': entry.url_for_self(request.urlgen, + qualified=True)} + + +md5sum_matcher = re.compile(r"^[0-9a-fA-F]{32}$") + + +def fetch_md5(request, parm_name, optional_parm=False): + val = request.form.get(parm_name) + if (val is None) and (not optional_parm): + _log.error("Parameter %s missing", parm_name) + raise BadRequest("Parameter %s missing" % parm_name) + if not md5sum_matcher.match(val): + _log.error("Parameter %s=%r has no valid md5 value", parm_name, val) + raise BadRequest("Parameter %s is not md5" % parm_name) + return val + + +@CmdTable("pwg.images.addChunk", True) +def pwg_images_addChunk(request): + o_sum = fetch_md5(request, 'original_sum') + typ = request.form.get('type') + pos = request.form.get('position') + data = request.form.get('data') + + # Validate params: + pos = int(pos) + if not typ in ("file", "thumb"): + _log.error("type %r not allowed for now", typ) + return False + + _log.info("addChunk for %r, type %r, position %d, len: %d", + o_sum, typ, pos, len(data)) + if typ == "thumb": + _log.info("addChunk: Ignoring thumb, because we create our own") + return True + + return True + + +@CmdTable("pwg.images.add", True) +def pwg_images_add(request): + _log.info("add: %r", request.form) + form = AddForm(request.form) + check_form(form) + + return {'image_id': 123456, 'url': ''} + + +@csrf_exempt +def ws_php(request): + if request.method not in ("GET", "POST"): + _log.error("Method %r not supported", request.method) + raise MethodNotAllowed() + + func = CmdTable.find_func(request) + if not func: + _log.warn("wsphp: Unhandled %s %r %r", request.method, + request.args, request.form) + raise NotImplemented() + + with PWGSession(request) as session: + result = func(request) + + if isinstance(result, BaseResponse): + return result + + response = response_xml(result) + session.save_to_cookie(response) + + return response diff --git a/mediagoblin/plugins/raven/README.rst b/mediagoblin/plugins/raven/README.rst new file mode 100644 index 00000000..4006060d --- /dev/null +++ b/mediagoblin/plugins/raven/README.rst @@ -0,0 +1,17 @@ +============== + raven plugin +============== + +.. _raven-setup: + +Warning: this plugin is somewhat experimental. + +Set up the raven plugin +======================= + +1. Add the following to your MediaGoblin .ini file in the ``[plugins]`` section:: + + [[mediagoblin.plugins.raven]] + sentry_dsn = <YOUR SENTRY DSN> + # Logging is very high-volume, set to 0 if you want to turn off logging + setup_logging = 1 diff --git a/mediagoblin/plugins/raven/__init__.py b/mediagoblin/plugins/raven/__init__.py new file mode 100644 index 00000000..8cfaed0a --- /dev/null +++ b/mediagoblin/plugins/raven/__init__.py @@ -0,0 +1,92 @@ +# 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 os +import logging + +from mediagoblin.tools import pluginapi + +_log = logging.getLogger(__name__) + + +def get_client(): + from raven import Client + config = pluginapi.get_config('mediagoblin.plugins.raven') + + sentry_dsn = config.get('sentry_dsn') + + client = None + + if sentry_dsn: + _log.info('Setting up raven from plugin config: {0}'.format( + sentry_dsn)) + client = Client(sentry_dsn) + elif os.environ.get('SENTRY_DSN'): + _log.info('Setting up raven from SENTRY_DSN environment variable: {0}'\ + .format(os.environ.get('SENTRY_DSN'))) + client = Client() # Implicitly looks for SENTRY_DSN + + if not client: + _log.error('Could not set up client, missing sentry DSN') + return None + + return client + + +def setup_celery(): + from raven.contrib.celery import register_signal + + client = get_client() + + register_signal(client) + + +def setup_logging(): + config = pluginapi.get_config('mediagoblin.plugins.raven') + + conf_setup_logging = False + if config.get('setup_logging'): + conf_setup_logging = bool(int(config.get('setup_logging'))) + + if not conf_setup_logging: + return + + from raven.handlers.logging import SentryHandler + from raven.conf import setup_logging + + client = get_client() + + _log.info('Setting up raven logging handler') + + setup_logging(SentryHandler(client)) + + +def wrap_wsgi(app): + from raven.middleware import Sentry + + client = get_client() + + _log.info('Attaching raven middleware...') + + return Sentry(app, client) + + +hooks = { + 'setup': setup_logging, + 'wrap_wsgi': wrap_wsgi, + 'celery_logging_setup': setup_logging, + 'celery_setup': setup_celery, + } diff --git a/mediagoblin/plugins/sampleplugin/README.rst b/mediagoblin/plugins/sampleplugin/README.rst new file mode 100644 index 00000000..73897133 --- /dev/null +++ b/mediagoblin/plugins/sampleplugin/README.rst @@ -0,0 +1,8 @@ +============== + sampleplugin +============== + +This is a sample plugin. It does nothing interesting other than show +one way to structure a MediaGoblin plugin. + +The code for this plugin is in ``mediagoblin/plugins/sampleplugin/``. diff --git a/mediagoblin/plugins/sampleplugin/__init__.py b/mediagoblin/plugins/sampleplugin/__init__.py new file mode 100644 index 00000000..2cd077a2 --- /dev/null +++ b/mediagoblin/plugins/sampleplugin/__init__.py @@ -0,0 +1,42 @@ +# 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 + +from mediagoblin.tools.pluginapi import get_config + + +_log = logging.getLogger(__name__) + + +_setup_plugin_called = 0 + +def setup_plugin(): + global _setup_plugin_called + + _log.info('Sample plugin set up!') + config = get_config('mediagoblin.plugins.sampleplugin') + if config: + _log.info('%r' % config) + else: + _log.info('There is no configuration set.') + _setup_plugin_called += 1 + + +hooks = { + 'setup': setup_plugin + } diff --git a/mediagoblin/plugins/trim_whitespace/README.rst b/mediagoblin/plugins/trim_whitespace/README.rst new file mode 100644 index 00000000..b55ce35e --- /dev/null +++ b/mediagoblin/plugins/trim_whitespace/README.rst @@ -0,0 +1,25 @@ +======================= + Trim whitespace plugin +======================= + +Mediagoblin templates are written with 80 char limit for better +readability. However that means that the html output is very verbose +containing LOTS of whitespace. This plugin inserts a Middleware that +filters out whitespace from the returned HTML in the Response() objects. + +Simply enable this plugin by putting it somewhere where python can reach it and put it's path into the [plugins] section of your mediagoblin.ini or mediagoblin_local.ini like for example this: + + [plugins] + [[mediagoblin.plugins.trim_whitespace]] + +There is no further configuration required. If this plugin is enabled, +all text/html documents should not have lots of whitespace in between +elements, although it does a very naive filtering right now (just keep +the first whitespace and delete all subsequent ones). + +Nonetheless, it is a useful plugin that might serve as inspiration for +other plugin writers. + +It was originally conceived by Sebastian Spaeth. It is licensed under +the GNU AGPL v3 (or any later version) license. + diff --git a/mediagoblin/plugins/trim_whitespace/__init__.py b/mediagoblin/plugins/trim_whitespace/__init__.py new file mode 100644 index 00000000..3da1e8b4 --- /dev/null +++ b/mediagoblin/plugins/trim_whitespace/__init__.py @@ -0,0 +1,73 @@ +# 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 __future__ import unicode_literals +import logging +import re + +from mediagoblin import meddleware + +_log = logging.getLogger(__name__) + +class TrimWhiteSpaceMeddleware(meddleware.BaseMeddleware): + _setup_plugin_called = 0 + RE_MULTI_WHITESPACE = re.compile(b'(\s)\s+', re.M) + + def process_response(self, request, response): + """Perform very naive html tidying by removing multiple whitespaces""" + # werkzeug.BaseResponse has no content_type attr, this comes via + # werkzeug.wrappers.CommonRequestDescriptorsMixin (part of + # wrappers.Response) + if getattr(response ,'content_type', None) != 'text/html': + return + + # This is a tad more complex than needed to be able to handle + # response.data and response.body, depending on whether we have + # a werkzeug Resonse or a webob one. Let's kill webob soon! + if hasattr(response, 'body') and not hasattr(response, 'data'): + # Old-style webob Response object. + # TODO: Remove this once we transition away from webob + resp_attr = 'body' + else: + resp_attr = 'data' + # Don't flatten iterator to list when we fudge the response body + # (see werkzeug.Response documentation) + response.implicit_sequence_conversion = False + + # Set the tidied text. Very naive tidying for now, just strip all + # subsequent whitespaces (this preserves most newlines) + setattr(response, resp_attr, re.sub( + TrimWhiteSpaceMeddleware.RE_MULTI_WHITESPACE, br'\1', + getattr(response, resp_attr))) + + @classmethod + def setup_plugin(cls): + """Set up this meddleware as a plugin during 'setup' hook""" + global _log + if cls._setup_plugin_called: + _log.info('Trim whitespace plugin was already set up.') + return + + _log.debug('Trim whitespace plugin set up.') + cls._setup_plugin_called += 1 + + # Append ourselves to the list of enabled Meddlewares + meddleware.ENABLED_MEDDLEWARE.append( + '{0}:{1}'.format(cls.__module__, cls.__name__)) + + +hooks = { + 'setup': TrimWhiteSpaceMeddleware.setup_plugin + } |