aboutsummaryrefslogtreecommitdiffstats
path: root/mediagoblin/plugins
diff options
context:
space:
mode:
Diffstat (limited to 'mediagoblin/plugins')
-rw-r--r--mediagoblin/plugins/README6
-rw-r--r--mediagoblin/plugins/__init__.py16
-rw-r--r--mediagoblin/plugins/api/__init__.py47
-rw-r--r--mediagoblin/plugins/api/tools.py164
-rw-r--r--mediagoblin/plugins/api/views.py122
-rw-r--r--mediagoblin/plugins/flatpagesfile/README.rst163
-rw-r--r--mediagoblin/plugins/flatpagesfile/__init__.py78
-rw-r--r--mediagoblin/plugins/flatpagesfile/templates/flatpagesfile/base.html18
-rw-r--r--mediagoblin/plugins/geolocation/__init__.py35
-rw-r--r--mediagoblin/plugins/geolocation/templates/mediagoblin/plugins/geolocation/map.html59
-rw-r--r--mediagoblin/plugins/geolocation/templates/mediagoblin/plugins/geolocation/map_js_head.html25
-rw-r--r--mediagoblin/plugins/httpapiauth/__init__.py58
-rw-r--r--mediagoblin/plugins/oauth/README.rst148
-rw-r--r--mediagoblin/plugins/oauth/__init__.py109
-rw-r--r--mediagoblin/plugins/oauth/forms.py69
-rw-r--r--mediagoblin/plugins/oauth/migrations.py158
-rw-r--r--mediagoblin/plugins/oauth/models.py192
-rw-r--r--mediagoblin/plugins/oauth/templates/oauth/authorize.html31
-rw-r--r--mediagoblin/plugins/oauth/templates/oauth/client/connections.html34
-rw-r--r--mediagoblin/plugins/oauth/templates/oauth/client/list.html45
-rw-r--r--mediagoblin/plugins/oauth/templates/oauth/client/register.html34
-rw-r--r--mediagoblin/plugins/oauth/tools.py114
-rw-r--r--mediagoblin/plugins/oauth/views.py254
-rw-r--r--mediagoblin/plugins/piwigo/README.rst23
-rw-r--r--mediagoblin/plugins/piwigo/__init__.py42
-rw-r--r--mediagoblin/plugins/piwigo/forms.py44
-rw-r--r--mediagoblin/plugins/piwigo/tools.py165
-rw-r--r--mediagoblin/plugins/piwigo/views.py249
-rw-r--r--mediagoblin/plugins/raven/README.rst17
-rw-r--r--mediagoblin/plugins/raven/__init__.py92
-rw-r--r--mediagoblin/plugins/sampleplugin/README.rst8
-rw-r--r--mediagoblin/plugins/sampleplugin/__init__.py42
-rw-r--r--mediagoblin/plugins/trim_whitespace/README.rst25
-rw-r--r--mediagoblin/plugins/trim_whitespace/__init__.py73
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 &copy;<a
+ href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>
+ contributors
+ </li><li>Imaging &copy;<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
+ }