diff options
Diffstat (limited to 'mediagoblin')
72 files changed, 2385 insertions, 174 deletions
diff --git a/mediagoblin/app.py b/mediagoblin/app.py index 57e09e49..e9177eff 100644 --- a/mediagoblin/app.py +++ b/mediagoblin/app.py @@ -39,7 +39,6 @@ from mediagoblin.init import (get_jinja_loader, get_staticdirector, from mediagoblin.tools.pluginapi import PluginManager, hook_transform from mediagoblin.tools.crypto import setup_crypto from mediagoblin.auth.tools import check_auth_enabled, no_auth_logout -from mediagoblin import notifications _log = logging.getLogger(__name__) @@ -199,8 +198,6 @@ class MediaGoblinApp(object): # Log user out if authentication_disabled no_auth_logout(request) - request.notifications = notifications - mg_request.setup_user_in_request(request) request.controller_name = None diff --git a/mediagoblin/auth/views.py b/mediagoblin/auth/views.py index 90d6f5cc..7d95b81a 100644 --- a/mediagoblin/auth/views.py +++ b/mediagoblin/auth/views.py @@ -41,8 +41,11 @@ def register(request): """ if 'pass_auth' not in request.template_env.globals: redirect_name = hook_handle('auth_no_pass_redirect') - return redirect(request, 'mediagoblin.plugins.{0}.register'.format( - redirect_name)) + if redirect_name: + return redirect(request, 'mediagoblin.plugins.{0}.register'.format( + redirect_name)) + else: + return redirect(request, 'index') register_form = hook_handle("auth_get_registration_form", request) @@ -73,8 +76,11 @@ def login(request): """ if 'pass_auth' not in request.template_env.globals: redirect_name = hook_handle('auth_no_pass_redirect') - return redirect(request, 'mediagoblin.plugins.{0}.login'.format( - redirect_name)) + if redirect_name: + return redirect(request, 'mediagoblin.plugins.{0}.login'.format( + redirect_name)) + else: + return redirect(request, 'index') login_form = hook_handle("auth_get_login_form", request) diff --git a/mediagoblin/config_spec.ini b/mediagoblin/config_spec.ini index d2ada163..8f03509d 100644 --- a/mediagoblin/config_spec.ini +++ b/mediagoblin/config_spec.ini @@ -75,6 +75,12 @@ theme = string() plugin_web_path = string(default="/plugin_static/") plugin_linked_assets_dir = string(default="%(here)s/user_dev/plugin_static/") +[jinja2] +# Jinja2 supports more directives than the minimum required by mediagoblin. +# This setting allows users creating custom templates to specify a list of +# additional extensions they want to use. example value: +# extensions = jinja2.ext.loopcontrols , jinja2.ext.with_ +extensions = string_list(default=list()) [storage:publicstore] storage_class = string(default="mediagoblin.storage.filestorage:BasicFileStorage") @@ -116,7 +122,7 @@ vp8_quality = integer(default=8) vorbis_quality = float(default=0.3) # Autoplay the video when page is loaded? -auto_play = boolean(default=True) +auto_play = boolean(default=False) [[skip_transcode]] mime_types = string_list(default=list("video/webm")) @@ -146,7 +152,7 @@ CELERY_RESULT_DBURI = string(default="sqlite:///%(here)s/celery.db") # default kombu stuff BROKER_TRANSPORT = string(default="sqlalchemy") -BROKER_HOST = string(default="sqlite:///%(here)s/kombu.db") +BROKER_URL = string(default="sqlite:///%(here)s/kombu.db") # known booleans CELERY_RESULT_PERSISTENT = boolean() diff --git a/mediagoblin/db/migrations.py b/mediagoblin/db/migrations.py index 9dff22ee..0eedc5d4 100644 --- a/mediagoblin/db/migrations.py +++ b/mediagoblin/db/migrations.py @@ -25,6 +25,8 @@ from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.sql import and_ from migrate.changeset.constraint import UniqueConstraint + +from mediagoblin.db.extratypes import JSONEncoded from mediagoblin.db.migration_tools import RegisterMigration, inspect_table from mediagoblin.db.models import (MediaEntry, Collection, User, MediaComment, Privilege, ReportBase) @@ -452,3 +454,82 @@ def create_moderation_tables(db): Privilege_v0.__table__.create(db.bind) PrivilegeUserAssociation_v0.__table__.create(db.bind) db.commit() + + +# oauth1 migrations +class Client_v0(declarative_base()): + """ + Model representing a client - Used for API Auth + """ + __tablename__ = "core__clients" + + id = Column(Unicode, nullable=True, primary_key=True) + secret = Column(Unicode, nullable=False) + expirey = Column(DateTime, nullable=True) + application_type = Column(Unicode, nullable=False) + created = Column(DateTime, nullable=False, default=datetime.datetime.now) + updated = Column(DateTime, nullable=False, default=datetime.datetime.now) + + # optional stuff + redirect_uri = Column(JSONEncoded, nullable=True) + logo_url = Column(Unicode, nullable=True) + application_name = Column(Unicode, nullable=True) + contacts = Column(JSONEncoded, nullable=True) + + def __repr__(self): + if self.application_name: + return "<Client {0} - {1}>".format(self.application_name, self.id) + else: + return "<Client {0}>".format(self.id) + +class RequestToken_v0(declarative_base()): + """ + Model for representing the request tokens + """ + __tablename__ = "core__request_tokens" + + token = Column(Unicode, primary_key=True) + secret = Column(Unicode, nullable=False) + client = Column(Unicode, ForeignKey(Client_v0.id)) + user = Column(Integer, ForeignKey(User.id), nullable=True) + used = Column(Boolean, default=False) + authenticated = Column(Boolean, default=False) + verifier = Column(Unicode, nullable=True) + callback = Column(Unicode, nullable=False, default=u"oob") + created = Column(DateTime, nullable=False, default=datetime.datetime.now) + updated = Column(DateTime, nullable=False, default=datetime.datetime.now) + +class AccessToken_v0(declarative_base()): + """ + Model for representing the access tokens + """ + __tablename__ = "core__access_tokens" + + token = Column(Unicode, nullable=False, primary_key=True) + secret = Column(Unicode, nullable=False) + user = Column(Integer, ForeignKey(User.id)) + request_token = Column(Unicode, ForeignKey(RequestToken_v0.token)) + created = Column(DateTime, nullable=False, default=datetime.datetime.now) + updated = Column(DateTime, nullable=False, default=datetime.datetime.now) + + +class NonceTimestamp_v0(declarative_base()): + """ + A place the timestamp and nonce can be stored - this is for OAuth1 + """ + __tablename__ = "core__nonce_timestamps" + + nonce = Column(Unicode, nullable=False, primary_key=True) + timestamp = Column(DateTime, nullable=False, primary_key=True) + + +@RegisterMigration(14, MIGRATIONS) +def create_oauth1_tables(db): + """ Creates the OAuth1 tables """ + + Client_v0.__table__.create(db.bind) + RequestToken_v0.__table__.create(db.bind) + AccessToken_v0.__table__.create(db.bind) + NonceTimestamp_v0.__table__.create(db.bind) + + db.commit() diff --git a/mediagoblin/db/models.py b/mediagoblin/db/models.py index 69b59c99..25b4fa8f 100644 --- a/mediagoblin/db/models.py +++ b/mediagoblin/db/models.py @@ -30,6 +30,7 @@ from sqlalchemy.sql.expression import desc from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.util import memoized_property + from mediagoblin.db.extratypes import PathTupleWithSlashes, JSONEncoded from mediagoblin.db.base import Base, DictReadAttrProxy from mediagoblin.db.mixin import UserMixin, MediaEntryMixin, \ @@ -61,20 +62,17 @@ class User(Base, UserMixin): # the RFC) and because it would be a mess to implement at this # point. email = Column(Unicode, nullable=False) - created = Column(DateTime, nullable=False, default=datetime.datetime.now) - pw_hash = Column(Unicode, nullable=False) + pw_hash = Column(Unicode) email_verified = Column(Boolean, default=False) + created = Column(DateTime, nullable=False, default=datetime.datetime.now) status = Column(Unicode, default=u"needs_email_verification", nullable=False) # Intented to be nullable=False, but migrations would not work for it # set to nullable=True implicitly. wants_comment_notification = Column(Boolean, default=True) license_preference = Column(Unicode) - verification_key = Column(Unicode) is_admin = Column(Boolean, default=False, nullable=False) url = Column(Unicode) bio = Column(UnicodeText) # ?? - fp_verification_key = Column(Unicode) - fp_token_expire = Column(DateTime) ## TODO # plugin data would be in a separate model @@ -116,6 +114,71 @@ class User(Base, UserMixin): self.has_privilege(*priv_names[1:]) return False +class Client(Base): + """ + Model representing a client - Used for API Auth + """ + __tablename__ = "core__clients" + + id = Column(Unicode, nullable=True, primary_key=True) + secret = Column(Unicode, nullable=False) + expirey = Column(DateTime, nullable=True) + application_type = Column(Unicode, nullable=False) + created = Column(DateTime, nullable=False, default=datetime.datetime.now) + updated = Column(DateTime, nullable=False, default=datetime.datetime.now) + + # optional stuff + redirect_uri = Column(JSONEncoded, nullable=True) + logo_url = Column(Unicode, nullable=True) + application_name = Column(Unicode, nullable=True) + contacts = Column(JSONEncoded, nullable=True) + + def __repr__(self): + if self.application_name: + return "<Client {0} - {1}>".format(self.application_name, self.id) + else: + return "<Client {0}>".format(self.id) + +class RequestToken(Base): + """ + Model for representing the request tokens + """ + __tablename__ = "core__request_tokens" + + token = Column(Unicode, primary_key=True) + secret = Column(Unicode, nullable=False) + client = Column(Unicode, ForeignKey(Client.id)) + user = Column(Integer, ForeignKey(User.id), nullable=True) + used = Column(Boolean, default=False) + authenticated = Column(Boolean, default=False) + verifier = Column(Unicode, nullable=True) + callback = Column(Unicode, nullable=False, default=u"oob") + created = Column(DateTime, nullable=False, default=datetime.datetime.now) + updated = Column(DateTime, nullable=False, default=datetime.datetime.now) + +class AccessToken(Base): + """ + Model for representing the access tokens + """ + __tablename__ = "core__access_tokens" + + token = Column(Unicode, nullable=False, primary_key=True) + secret = Column(Unicode, nullable=False) + user = Column(Integer, ForeignKey(User.id)) + request_token = Column(Unicode, ForeignKey(RequestToken.token)) + created = Column(DateTime, nullable=False, default=datetime.datetime.now) + updated = Column(DateTime, nullable=False, default=datetime.datetime.now) + + +class NonceTimestamp(Base): + """ + A place the timestamp and nonce can be stored - this is for OAuth1 + """ + __tablename__ = "core__nonce_timestamps" + + nonce = Column(Unicode, nullable=False, primary_key=True) + timestamp = Column(DateTime, nullable=False, primary_key=True) + class MediaEntry(Base, MediaEntryMixin): """ @@ -498,6 +561,7 @@ class ProcessingMetaData(Base): """A dict like view on this object""" return DictReadAttrProxy(self) + class CommentSubscription(Base): __tablename__ = 'core__comment_subscriptions' id = Column(Integer, primary_key=True) @@ -768,16 +832,15 @@ with_polymorphic( Notification, [ProcessingNotification, CommentNotification]) - MODELS = [ - User, MediaEntry, Tag, MediaTag, MediaComment, Collection, CollectionItem, - MediaFile, FileKeynames, MediaAttachmentFile, ProcessingMetaData, ReportBase, - CommentReport, MediaReport, UserBan, Privilege, PrivilegeUserAssociation, - ArchivedReport, Notification, CommentNotification, - ProcessingNotification, CommentSubscription] + User, MediaEntry, Tag, MediaTag, MediaComment, Collection, CollectionItem, + MediaFile, FileKeynames, MediaAttachmentFile, ProcessingMetaData, + Notification, CommentNotification, ProcessingNotification, + CommentSubscription, ReportBase, CommentReport, MediaReport, UserBan, + Privilege, PrivilegeUserAssociation, ArchivedReport, ArchivedReport] """ - Foundations are the default rows that are created immediately after the tables + Foundations are the default rows that are created immediately after the tables are initialized. Each entry to this dictionary should be in the format of: ModelConstructorObject:List of Dictionaries (Each Dictionary represents a row on the Table to be created, containing each diff --git a/mediagoblin/decorators.py b/mediagoblin/decorators.py index d3a9647e..a3479164 100644 --- a/mediagoblin/decorators.py +++ b/mediagoblin/decorators.py @@ -18,15 +18,18 @@ from functools import wraps from urlparse import urljoin from werkzeug.exceptions import Forbidden, NotFound -from werkzeug.urls import url_quote +from oauthlib.oauth1 import ResourceEndpoint from mediagoblin import mg_globals as mgg from mediagoblin import messages from mediagoblin.db.models import (MediaEntry, User, MediaComment, UserBan, Privilege) -from mediagoblin.tools.response import redirect, render_404, render_user_banned +from mediagoblin.tools.response import (redirect, render_404, + render_user_banned, json_response) from mediagoblin.tools.translate import pass_to_ugettext as _ +from mediagoblin.oauth.tools.request import decode_authorization_header +from mediagoblin.oauth.oauth import GMGRequestValidator def require_active_login(controller): """ @@ -245,6 +248,17 @@ def get_media_entry_by_id(controller): return wrapper +def get_workbench(func): + """Decorator, passing in a workbench as kwarg which is cleaned up afterwards""" + + @wraps(func) + def new_func(*args, **kwargs): + with mgg.workbench_manager.create() as workbench: + return func(*args, workbench=workbench, **kwargs) + + return new_func + + def allow_registration(controller): """ Decorator for if registration is enabled""" @wraps(controller) @@ -287,18 +301,11 @@ def auth_enabled(controller): messages.WARNING, _('Sorry, authentication is disabled on this instance.')) return redirect(request, 'index') + return controller(request, *args, **kwargs) return wrapper -def get_workbench(func): - """Decorator, passing in a workbench as kwarg which is cleaned up afterwards""" - @wraps(func) - def new_func(*args, **kwargs): - with mgg.workbench_manager.create() as workbench: - return func(*args, workbench=workbench, **kwargs) - return new_func - def require_admin_or_moderator_login(controller): """ Require an login from an administrator or a moderator. @@ -322,6 +329,7 @@ def require_admin_or_moderator_login(controller): return new_controller_func + def user_not_banned(controller): """ Requires that the user has not been banned. Otherwise redirects to the page @@ -337,3 +345,33 @@ def user_not_banned(controller): return wrapper + + +def oauth_required(controller): + """ Used to wrap API endpoints where oauth is required """ + @wraps(controller) + def wrapper(request, *args, **kwargs): + data = request.headers + authorization = decode_authorization_header(data) + + if authorization == dict(): + error = "Missing required parameter." + return json_response({"error": error}, status=400) + + + request_validator = GMGRequestValidator() + resource_endpoint = ResourceEndpoint(request_validator) + valid, request = resource_endpoint.validate_protected_resource_request( + uri=request.url, + http_method=request.method, + body=request.get_data(), + headers=dict(request.headers), + ) + + if not valid: + error = "Invalid oauth prarameter." + return json_response({"error": error}, status=400) + + return controller(request, *args, **kwargs) + + return wrapper diff --git a/mediagoblin/gmg_commands/dbupdate.py b/mediagoblin/gmg_commands/dbupdate.py index bad3e352..961752f6 100644 --- a/mediagoblin/gmg_commands/dbupdate.py +++ b/mediagoblin/gmg_commands/dbupdate.py @@ -96,7 +96,7 @@ forgotten to add it? ({1})'.format(plugin, exc)) plugin, exc)) - foundations = [] + foundations = {} except AttributeError as exc: _log.debug('Could not find FOUNDATIONS in {0}.models, have you \ forgotten to add it? ({1})'.format(plugin, exc)) @@ -126,7 +126,7 @@ def run_dbupdate(app_config, global_config): def run_all_migrations(db, app_config, global_config): """ - Initializes or migrates a database that already has a + Initializes or migrates a database that already has a connection setup and also initializes or migrates all extensions based on the config files. diff --git a/mediagoblin/media_types/audio/__init__.py b/mediagoblin/media_types/audio/__init__.py index 90f842ba..c7ed8d2d 100644 --- a/mediagoblin/media_types/audio/__init__.py +++ b/mediagoblin/media_types/audio/__init__.py @@ -19,6 +19,9 @@ from mediagoblin.media_types.audio.processing import process_audio, \ sniff_handler from mediagoblin.tools import pluginapi +# Why isn't .ogg in this list? It's still detected, but via sniffing, +# .ogg files could be either video or audio... sniffing determines which. + ACCEPTED_EXTENSIONS = ["mp3", "flac", "wav", "m4a"] MEDIA_TYPE = 'mediagoblin.media_types.audio' diff --git a/mediagoblin/media_types/video/processing.py b/mediagoblin/media_types/video/processing.py index 5386ba60..857c1647 100644 --- a/mediagoblin/media_types/video/processing.py +++ b/mediagoblin/media_types/video/processing.py @@ -14,7 +14,7 @@ # 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 tempfile import NamedTemporaryFile +import os.path import logging import datetime @@ -73,79 +73,77 @@ def process_video(proc_state): queued_filename = proc_state.get_queued_filename() name_builder = FilenameBuilder(queued_filename) - medium_filepath = create_pub_filepath( - entry, name_builder.fill('{basename}-640p.webm')) + medium_basename = name_builder.fill('{basename}-640p.webm') + medium_filepath = create_pub_filepath(entry, medium_basename) - thumbnail_filepath = create_pub_filepath( - entry, name_builder.fill('{basename}.thumbnail.jpg')) + thumbnail_basename = name_builder.fill('{basename}.thumbnail.jpg') + thumbnail_filepath = create_pub_filepath(entry, thumbnail_basename) # Create a temporary file for the video destination (cleaned up with workbench) - tmp_dst = NamedTemporaryFile(dir=workbench.dir, delete=False) - with tmp_dst: - # Transcode queued file to a VP8/vorbis file that fits in a 640x640 square - progress_callback = ProgressCallback(entry) + tmp_dst = os.path.join(workbench.dir, medium_basename) + # Transcode queued file to a VP8/vorbis file that fits in a 640x640 square + progress_callback = ProgressCallback(entry) - dimensions = ( - mgg.global_config['media:medium']['max_width'], - mgg.global_config['media:medium']['max_height']) + dimensions = ( + mgg.global_config['media:medium']['max_width'], + mgg.global_config['media:medium']['max_height']) - # Extract metadata and keep a record of it - metadata = transcoders.VideoTranscoder().discover(queued_filename) - store_metadata(entry, metadata) + # Extract metadata and keep a record of it + metadata = transcoders.VideoTranscoder().discover(queued_filename) + store_metadata(entry, metadata) - # Figure out whether or not we need to transcode this video or - # if we can skip it - if skip_transcode(metadata): - _log.debug('Skipping transcoding') + # Figure out whether or not we need to transcode this video or + # if we can skip it + if skip_transcode(metadata): + _log.debug('Skipping transcoding') - dst_dimensions = metadata['videowidth'], metadata['videoheight'] + dst_dimensions = metadata['videowidth'], metadata['videoheight'] # Push original file to public storage - _log.debug('Saving original...') - proc_state.copy_original(queued_filepath[-1]) + _log.debug('Saving original...') + proc_state.copy_original(queued_filepath[-1]) - did_transcode = False - else: - transcoder = transcoders.VideoTranscoder() + did_transcode = False + else: + transcoder = transcoders.VideoTranscoder() - transcoder.transcode(queued_filename, tmp_dst.name, - vp8_quality=video_config['vp8_quality'], - vp8_threads=video_config['vp8_threads'], - vorbis_quality=video_config['vorbis_quality'], - progress_callback=progress_callback, - dimensions=dimensions) + transcoder.transcode(queued_filename, tmp_dst, + vp8_quality=video_config['vp8_quality'], + vp8_threads=video_config['vp8_threads'], + vorbis_quality=video_config['vorbis_quality'], + progress_callback=progress_callback, + dimensions=dimensions) - dst_dimensions = transcoder.dst_data.videowidth,\ - transcoder.dst_data.videoheight + dst_dimensions = transcoder.dst_data.videowidth,\ + transcoder.dst_data.videoheight - # Push transcoded video to public storage - _log.debug('Saving medium...') - mgg.public_store.copy_local_to_storage(tmp_dst.name, medium_filepath) - _log.debug('Saved medium') + # Push transcoded video to public storage + _log.debug('Saving medium...') + mgg.public_store.copy_local_to_storage(tmp_dst, medium_filepath) + _log.debug('Saved medium') - entry.media_files['webm_640'] = medium_filepath + entry.media_files['webm_640'] = medium_filepath - did_transcode = True + did_transcode = True - # Save the width and height of the transcoded video - entry.media_data_init( - width=dst_dimensions[0], - height=dst_dimensions[1]) + # Save the width and height of the transcoded video + entry.media_data_init( + width=dst_dimensions[0], + height=dst_dimensions[1]) # Temporary file for the video thumbnail (cleaned up with workbench) - tmp_thumb = NamedTemporaryFile(dir=workbench.dir, suffix='.jpg', delete=False) - - with tmp_thumb: - # Create a thumbnail.jpg that fits in a 180x180 square - transcoders.VideoThumbnailerMarkII( - queued_filename, - tmp_thumb.name, - 180) - - # Push the thumbnail to public storage - _log.debug('Saving thumbnail...') - mgg.public_store.copy_local_to_storage(tmp_thumb.name, thumbnail_filepath) - entry.media_files['thumb'] = thumbnail_filepath + tmp_thumb = os.path.join(workbench.dir, thumbnail_basename) + + # Create a thumbnail.jpg that fits in a 180x180 square + transcoders.VideoThumbnailerMarkII( + queued_filename, + tmp_thumb, + 180) + + # Push the thumbnail to public storage + _log.debug('Saving thumbnail...') + mgg.public_store.copy_local_to_storage(tmp_thumb, thumbnail_filepath) + entry.media_files['thumb'] = thumbnail_filepath # save the original... but only if we did a transcoding # (if we skipped transcoding and just kept the original anyway as the main diff --git a/mediagoblin/notifications/__init__.py b/mediagoblin/notifications/__init__.py index 4b7fbb8c..ed9f8d78 100644 --- a/mediagoblin/notifications/__init__.py +++ b/mediagoblin/notifications/__init__.py @@ -18,7 +18,6 @@ import logging from mediagoblin.db.models import Notification, \ CommentNotification, CommentSubscription -from mediagoblin.notifications.task import email_notification_task from mediagoblin.notifications.tools import generate_comment_message _log = logging.getLogger(__name__) @@ -50,6 +49,7 @@ def trigger_notification(comment, media_entry, request): media_entry, request) + from mediagoblin.notifications.task import email_notification_task email_notification_task.apply_async([cn.id, message]) diff --git a/mediagoblin/oauth/__init__.py b/mediagoblin/oauth/__init__.py new file mode 100644 index 00000000..719b56e7 --- /dev/null +++ b/mediagoblin/oauth/__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/oauth/exceptions.py b/mediagoblin/oauth/exceptions.py new file mode 100644 index 00000000..5eccba34 --- /dev/null +++ b/mediagoblin/oauth/exceptions.py @@ -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/>. + +class ValidationException(Exception): + pass diff --git a/mediagoblin/oauth/forms.py b/mediagoblin/oauth/forms.py new file mode 100644 index 00000000..94c7cb52 --- /dev/null +++ b/mediagoblin/oauth/forms.py @@ -0,0 +1,7 @@ +import wtforms + +class AuthorizeForm(wtforms.Form): + """ Form used to authorize the request token """ + + oauth_token = wtforms.HiddenField("oauth_token") + oauth_verifier = wtforms.HiddenField("oauth_verifier") diff --git a/mediagoblin/oauth/oauth.py b/mediagoblin/oauth/oauth.py new file mode 100644 index 00000000..8229c47d --- /dev/null +++ b/mediagoblin/oauth/oauth.py @@ -0,0 +1,132 @@ +# 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 oauthlib.common import Request +from oauthlib.oauth1 import RequestValidator + +from mediagoblin.db.models import NonceTimestamp, Client, RequestToken, AccessToken + + + +class GMGRequestValidator(RequestValidator): + + enforce_ssl = False + + def __init__(self, data=None, *args, **kwargs): + self.POST = data + super(GMGRequestValidator, self).__init__(*args, **kwargs) + + def save_request_token(self, token, request): + """ Saves request token in db """ + client_id = self.POST[u"oauth_consumer_key"] + + request_token = RequestToken( + token=token["oauth_token"], + secret=token["oauth_token_secret"], + ) + request_token.client = client_id + if u"oauth_callback" in self.POST: + request_token.callback = self.POST[u"oauth_callback"] + request_token.save() + + def save_verifier(self, token, verifier, request): + """ Saves the oauth request verifier """ + request_token = RequestToken.query.filter_by(token=token).first() + request_token.verifier = verifier["oauth_verifier"] + request_token.save() + + def save_access_token(self, token, request): + """ Saves access token in db """ + access_token = AccessToken( + token=token["oauth_token"], + secret=token["oauth_token_secret"], + ) + access_token.request_token = request.oauth_token + request_token = RequestToken.query.filter_by(token=request.oauth_token).first() + access_token.user = request_token.user + access_token.save() + + def get_realms(*args, **kwargs): + """ Currently a stub - called when making AccessTokens """ + return list() + + def validate_timestamp_and_nonce(self, client_key, timestamp, + nonce, request, request_token=None, + access_token=None): + nc = NonceTimestamp.query.filter_by(timestamp=timestamp, nonce=nonce) + nc = nc.first() + if nc is None: + return True + + return False + + def validate_client_key(self, client_key, request): + """ Verifies client exists with id of client_key """ + client = Client.query.filter_by(id=client_key).first() + if client is None: + return False + + return True + + def validate_access_token(self, client_key, token, request): + """ Verifies token exists for client with id of client_key """ + client = Client.query.filter_by(id=client_key).first() + token = AccessToken.query.filter_by(token=token) + token = token.first() + + if token is None: + return False + + request_token = RequestToken.query.filter_by(token=token.request_token) + request_token = request_token.first() + + if client.id != request_token.client: + return False + + return True + + def validate_realms(self, *args, **kwargs): + """ Would validate reals however not using these yet. """ + return True # implement when realms are implemented + + + def get_client_secret(self, client_key, request): + """ Retrives a client secret with from a client with an id of client_key """ + client = Client.query.filter_by(id=client_key).first() + return client.secret + + def get_access_token_secret(self, client_key, token, request): + access_token = AccessToken.query.filter_by(token=token).first() + return access_token.secret + +class GMGRequest(Request): + """ + Fills in data to produce a oauth.common.Request object from a + werkzeug Request object + """ + + def __init__(self, request, *args, **kwargs): + """ + :param request: werkzeug request object + + any extra params are passed to oauthlib.common.Request object + """ + kwargs["uri"] = kwargs.get("uri", request.url) + kwargs["http_method"] = kwargs.get("http_method", request.method) + kwargs["body"] = kwargs.get("body", request.get_data()) + kwargs["headers"] = kwargs.get("headers", dict(request.headers)) + + super(GMGRequest, self).__init__(*args, **kwargs) diff --git a/mediagoblin/oauth/routing.py b/mediagoblin/oauth/routing.py new file mode 100644 index 00000000..e45077bb --- /dev/null +++ b/mediagoblin/oauth/routing.py @@ -0,0 +1,43 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from mediagoblin.tools.routing import add_route + +# client registration & oauth +add_route( + "mediagoblin.oauth", + "/api/client/register", + "mediagoblin.oauth.views:client_register" + ) + +add_route( + "mediagoblin.oauth", + "/oauth/request_token", + "mediagoblin.oauth.views:request_token" + ) + +add_route( + "mediagoblin.oauth", + "/oauth/authorize", + "mediagoblin.oauth.views:authorize", + ) + +add_route( + "mediagoblin.oauth", + "/oauth/access_token", + "mediagoblin.oauth.views:access_token" + ) + diff --git a/mediagoblin/oauth/tools/__init__.py b/mediagoblin/oauth/tools/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/mediagoblin/oauth/tools/__init__.py diff --git a/mediagoblin/oauth/tools/forms.py b/mediagoblin/oauth/tools/forms.py new file mode 100644 index 00000000..e3eb3298 --- /dev/null +++ b/mediagoblin/oauth/tools/forms.py @@ -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/>. + +class WTFormData(dict): + """ + Provides a WTForm usable dictionary + """ + def getlist(self, key): + v = self[key] + if not isinstance(v, (list, tuple)): + v = [v] + return v diff --git a/mediagoblin/oauth/tools/request.py b/mediagoblin/oauth/tools/request.py new file mode 100644 index 00000000..5ce2da77 --- /dev/null +++ b/mediagoblin/oauth/tools/request.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/>. + +def decode_authorization_header(header): + """ Decodes a HTTP Authorization Header to python dictionary """ + authorization = header.get("Authorization", "").lstrip(" ").lstrip("OAuth") + tokens = {} + + for param in authorization.split(","): + try: + key, value = param.split("=") + except ValueError: + continue + + key = key.lstrip(" ") + value = value.lstrip(" ").lstrip('"') + value = value.rstrip(" ").rstrip('"') + + tokens[key] = value + + return tokens + diff --git a/mediagoblin/oauth/views.py b/mediagoblin/oauth/views.py new file mode 100644 index 00000000..116eb023 --- /dev/null +++ b/mediagoblin/oauth/views.py @@ -0,0 +1,339 @@ +# 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 datetime + +from oauthlib.oauth1 import (RequestTokenEndpoint, AuthorizationEndpoint, + AccessTokenEndpoint) + +from mediagoblin.decorators import require_active_login +from mediagoblin.tools.translate import pass_to_ugettext +from mediagoblin.meddleware.csrf import csrf_exempt +from mediagoblin.tools.request import decode_request +from mediagoblin.tools.response import (render_to_response, redirect, + json_response, render_400, + form_response) +from mediagoblin.tools.crypto import random_string +from mediagoblin.tools.validator import validate_email, validate_url +from mediagoblin.oauth.forms import AuthorizeForm +from mediagoblin.oauth.oauth import GMGRequestValidator, GMGRequest +from mediagoblin.oauth.tools.request import decode_authorization_header +from mediagoblin.oauth.tools.forms import WTFormData +from mediagoblin.db.models import NonceTimestamp, Client, RequestToken + +# possible client types +client_types = ["web", "native"] # currently what pump supports + +@csrf_exempt +def client_register(request): + """ Endpoint for client registration """ + try: + data = decode_request(request) + except ValueError: + error = "Could not decode data." + return json_response({"error": error}, status=400) + + if data is "": + error = "Unknown Content-Type" + return json_response({"error": error}, status=400) + + if "type" not in data: + error = "No registration type provided." + return json_response({"error": error}, status=400) + if data.get("application_type", None) not in client_types: + error = "Unknown application_type." + return json_response({"error": error}, status=400) + + client_type = data["type"] + + if client_type == "client_update": + # updating a client + if "client_id" not in data: + error = "client_id is requried to update." + return json_response({"error": error}, status=400) + elif "client_secret" not in data: + error = "client_secret is required to update." + return json_response({"error": error}, status=400) + + client = Client.query.filter_by( + id=data["client_id"], + secret=data["client_secret"] + ).first() + + if client is None: + error = "Unauthorized." + return json_response({"error": error}, status=403) + + client.application_name = data.get( + "application_name", + client.application_name + ) + + client.application_type = data.get( + "application_type", + client.application_type + ) + + app_name = ("application_type", client.application_name) + if app_name in client_types: + client.application_name = app_name + + elif client_type == "client_associate": + # registering + if "client_id" in data: + error = "Only set client_id for update." + return json_response({"error": error}, status=400) + elif "access_token" in data: + error = "access_token not needed for registration." + return json_response({"error": error}, status=400) + elif "client_secret" in data: + error = "Only set client_secret for update." + return json_response({"error": error}, status=400) + + # generate the client_id and client_secret + client_id = random_string(22) # seems to be what pump uses + client_secret = random_string(43) # again, seems to be what pump uses + expirey = 0 # for now, lets not have it expire + expirey_db = None if expirey == 0 else expirey + application_type = data["application_type"] + + # save it + client = Client( + id=client_id, + secret=client_secret, + expirey=expirey_db, + application_type=application_type, + ) + + else: + error = "Invalid registration type" + return json_response({"error": error}, status=400) + + logo_url = data.get("logo_url", client.logo_url) + if logo_url is not None and not validate_url(logo_url): + error = "Logo URL {0} is not a valid URL.".format(logo_url) + return json_response( + {"error": error}, + status=400 + ) + else: + client.logo_url = logo_url + + client.application_name = data.get("application_name", None) + + contacts = data.get("contacts", None) + if contacts is not None: + if type(contacts) is not unicode: + error = "Contacts must be a string of space-seporated email addresses." + return json_response({"error": error}, status=400) + + contacts = contacts.split() + for contact in contacts: + if not validate_email(contact): + # not a valid email + error = "Email {0} is not a valid email.".format(contact) + return json_response({"error": error}, status=400) + + + client.contacts = contacts + + redirect_uris = data.get("redirect_uris", None) + if redirect_uris is not None: + if type(redirect_uris) is not unicode: + error = "redirect_uris must be space-seporated URLs." + return json_response({"error": error}, status=400) + + redirect_uris = redirect_uris.split() + + for uri in redirect_uris: + if not validate_url(uri): + # not a valid uri + error = "URI {0} is not a valid URI".format(uri) + return json_response({"error": error}, status=400) + + client.redirect_uri = redirect_uris + + + client.save() + + expirey = 0 if client.expirey is None else client.expirey + + return json_response( + { + "client_id": client.id, + "client_secret": client.secret, + "expires_at": expirey, + }) + +@csrf_exempt +def request_token(request): + """ Returns request token """ + try: + data = decode_request(request) + except ValueError: + error = "Could not decode data." + return json_response({"error": error}, status=400) + + if data == "": + error = "Unknown Content-Type" + return json_response({"error": error}, status=400) + + if not data and request.headers: + data = request.headers + + data = dict(data) # mutableifying + + authorization = decode_authorization_header(data) + + if authorization == dict() or u"oauth_consumer_key" not in authorization: + error = "Missing required parameter." + return json_response({"error": error}, status=400) + + # check the client_id + client_id = authorization[u"oauth_consumer_key"] + client = Client.query.filter_by(id=client_id).first() + + if client == None: + # client_id is invalid + error = "Invalid client_id" + return json_response({"error": error}, status=400) + + # make request token and return to client + request_validator = GMGRequestValidator(authorization) + rv = RequestTokenEndpoint(request_validator) + tokens = rv.create_request_token(request, authorization) + + # store the nonce & timestamp before we return back + nonce = authorization[u"oauth_nonce"] + timestamp = authorization[u"oauth_timestamp"] + timestamp = datetime.datetime.fromtimestamp(float(timestamp)) + + nc = NonceTimestamp(nonce=nonce, timestamp=timestamp) + nc.save() + + return form_response(tokens) + +@require_active_login +def authorize(request): + """ Displays a page for user to authorize """ + if request.method == "POST": + return authorize_finish(request) + + _ = pass_to_ugettext + token = request.args.get("oauth_token", None) + if token is None: + # no token supplied, display a html 400 this time + err_msg = _("Must provide an oauth_token.") + return render_400(request, err_msg=err_msg) + + oauth_request = RequestToken.query.filter_by(token=token).first() + if oauth_request is None: + err_msg = _("No request token found.") + return render_400(request, err_msg) + + if oauth_request.used: + return authorize_finish(request) + + if oauth_request.verifier is None: + orequest = GMGRequest(request) + request_validator = GMGRequestValidator() + auth_endpoint = AuthorizationEndpoint(request_validator) + verifier = auth_endpoint.create_verifier(orequest, {}) + oauth_request.verifier = verifier["oauth_verifier"] + + oauth_request.user = request.user.id + oauth_request.save() + + # find client & build context + client = Client.query.filter_by(id=oauth_request.client).first() + + authorize_form = AuthorizeForm(WTFormData({ + "oauth_token": oauth_request.token, + "oauth_verifier": oauth_request.verifier + })) + + context = { + "user": request.user, + "oauth_request": oauth_request, + "client": client, + "authorize_form": authorize_form, + } + + + # AuthorizationEndpoint + return render_to_response( + request, + "mediagoblin/api/authorize.html", + context + ) + + +def authorize_finish(request): + """ Finishes the authorize """ + _ = pass_to_ugettext + token = request.form["oauth_token"] + verifier = request.form["oauth_verifier"] + oauth_request = RequestToken.query.filter_by(token=token, verifier=verifier) + oauth_request = oauth_request.first() + + if oauth_request is None: + # invalid token or verifier + err_msg = _("No request token found.") + return render_400(request, err_msg) + + oauth_request.used = True + oauth_request.updated = datetime.datetime.now() + oauth_request.save() + + if oauth_request.callback == "oob": + # out of bounds + context = {"oauth_request": oauth_request} + return render_to_response( + request, + "mediagoblin/api/oob.html", + context + ) + + # okay we need to redirect them then! + querystring = "?oauth_token={0}&oauth_verifier={1}".format( + oauth_request.token, + oauth_request.verifier + ) + + return redirect( + request, + querystring=querystring, + location=oauth_request.callback + ) + +@csrf_exempt +def access_token(request): + """ Provides an access token based on a valid verifier and request token """ + data = request.headers + + parsed_tokens = decode_authorization_header(data) + + if parsed_tokens == dict() or "oauth_token" not in parsed_tokens: + error = "Missing required parameter." + return json_response({"error": error}, status=400) + + + request.oauth_token = parsed_tokens["oauth_token"] + request_validator = GMGRequestValidator(data) + av = AccessTokenEndpoint(request_validator) + tokens = av.create_access_token(request, {}) + return form_response(tokens) + diff --git a/mediagoblin/plugins/api/tools.py b/mediagoblin/plugins/api/tools.py index 92411f4b..d1b3ebb1 100644 --- a/mediagoblin/plugins/api/tools.py +++ b/mediagoblin/plugins/api/tools.py @@ -51,30 +51,6 @@ class Auth(object): 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. diff --git a/mediagoblin/plugins/api/views.py b/mediagoblin/plugins/api/views.py index 9159fe65..b7e74799 100644 --- a/mediagoblin/plugins/api/views.py +++ b/mediagoblin/plugins/api/views.py @@ -21,11 +21,11 @@ from os.path import splitext from werkzeug.exceptions import BadRequest, Forbidden from werkzeug.wrappers import Response +from mediagoblin.tools.response import json_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.plugins.api.tools import api_auth, get_entry_serializable from mediagoblin.submit.lib import check_file_field, prepare_queue_task, \ run_process_media, new_upload_entry diff --git a/mediagoblin/plugins/basic_auth/README.rst b/mediagoblin/plugins/basic_auth/README.rst new file mode 100644 index 00000000..82f247ed --- /dev/null +++ b/mediagoblin/plugins/basic_auth/README.rst @@ -0,0 +1,24 @@ +.. _basic_auth-chapter: + +=================== + basic_auth plugin +=================== + +The basic_auth plugin is enabled by default in mediagoblin.ini. This plugin +provides basic username and password authentication for GNU Mediagoblin. + +This plugin can be enabled alongside :ref:`openid-chapter` and +:ref:`persona-chapter`. + +Set up the basic_auth plugin +============================ + +1. Add the following to your MediaGoblin .ini file in the ``[plugins]`` section:: + + [[mediagoblin.plugins.basic_auth]] + +2. Run:: + + gmg assetlink + + in order to link basic_auth's static assets diff --git a/mediagoblin/plugins/oauth/__init__.py b/mediagoblin/plugins/oauth/__init__.py index 5762379d..82c1f380 100644 --- a/mediagoblin/plugins/oauth/__init__.py +++ b/mediagoblin/plugins/oauth/__init__.py @@ -35,22 +35,22 @@ def setup_plugin(): routes = [ ('mediagoblin.plugins.oauth.authorize', - '/oauth/authorize', + '/oauth-2/authorize', 'mediagoblin.plugins.oauth.views:authorize'), ('mediagoblin.plugins.oauth.authorize_client', - '/oauth/client/authorize', + '/oauth-2/client/authorize', 'mediagoblin.plugins.oauth.views:authorize_client'), ('mediagoblin.plugins.oauth.access_token', - '/oauth/access_token', + '/oauth-2/access_token', 'mediagoblin.plugins.oauth.views:access_token'), ('mediagoblin.plugins.oauth.list_connections', - '/oauth/client/connections', + '/oauth-2/client/connections', 'mediagoblin.plugins.oauth.views:list_connections'), ('mediagoblin.plugins.oauth.register_client', - '/oauth/client/register', + '/oauth-2/client/register', 'mediagoblin.plugins.oauth.views:register_client'), ('mediagoblin.plugins.oauth.list_clients', - '/oauth/client/list', + '/oauth-2/client/list', 'mediagoblin.plugins.oauth.views:list_clients')] pluginapi.register_routes(routes) diff --git a/mediagoblin/plugins/oauth/tools.py b/mediagoblin/plugins/oauth/tools.py index 27ff32b4..af0a3305 100644 --- a/mediagoblin/plugins/oauth/tools.py +++ b/mediagoblin/plugins/oauth/tools.py @@ -23,7 +23,7 @@ from datetime import datetime from functools import wraps -from mediagoblin.plugins.api.tools import json_response +from mediagoblin.tools.response import json_response def require_client_auth(controller): diff --git a/mediagoblin/plugins/oauth/views.py b/mediagoblin/plugins/oauth/views.py index d6fd314f..de637d6b 100644 --- a/mediagoblin/plugins/oauth/views.py +++ b/mediagoblin/plugins/oauth/views.py @@ -21,7 +21,7 @@ from urllib import urlencode from werkzeug.exceptions import BadRequest -from mediagoblin.tools.response import render_to_response, redirect +from mediagoblin.tools.response import render_to_response, redirect, json_response from mediagoblin.decorators import require_active_login from mediagoblin.messages import add_message, SUCCESS from mediagoblin.tools.translate import pass_to_ugettext as _ @@ -31,7 +31,6 @@ 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__) diff --git a/mediagoblin/plugins/openid/README.rst b/mediagoblin/plugins/openid/README.rst new file mode 100644 index 00000000..870a2b58 --- /dev/null +++ b/mediagoblin/plugins/openid/README.rst @@ -0,0 +1,34 @@ +.. _openid-chapter: + +=================== + openid plugin +=================== + +The openid plugin allows user to login to your GNU Mediagoblin instance using +their openid url. + +This plugin can be enabled alongside :ref:`basic_auth-chapter` and +:ref:`persona-chapter`. + +.. note:: + When :ref:`basic_auth-chapter` is enabled alongside this openid plugin, and + a user creates an account using their openid. If they would like to add a + password to their account, they can use the forgot password feature to do + so. + + +Set up the openid plugin +============================ + +1. Install the ``python-openid`` package. + +2. Add the following to your MediaGoblin .ini file in the ``[plugins]`` section:: + + [[mediagoblin.plugins.openid]] + +3. Run:: + + gmg dbupdate + + in order to create and apply migrations to any database tables that the + plugin requires. diff --git a/mediagoblin/plugins/openid/__init__.py b/mediagoblin/plugins/openid/__init__.py index ee88808c..ca17a7e8 100644 --- a/mediagoblin/plugins/openid/__init__.py +++ b/mediagoblin/plugins/openid/__init__.py @@ -120,4 +120,6 @@ hooks = { 'auth_no_pass_redirect': no_pass_redirect, ('mediagoblin.auth.register', 'mediagoblin/auth/register.html'): add_to_form_context, + ('mediagoblin.auth.login', + 'mediagoblin/auth/login.html'): add_to_form_context } diff --git a/mediagoblin/plugins/openid/templates/mediagoblin/plugins/openid/login.html b/mediagoblin/plugins/openid/templates/mediagoblin/plugins/openid/login.html index 33df7200..8d74c2b9 100644 --- a/mediagoblin/plugins/openid/templates/mediagoblin/plugins/openid/login.html +++ b/mediagoblin/plugins/openid/templates/mediagoblin/plugins/openid/login.html @@ -44,6 +44,7 @@ {% trans %}Log in to create an account!{% endtrans %} </p> {% endif %} + {% template_hook('login_link') %} {% if pass_auth is defined %} <p> <a href="{{ request.urlgen('mediagoblin.auth.login') }}?{{ request.query_string }}"> diff --git a/mediagoblin/plugins/openid/templates/mediagoblin/plugins/openid/login_link.html b/mediagoblin/plugins/openid/templates/mediagoblin/plugins/openid/login_link.html index e5e77d01..fa4d5e85 100644 --- a/mediagoblin/plugins/openid/templates/mediagoblin/plugins/openid/login_link.html +++ b/mediagoblin/plugins/openid/templates/mediagoblin/plugins/openid/login_link.html @@ -17,9 +17,11 @@ #} {% block openid_login_link %} + {% if openid_link is defined %} <p> <a href="{{ request.urlgen('mediagoblin.plugins.openid.login') }}?{{ request.query_string }}"> {%- trans %}Or login with OpenID!{% endtrans %} </a> </p> + {% endif %} {% endblock %} diff --git a/mediagoblin/plugins/persona/README.rst b/mediagoblin/plugins/persona/README.rst new file mode 100644 index 00000000..ef19ac5d --- /dev/null +++ b/mediagoblin/plugins/persona/README.rst @@ -0,0 +1,41 @@ +.. _persona-chapter: + +================ + persona plugin +================ + +The persona plugin allows users to login to you GNU MediaGoblin instance using +`Mozilla Persona`_. + +This plugin can be enabled alongside :ref:`openid-chapter` and +:ref:`basic_auth-chapter`. + +.. note:: + When :ref:`basic_auth-chapter` is enabled alongside this persona plugin, and + a user creates an account using their persona. If they would like to add a + password to their account, they can use the forgot password feature to do + so. + +.. _Mozilla Persona: https://www.mozilla.org/en-US/persona/ + +Set up the persona plugin +========================= + +1. Install the ``requests`` package. + +2. Add the following to your MediaGoblin .ini file in the ``[plugins]`` section:: + + [[mediagoblin.plugins.persona]] + +3. Run:: + + gmg dbupdate + + in order to create and apply migrations to any database tables that the + plugin requires. + +4. Run:: + + gmg assetlink + + in order to persona's static assets. diff --git a/mediagoblin/plugins/persona/__init__.py b/mediagoblin/plugins/persona/__init__.py new file mode 100644 index 00000000..700c18e2 --- /dev/null +++ b/mediagoblin/plugins/persona/__init__.py @@ -0,0 +1,116 @@ +# 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 pkg_resources import resource_filename +import os + +from sqlalchemy import or_ + +from mediagoblin.auth.tools import create_basic_user +from mediagoblin.db.models import User +from mediagoblin.plugins.persona.models import PersonaUserEmails +from mediagoblin.tools import pluginapi +from mediagoblin.tools.staticdirect import PluginStatic +from mediagoblin.tools.translate import pass_to_ugettext as _ + +PLUGIN_DIR = os.path.dirname(__file__) + + +def setup_plugin(): + config = pluginapi.get_config('mediagoblin.plugins.persona') + + routes = [ + ('mediagoblin.plugins.persona.login', + '/auth/persona/login/', + 'mediagoblin.plugins.persona.views:login'), + ('mediagoblin.plugins.persona.register', + '/auth/persona/register/', + 'mediagoblin.plugins.persona.views:register'), + ('mediagoblin.plugins.persona.edit', + '/edit/persona/', + 'mediagoblin.plugins.persona.views:edit'), + ('mediagoblin.plugins.persona.add', + '/edit/persona/add/', + 'mediagoblin.plugins.persona.views:add')] + + pluginapi.register_routes(routes) + pluginapi.register_template_path(os.path.join(PLUGIN_DIR, 'templates')) + pluginapi.register_template_hooks( + {'persona_end': 'mediagoblin/plugins/persona/persona_js_end.html', + 'persona_form': 'mediagoblin/plugins/persona/persona.html', + 'edit_link': 'mediagoblin/plugins/persona/edit_link.html', + 'login_link': 'mediagoblin/plugins/persona/login_link.html', + 'register_link': 'mediagoblin/plugins/persona/register_link.html'}) + + +def create_user(register_form): + if 'persona_email' in register_form: + username = register_form.username.data + user = User.query.filter( + or_( + User.username == username, + User.email == username, + )).first() + + if not user: + user = create_basic_user(register_form) + + new_entry = PersonaUserEmails() + new_entry.persona_email = register_form.persona_email.data + new_entry.user_id = user.id + new_entry.save() + + return user + + +def extra_validation(register_form): + persona_email = register_form.persona_email.data if 'persona_email' in \ + register_form else None + if persona_email: + persona_email_exists = PersonaUserEmails.query.filter_by( + persona_email=persona_email + ).count() + + extra_validation_passes = True + + if persona_email_exists: + register_form.persona_email.errors.append( + _('Sorry, an account is already registered to that Persona' + ' email.')) + extra_validation_passes = False + + return extra_validation_passes + + +def Auth(): + return True + + +def add_to_global_context(context): + if len(pluginapi.hook_runall('authentication')) == 1: + context['persona_auth'] = True + context['persona'] = True + return context + +hooks = { + 'setup': setup_plugin, + 'authentication': Auth, + 'auth_extra_validation': extra_validation, + 'auth_create_user': create_user, + 'template_global_context': add_to_global_context, + 'static_setup': lambda: PluginStatic( + 'coreplugin_persona', + resource_filename('mediagoblin.plugins.persona', 'static')) +} diff --git a/mediagoblin/plugins/persona/forms.py b/mediagoblin/plugins/persona/forms.py new file mode 100644 index 00000000..608be0c7 --- /dev/null +++ b/mediagoblin/plugins/persona/forms.py @@ -0,0 +1,41 @@ +# 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 mediagoblin.tools.translate import lazy_pass_to_ugettext as _ +from mediagoblin.auth.tools import normalize_user_or_email_field + + +class RegistrationForm(wtforms.Form): + username = wtforms.TextField( + _('Username'), + [wtforms.validators.Required(), + normalize_user_or_email_field(allow_email=False)]) + email = wtforms.TextField( + _('Email address'), + [wtforms.validators.Required(), + normalize_user_or_email_field(allow_user=False)]) + persona_email = wtforms.HiddenField( + '', + [wtforms.validators.Required(), + normalize_user_or_email_field(allow_user=False)]) + + +class EditForm(wtforms.Form): + email = wtforms.TextField( + _('Email address'), + [wtforms.validators.Required(), + normalize_user_or_email_field(allow_user=False)]) diff --git a/mediagoblin/plugins/persona/models.py b/mediagoblin/plugins/persona/models.py new file mode 100644 index 00000000..ff3c525a --- /dev/null +++ b/mediagoblin/plugins/persona/models.py @@ -0,0 +1,36 @@ +# 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 sqlalchemy import Column, Integer, Unicode, ForeignKey +from sqlalchemy.orm import relationship, backref + +from mediagoblin.db.models import User +from mediagoblin.db.base import Base + + +class PersonaUserEmails(Base): + __tablename__ = "persona__user_emails" + + id = Column(Integer, primary_key=True) + persona_email = Column(Unicode, nullable=False) + user_id = Column(Integer, ForeignKey(User.id), nullable=False) + + # Persona's are owned by their user, so do the full thing. + user = relationship(User, backref=backref('persona_emails', + cascade='all, delete-orphan')) + +MODELS = [ + PersonaUserEmails +] diff --git a/mediagoblin/plugins/persona/static/js/persona.js b/mediagoblin/plugins/persona/static/js/persona.js new file mode 100644 index 00000000..a6def398 --- /dev/null +++ b/mediagoblin/plugins/persona/static/js/persona.js @@ -0,0 +1,51 @@ +/** + * GNU MediaGoblin -- federated, autonomous media hosting + * Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +$(document).ready(function () { + var signinLink = document.getElementById('persona_login'); + if (signinLink) { + signinLink.onclick = function() { navigator.id.request(); }; + } + + var signinLink1 = document.getElementById('persona_login1'); + if (signinLink1) { + signinLink1.onclick = function() { navigator.id.request(); }; + } + + var signoutLink = document.getElementById('logout'); + if (signoutLink) { + signoutLink.onclick = function() { navigator.id.logout(); }; + } + + var logout_url = document.getElementById('_logout_url').value; + + navigator.id.watch({ + onlogin: function(assertion) { + document.getElementById('_assertion').value = assertion; + document.getElementById('_persona_login').submit() + }, + onlogout: function() { + $.ajax({ + type: 'GET', + url: logout_url, + success: function(res, status, xhr) { window.location.reload(); }, + error: function(xhr, status, err) { alert("Logout failure: " + err); } + }); + } + }); +}); diff --git a/mediagoblin/plugins/persona/templates/mediagoblin/plugins/persona/edit.html b/mediagoblin/plugins/persona/templates/mediagoblin/plugins/persona/edit.html new file mode 100644 index 00000000..be62b8cc --- /dev/null +++ b/mediagoblin/plugins/persona/templates/mediagoblin/plugins/persona/edit.html @@ -0,0 +1,43 @@ +{# +# 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 title -%} + {% trans %}Add an OpenID{% endtrans %} — {{ super() }} +{%- endblock %} + +{% block mediagoblin_content %} + <form action="{{ request.urlgen('mediagoblin.plugins.persona.edit') }}" + method="POST" enctype="multipart/form-data"> + {{ csrf_token }} + <div class="form_box"> + <h1>{% trans %}Delete a Persona email address{% endtrans %}</h1> + <p> + <a href="javascript:;" id="persona_login"> + {% trans %}Add a Persona email address{% endtrans %} + </a> + </p> + {{ wtforms_util.render_divs(form, True) }} + <div class="form_submit_buttons"> + <input type="submit" value="{% trans %}Delete{% endtrans %}" class="button_form"/> + </div> + </div> + </form> +{% endblock %} diff --git a/mediagoblin/plugins/persona/templates/mediagoblin/plugins/persona/edit_link.html b/mediagoblin/plugins/persona/templates/mediagoblin/plugins/persona/edit_link.html new file mode 100644 index 00000000..08879da5 --- /dev/null +++ b/mediagoblin/plugins/persona/templates/mediagoblin/plugins/persona/edit_link.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/>. +#} + +{% block persona_edit_link %} + <p> + <a href="{{ request.urlgen('mediagoblin.plugins.persona.edit') }}"> + {% trans %}Edit your Persona email addresses{% endtrans %} + </a> + </p> +{% endblock %} diff --git a/mediagoblin/plugins/persona/templates/mediagoblin/plugins/persona/login_link.html b/mediagoblin/plugins/persona/templates/mediagoblin/plugins/persona/login_link.html new file mode 100644 index 00000000..975683da --- /dev/null +++ b/mediagoblin/plugins/persona/templates/mediagoblin/plugins/persona/login_link.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/>. +#} + +{% block person_login_link %} + <p> + <a href="javascript:;" id="persona_login"> + {% trans %}Or login with Persona!{% endtrans %} + </a> + </p> +{% endblock %} diff --git a/mediagoblin/plugins/persona/templates/mediagoblin/plugins/persona/persona.html b/mediagoblin/plugins/persona/templates/mediagoblin/plugins/persona/persona.html new file mode 100644 index 00000000..372bd246 --- /dev/null +++ b/mediagoblin/plugins/persona/templates/mediagoblin/plugins/persona/persona.html @@ -0,0 +1,32 @@ +{# +# 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 persona %} + <form id="_persona_login" + action= + {%- if edit_persona is defined -%} + "{{ request.urlgen('mediagoblin.plugins.persona.add') }}" + {%- else -%} + "{{ request.urlgen('mediagoblin.plugins.persona.login') }}" + {%- endif %} + method="POST"> + {{ csrf_token }} + <input type="hidden" name="assertion" type="text" id="_assertion"/> + <input type="hidden" name="_logout_url" type="text" id="_logout_url" + value="{{ request.urlgen('mediagoblin.auth.logout') }}"/> + </form> +{% endblock %} diff --git a/mediagoblin/plugins/persona/templates/mediagoblin/plugins/persona/persona_js_end.html b/mediagoblin/plugins/persona/templates/mediagoblin/plugins/persona/persona_js_end.html new file mode 100644 index 00000000..8c0d72d5 --- /dev/null +++ b/mediagoblin/plugins/persona/templates/mediagoblin/plugins/persona/persona_js_end.html @@ -0,0 +1,21 @@ +{# +# 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/>. +#} + +<script src="https://login.persona.org/include.js"></script> +<script type="text/javascript" + src="{{ request.staticdirect('/js/persona.js', 'coreplugin_persona') }}"></script> diff --git a/mediagoblin/plugins/persona/templates/mediagoblin/plugins/persona/register_link.html b/mediagoblin/plugins/persona/templates/mediagoblin/plugins/persona/register_link.html new file mode 100644 index 00000000..bcd9ae2b --- /dev/null +++ b/mediagoblin/plugins/persona/templates/mediagoblin/plugins/persona/register_link.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/>. +#} + +{% block persona_register_link %} + <p> + <a href="javascript:;" id="persona_login"> + {% trans %}Or register with Persona!{% endtrans %} + </a> + </p> +{% endblock %} diff --git a/mediagoblin/plugins/persona/views.py b/mediagoblin/plugins/persona/views.py new file mode 100644 index 00000000..f3aff38d --- /dev/null +++ b/mediagoblin/plugins/persona/views.py @@ -0,0 +1,191 @@ +# 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 +import requests + +from werkzeug.exceptions import BadRequest + +from mediagoblin import messages, mg_globals +from mediagoblin.auth.tools import register_user +from mediagoblin.decorators import (auth_enabled, allow_registration, + require_active_login) +from mediagoblin.tools.response import render_to_response, redirect +from mediagoblin.tools.translate import pass_to_ugettext as _ +from mediagoblin.plugins.persona import forms +from mediagoblin.plugins.persona.models import PersonaUserEmails + +_log = logging.getLogger(__name__) + + +def _get_response(request): + if 'assertion' not in request.form: + _log.debug('assertion not in request.form') + raise BadRequest() + + data = {'assertion': request.form['assertion'], + 'audience': request.urlgen('index', qualified=True)} + resp = requests.post('https://verifier.login.persona.org/verify', + data=data, verify=True) + + if resp.ok: + verification_data = json.loads(resp.content) + + if verification_data['status'] == 'okay': + return verification_data['email'] + + return None + + +@auth_enabled +def login(request): + if request.method == 'GET': + return redirect(request, 'mediagoblin.auth.login') + + email = _get_response(request) + if email: + query = PersonaUserEmails.query.filter_by( + persona_email=email + ).first() + user = query.user if query else None + + if user: + request.session['user_id'] = unicode(user.id) + request.session.save() + + return redirect(request, "index") + + else: + if not mg_globals.app.auth: + messages.add_message( + request, + messages.WARNING, + _('Sorry, authentication is disabled on this instance.')) + + return redirect(request, 'index') + + register_form = forms.RegistrationForm(email=email, + persona_email=email) + return render_to_response( + request, + 'mediagoblin/auth/register.html', + {'register_form': register_form, + 'post_url': request.urlgen( + 'mediagoblin.plugins.persona.register')}) + + return redirect(request, 'mediagoblin.auth.login') + + +@allow_registration +@auth_enabled +def register(request): + if request.method == 'GET': + # Need to connect to persona before registering a user. If method is + # 'GET', then this page was acessed without logging in first. + return redirect(request, 'mediagoblin.auth.login') + register_form = forms.RegistrationForm(request.form) + + if register_form.validate(): + user = register_user(request, register_form) + + if user: + # redirect the user to their homepage... there will be a + # message waiting for them to verify their email + return redirect( + request, 'mediagoblin.user_pages.user_home', + user=user.username) + + return render_to_response( + request, + 'mediagoblin/auth/register.html', + {'register_form': register_form, + 'post_url': request.urlgen('mediagoblin.plugins.persona.register')}) + + +@require_active_login +def edit(request): + form = forms.EditForm(request.form) + + if request.method == 'POST' and form.validate(): + query = PersonaUserEmails.query.filter_by( + persona_email=form.email.data) + user = query.first().user if query.first() else None + + if user and user.id == int(request.user.id): + count = len(user.persona_emails) + + if count > 1 or user.pw_hash: + # User has more then one Persona email or also has a password. + query.first().delete() + + messages.add_message( + request, + messages.SUCCESS, + _('The Persona email address was successfully removed.')) + + return redirect(request, 'mediagoblin.edit.account') + + elif not count > 1: + form.email.errors.append( + _("You can't delete your only Persona email address unless" + " you have a password set.")) + + else: + form.email.errors.append( + _('That Persona email address is not registered to this' + ' account.')) + + return render_to_response( + request, + 'mediagoblin/plugins/persona/edit.html', + {'form': form, + 'edit_persona': True}) + + +@require_active_login +def add(request): + if request.method == 'GET': + return redirect(request, 'mediagoblin.plugins.persona.edit') + + email = _get_response(request) + + if email: + query = PersonaUserEmails.query.filter_by( + persona_email=email + ).first() + user_exists = query.user if query else None + + if user_exists: + messages.add_message( + request, + messages.WARNING, + _('Sorry, an account is already registered with that Persona' + ' email address.')) + return redirect(request, 'mediagoblin.plugins.persona.edit') + + else: + # Save the Persona Email to the user + new_entry = PersonaUserEmails() + new_entry.persona_email = email + new_entry.user_id = request.user.id + new_entry.save() + + messages.add_message( + request, + messages.SUCCESS, + _('Your Person email address was saved successfully.')) + + return redirect(request, 'mediagoblin.edit.account') diff --git a/mediagoblin/processing/__init__.py b/mediagoblin/processing/__init__.py index f3a85940..454eb09b 100644 --- a/mediagoblin/processing/__init__.py +++ b/mediagoblin/processing/__init__.py @@ -184,7 +184,6 @@ class BaseProcessingFail(Exception): def __init__(self, **metadata): self.metadata = metadata or {} - class BadMediaFail(BaseProcessingFail): """ Error that should be raised when an inappropriate file was given diff --git a/mediagoblin/processing/task.py b/mediagoblin/processing/task.py index 9af192ed..05cac844 100644 --- a/mediagoblin/processing/task.py +++ b/mediagoblin/processing/task.py @@ -18,11 +18,13 @@ import logging import urllib import urllib2 -from celery import registry, task +import celery +from celery.registry import tasks from mediagoblin import mg_globals as mgg from mediagoblin.db.models import MediaEntry -from . import mark_entry_failed, BaseProcessingFail, ProcessingState +from mediagoblin.processing import (mark_entry_failed, BaseProcessingFail, + ProcessingState) from mediagoblin.tools.processing import json_processing_callback _log = logging.getLogger(__name__) @@ -30,7 +32,7 @@ logging.basicConfig() _log.setLevel(logging.DEBUG) -@task.task(default_retry_delay=2 * 60) +@celery.task(default_retry_delay=2 * 60) def handle_push_urls(feed_url): """Subtask, notifying the PuSH servers of new content @@ -60,14 +62,16 @@ def handle_push_urls(feed_url): 'Giving up.'.format(feed_url)) return False + ################################ # Media processing initial steps ################################ - -class ProcessMedia(task.Task): +class ProcessMedia(celery.Task): """ Pass this entry off for processing. """ + track_started=True + def run(self, media_id, feed_url): """ Pass the media entry off to the appropriate processing function @@ -140,6 +144,4 @@ class ProcessMedia(task.Task): entry = mgg.database.MediaEntry.query.filter_by(id=entry_id).first() json_processing_callback(entry) -# Register the task -process_media = registry.tasks[ProcessMedia.name] - +tasks.register(ProcessMedia) diff --git a/mediagoblin/routing.py b/mediagoblin/routing.py index 9686d103..a9809c44 100644 --- a/mediagoblin/routing.py +++ b/mediagoblin/routing.py @@ -38,8 +38,8 @@ def get_url_map(): import mediagoblin.webfinger.routing import mediagoblin.listings.routing import mediagoblin.notifications.routing - - + import mediagoblin.oauth.routing + for route in PluginManager().get_routes(): add_route(*route) diff --git a/mediagoblin/static/css/base.css b/mediagoblin/static/css/base.css index 1293086d..7fcbb93e 100644 --- a/mediagoblin/static/css/base.css +++ b/mediagoblin/static/css/base.css @@ -812,3 +812,10 @@ pre { #exif_additional_info table tr { margin-bottom: 10px; } + +p.verifier { + text-align:center; + font-size:50px; + none repeat scroll 0% 0% rgb(221, 221, 221); + padding: 1em 0px; +} diff --git a/mediagoblin/static/images/home_goblin.png b/mediagoblin/static/images/home_goblin.png Binary files differnew file mode 100644 index 00000000..5ba9afeb --- /dev/null +++ b/mediagoblin/static/images/home_goblin.png diff --git a/mediagoblin/static/js/comment_show.js b/mediagoblin/static/js/comment_show.js index c5ccee66..df3c1093 100644 --- a/mediagoblin/static/js/comment_show.js +++ b/mediagoblin/static/js/comment_show.js @@ -15,12 +15,25 @@ * 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/>. */ +var content=""; +function previewComment(){ + if ($('#comment_content').val() && (content != $('#comment_content').val())) { + content = $('#comment_content').val(); + $.post($('#previewURL').val(),$('#form_comment').serialize(), + function(data){ + preview = JSON.parse(data) + $('#comment_preview').replaceWith("<div id=comment_preview><h3>" + $('#previewText').val() +"</h3><br />" + preview.content + + "<hr style='border: 1px solid #333;' /></div>"); + }); + } +} $(document).ready(function(){ $('#form_comment').hide(); $('#button_addcomment').click(function(){ $(this).fadeOut('fast'); $('#form_comment').slideDown(function(){ + setInterval("previewComment()",1000); $('#comment_content').focus(); }); }); diff --git a/mediagoblin/submit/lib.py b/mediagoblin/submit/lib.py index 7e85696b..33687a72 100644 --- a/mediagoblin/submit/lib.py +++ b/mediagoblin/submit/lib.py @@ -21,7 +21,7 @@ from werkzeug.datastructures import FileStorage from mediagoblin.db.models import MediaEntry from mediagoblin.processing import mark_entry_failed -from mediagoblin.processing.task import process_media +from mediagoblin.processing.task import ProcessMedia _log = logging.getLogger(__name__) @@ -85,7 +85,7 @@ def run_process_media(entry, feed_url=None): 'mediagoblin.user_pages.atom_feed',qualified=True, user=request.user.username)`""" try: - process_media.apply_async( + ProcessMedia().apply_async( [entry.id, feed_url], {}, task_id=entry.queued_task_id) except BaseException as exc: diff --git a/mediagoblin/submit/views.py b/mediagoblin/submit/views.py index 7c0708ed..8640b8de 100644 --- a/mediagoblin/submit/views.py +++ b/mediagoblin/submit/views.py @@ -90,7 +90,7 @@ def submit_start(request): # Save now so we have this data before kicking off processing entry.save() - # Pass off to processing + # Pass off to async processing # # (... don't change entry after this point to avoid race # conditions with changes to the document via processing code) @@ -98,6 +98,7 @@ def submit_start(request): 'mediagoblin.user_pages.atom_feed', qualified=True, user=request.user.username) run_process_media(entry, feed_url) + add_message(request, SUCCESS, _('Woohoo! Submitted!')) add_comment_subscription(request.user, entry) diff --git a/mediagoblin/templates/mediagoblin/api/authorize.html b/mediagoblin/templates/mediagoblin/api/authorize.html new file mode 100644 index 00000000..d0ec2616 --- /dev/null +++ b/mediagoblin/templates/mediagoblin/api/authorize.html @@ -0,0 +1,56 @@ +{# +# 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" %} + +{% block title -%} + {% trans %}Authorization{% endtrans %} — {{ super() }} +{%- endblock %} + +{% block mediagoblin_content %} + +<h1>{% trans %}Authorize{% endtrans %}</h1> + +<p> + {% trans %}You are logged in as{% endtrans %} + <strong>{{user.username}}</strong> + <br /><br /> + + {% trans %}Do you want to authorize {% endtrans %} + {% if client.application_name -%} + <em>{{ client.application_name }}</em> + {%- else -%} + <em>{% trans %}an unknown application{% endtrans %}</em> + {%- endif %} + {% trans %} to access your account? {% endtrans %} + <br /><br /> + {% trans %}Applications with access to your account can: {% endtrans %} + <ul> + <li>{% trans %}Post new media as you{% endtrans %}</li> + <li>{% trans %}See your information (e.g profile, meida, etc...){% endtrans %}</li> + <li>{% trans %}Change your information{% endtrans %}</li> + </ul> + <br /> + + <form method="POST"> + {{ csrf_token }} + {{ authorize_form.oauth_token }} + {{ authorize_form.oauth_verifier }} + <input type="submit" value="{% trans %}Authorize{% endtrans %}"> + </form> +</p> +{% endblock %} diff --git a/mediagoblin/templates/mediagoblin/api/oob.html b/mediagoblin/templates/mediagoblin/api/oob.html new file mode 100644 index 00000000..d290472a --- /dev/null +++ b/mediagoblin/templates/mediagoblin/api/oob.html @@ -0,0 +1,33 @@ +{# +# 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" %} + +{% block title -%} + {% trans %}Authorization Finished{% endtrans %} — {{ super() }} +{%- endblock %} + +{% block mediagoblin_content %} + +<h1>{% trans %}Authorization Complete{% endtrans %}</h1> + +<h4>{% trans %}Copy and paste this into your client:{% endtrans %}</h4> + +<p class="verifier"> + {{ oauth_request.verifier }} +</p> +{% endblock %} diff --git a/mediagoblin/templates/mediagoblin/base.html b/mediagoblin/templates/mediagoblin/base.html index 6eaad70b..7d53585b 100644 --- a/mediagoblin/templates/mediagoblin/base.html +++ b/mediagoblin/templates/mediagoblin/base.html @@ -23,6 +23,7 @@ <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <meta http-equiv="X-UA-Compatible" content="IE=Edge"> <title>{% block title %}{{ app_config['html_title'] }}{% endblock %}</title> <link rel="stylesheet" type="text/css" href="{{ request.staticdirect('/css/extlib/reset.css') }}"/> @@ -60,24 +61,35 @@ {%- if request.user %} {% if request.user and request.user.status == 'active' %} - {% set notification_count = request.notifications.get_notification_count(request.user.id) %} + {% set notification_count = get_notification_count(request.user.id) %} {% if notification_count %} - <a href="#notifications" class="notification-gem button_action" title="Notifications"> - {{ notification_count }}</a> + <a href="javascript:;" class="notification-gem button_action" title="Notifications"> + {{ notification_count }}</a> {% endif %} - <div class="button_action header_dropdown_down">▼</div> - <div class="button_action header_dropdown_up">▲</div> + <a href="javascript:;" class="button_action header_dropdown_down">▼</a> + <a href="javascript:;" class="button_action header_dropdown_up">▲</a> {% elif request.user and request.user.status == "needs_email_verification" %} {# the following link should only appear when verification is needed #} <a href="{{ request.urlgen('mediagoblin.user_pages.user_home', user=request.user.username) }}" class="button_action_highlight"> {% trans %}Verify your email!{% endtrans %}</a> - or <a href="{{ request.urlgen('mediagoblin.auth.logout') }}">{% trans %}log out{% endtrans %}</a> + or <a id="logout" href= + {% if persona is not defined %} + "{{ request.urlgen('mediagoblin.auth.logout') }}" + {% else %} + "javascript:;" + {% endif %} + >{% trans %}log out{% endtrans %}</a> {% endif %} {%- elif auth %} - <a href="{{ request.urlgen('mediagoblin.auth.login') }}?next={{ - request.base_url|urlencode }}"> + <a href= + {% if persona_auth is defined %} + "javascript:;" id="persona_login" + {% else %} + "{{ request.urlgen('mediagoblin.auth.login') }}" + {% endif %} + > {%- trans %}Log in{% endtrans -%} </a> {%- endif %} @@ -101,7 +113,13 @@ {%- trans %}Media processing panel{% endtrans -%} </a> · - <a href="{{ request.urlgen('mediagoblin.auth.logout') }}">{% trans %}Log out{% endtrans %}</a> + <a id="logout" href= + {% if persona is not defined %} + "{{ request.urlgen('mediagoblin.auth.logout') }}" + {% else %} + "javascript:;" + {% endif %} + >{% trans %}Log out{% endtrans %}</a> </p> <a class="button_action" href="{{ request.urlgen('mediagoblin.submit.start') }}"> {%- trans %}Add media{% endtrans -%} @@ -134,6 +152,9 @@ {% include "mediagoblin/utils/messages.html" %} {% block mediagoblin_content %} {% endblock mediagoblin_content %} + {% if csrf_token is defined %} + {% template_hook("persona_form") %} + {% endif %} </div> {%- include "mediagoblin/bits/base_footer.html" %} </div> diff --git a/mediagoblin/templates/mediagoblin/bits/body_end.html b/mediagoblin/templates/mediagoblin/bits/body_end.html index bb7b9762..c8f5e2d6 100644 --- a/mediagoblin/templates/mediagoblin/bits/body_end.html +++ b/mediagoblin/templates/mediagoblin/bits/body_end.html @@ -15,3 +15,5 @@ # 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/>. -#} + +{% template_hook("persona_end") %} diff --git a/mediagoblin/templates/mediagoblin/bits/frontpage_welcome.html b/mediagoblin/templates/mediagoblin/bits/frontpage_welcome.html index 9ef28a4d..4e55e618 100644 --- a/mediagoblin/templates/mediagoblin/bits/frontpage_welcome.html +++ b/mediagoblin/templates/mediagoblin/bits/frontpage_welcome.html @@ -19,21 +19,27 @@ {% if request.user %} <h1>{% trans %}Explore{% endtrans %}</h1> {% else %} + <img class="right_align" src="{{ request.staticdirect('/images/home_goblin.png') }}" /> <h1>{% trans %}Hi there, welcome to this MediaGoblin site!{% endtrans %}</h1> - <img class="right_align" src="{{ request.staticdirect('/images/frontpage_image.png') }}" /> <p>{% trans %}This site is running <a href="http://mediagoblin.org">MediaGoblin</a>, an extraordinarily great piece of media hosting software.{% endtrans %}</p> {% if auth %} <p>{% trans %}To add your own media, place comments, and more, you can log in with your MediaGoblin account.{% endtrans %}</p> {% if allow_registration %} <p>{% trans %}Don't have one yet? It's easy!{% endtrans %}</p> - {% trans register_url=request.urlgen('mediagoblin.auth.register') -%} - <a class="button_action_highlight" href="{{ register_url }}">Create an account at this site</a> + <a class="button_action_highlight" href= + {% if persona_auth is defined %} + "javascript:;" id="persona_login1" + {% else %} + "{{ request.urlgen('mediagoblin.auth.register') }}" + {% endif %} + {% trans %} + >Create an account at this site</a> or {%- endtrans %} {% endif %} {% endif %} {% trans %} - <a class="button_action" href="http://wiki.mediagoblin.org/HackingHowto">Set up MediaGoblin on your own server</a> + <a class="button_action" href="http://mediagoblin.readthedocs.org/">Set up MediaGoblin on your own server</a> {%- endtrans %} <div class="clear"></div> diff --git a/mediagoblin/templates/mediagoblin/fragments/header_notifications.html b/mediagoblin/templates/mediagoblin/fragments/header_notifications.html index 613100aa..70d7935a 100644 --- a/mediagoblin/templates/mediagoblin/fragments/header_notifications.html +++ b/mediagoblin/templates/mediagoblin/fragments/header_notifications.html @@ -1,4 +1,4 @@ -{% set notifications = request.notifications.get_notifications(request.user.id) %} +{% set notifications = get_notifications(request.user.id) %} {% if notifications %} <div class="header_notifications"> <h3>{% trans %}New comments{% endtrans %}</h3> diff --git a/mediagoblin/templates/mediagoblin/user_pages/media.html b/mediagoblin/templates/mediagoblin/user_pages/media.html index 441452f2..e161afc9 100644 --- a/mediagoblin/templates/mediagoblin/user_pages/media.html +++ b/mediagoblin/templates/mediagoblin/user_pages/media.html @@ -90,7 +90,8 @@ {% if app_config['allow_comments'] %} <a {% if not request.user %} - href="{{ request.urlgen('mediagoblin.auth.login') }}" + href="{{ request.urlgen('mediagoblin.auth.login') }}?next={{ + request.base_url|urlencode }}" {% endif %} class="button_action" id="button_addcomment" title="Add a comment"> {% trans %}Add a comment{% endtrans %} @@ -107,7 +108,10 @@ <input type="submit" value="{% trans %}Add this comment{% endtrans %}" class="button_action" /> {{ csrf_token }} </div> + <input type="hidden" value="{{ request.urlgen('mediagoblin.user_pages.media_preview_comment') }}" id="previewURL" /> + <input type="hidden" value="{% trans %}Comment Preview{% endtrans %}" id="previewText"/> </form> + <div id="comment_preview"></div> {% endif %} <ul style="list-style:none"> {% for comment in comments %} diff --git a/mediagoblin/templates/mediagoblin/utils/comment-subscription.html b/mediagoblin/templates/mediagoblin/utils/comment-subscription.html index bd367e80..75da5e89 100644 --- a/mediagoblin/templates/mediagoblin/utils/comment-subscription.html +++ b/mediagoblin/templates/mediagoblin/utils/comment-subscription.html @@ -16,8 +16,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. #} {%- if request.user %} - {% set subscription = request.notifications.get_comment_subscription( - request.user.id, media.id) %} + {% set subscription = get_comment_subscription(request.user.id, media.id) %} {% if not subscription or not subscription.notify %} <a type="submit" href="{{ request.urlgen('mediagoblin.notifications.subscribe_comments', user=media.get_uploader.username, diff --git a/mediagoblin/tests/auth_configs/persona_appconfig.ini b/mediagoblin/tests/auth_configs/persona_appconfig.ini new file mode 100644 index 00000000..0bd5d634 --- /dev/null +++ b/mediagoblin/tests/auth_configs/persona_appconfig.ini @@ -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/>. +[mediagoblin] +direct_remote_path = /test_static/ +email_sender_address = "notice@mediagoblin.example.org" +email_debug_mode = true + +# TODO: Switch to using an in-memory database +sql_engine = "sqlite:///%(here)s/user_dev/mediagoblin.db" + +# Celery shouldn't be set up by the application as it's setup via +# mediagoblin.init.celery.from_celery +celery_setup_elsewhere = true + +[storage:publicstore] +base_dir = %(here)s/user_dev/media/public +base_url = /mgoblin_media/ + +[storage:queuestore] +base_dir = %(here)s/user_dev/media/queue + +[celery] +CELERY_ALWAYS_EAGER = true +CELERY_RESULT_DBURI = "sqlite:///%(here)s/user_dev/celery.db" +BROKER_HOST = "sqlite:///%(here)s/user_dev/kombu.db" + +[plugins] +[[mediagoblin.plugins.persona]] + diff --git a/mediagoblin/tests/test_celery_setup.py b/mediagoblin/tests/test_celery_setup.py index 0184436a..d60293f9 100644 --- a/mediagoblin/tests/test_celery_setup.py +++ b/mediagoblin/tests/test_celery_setup.py @@ -55,6 +55,6 @@ def test_setup_celery_from_config(): pkg_resources.resource_filename('mediagoblin.tests', 'celery.db')) assert fake_celery_module.BROKER_TRANSPORT == 'sqlalchemy' - assert fake_celery_module.BROKER_HOST == ( + assert fake_celery_module.BROKER_URL == ( 'sqlite:///' + pkg_resources.resource_filename('mediagoblin.tests', 'kombu.db')) diff --git a/mediagoblin/tests/test_http_callback.py b/mediagoblin/tests/test_http_callback.py index a0511af7..64b7ee8f 100644 --- a/mediagoblin/tests/test_http_callback.py +++ b/mediagoblin/tests/test_http_callback.py @@ -23,7 +23,7 @@ from mediagoblin import mg_globals from mediagoblin.tools import processing from mediagoblin.tests.tools import fixture_add_user from mediagoblin.tests.test_submission import GOOD_PNG -from mediagoblin.tests import test_oauth as oauth +from mediagoblin.tests import test_oauth2 as oauth class TestHTTPCallback(object): @@ -44,7 +44,7 @@ class TestHTTPCallback(object): 'password': self.user_password}) def get_access_token(self, client_id, client_secret, code): - response = self.test_app.get('/oauth/access_token', { + response = self.test_app.get('/oauth-2/access_token', { 'code': code, 'client_id': client_id, 'client_secret': client_secret}) diff --git a/mediagoblin/tests/test_mgoblin_app.ini b/mediagoblin/tests/test_mgoblin_app.ini index 535cf1c1..da0dffb9 100644 --- a/mediagoblin/tests/test_mgoblin_app.ini +++ b/mediagoblin/tests/test_mgoblin_app.ini @@ -23,7 +23,7 @@ base_dir = %(here)s/user_dev/media/queue [celery] CELERY_ALWAYS_EAGER = true CELERY_RESULT_DBURI = "sqlite:///%(here)s/user_dev/celery.db" -BROKER_HOST = "sqlite:///%(here)s/user_dev/kombu.db" +BROKER_URL = "sqlite:///%(here)s/test_user_dev/kombu.db" [plugins] [[mediagoblin.plugins.api]] diff --git a/mediagoblin/tests/test_oauth1.py b/mediagoblin/tests/test_oauth1.py new file mode 100644 index 00000000..073c2884 --- /dev/null +++ b/mediagoblin/tests/test_oauth1.py @@ -0,0 +1,166 @@ +# 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 cgi + +import pytest +from urlparse import parse_qs, urlparse + +from oauthlib.oauth1 import Client + +from mediagoblin import mg_globals +from mediagoblin.tools import template, pluginapi +from mediagoblin.tests.tools import fixture_add_user + + +class TestOAuth(object): + + MIME_FORM = "application/x-www-form-urlencoded" + MIME_JSON = "application/json" + + @pytest.fixture(autouse=True) + def setup(self, test_app): + self.test_app = test_app + + self.db = mg_globals.database + + self.pman = pluginapi.PluginManager() + + self.user_password = "AUserPassword123" + self.user = fixture_add_user("OAuthy", self.user_password) + + self.login() + + def login(self): + self.test_app.post( + "/auth/login/", { + "username": self.user.username, + "password": self.user_password}) + + def register_client(self, **kwargs): + """ Regiters a client with the API """ + + kwargs["type"] = "client_associate" + kwargs["application_type"] = kwargs.get("application_type", "native") + return self.test_app.post("/api/client/register", kwargs) + + def test_client_client_register_limited_info(self): + """ Tests that a client can be registered with limited information """ + response = self.register_client() + client_info = response.json + + client = self.db.Client.query.filter_by(id=client_info["client_id"]).first() + + assert response.status_int == 200 + assert client is not None + + def test_client_register_full_info(self): + """ Provides every piece of information possible to register client """ + query = { + "application_name": "Testificate MD", + "application_type": "web", + "contacts": "someone@someplace.com tuteo@tsengeo.lu", + "logo_url": "http://ayrel.com/utral.png", + "redirect_uris": "http://navi-kosman.lu http://gmg-yawne-oeru.lu", + } + + response = self.register_client(**query) + client_info = response.json + + client = self.db.Client.query.filter_by(id=client_info["client_id"]).first() + + assert client is not None + assert client.secret == client_info["client_secret"] + assert client.application_type == query["application_type"] + assert client.redirect_uri == query["redirect_uris"].split() + assert client.logo_url == query["logo_url"] + assert client.contacts == query["contacts"].split() + + + def test_client_update(self): + """ Tests that you can update a client """ + # first we need to register a client + response = self.register_client() + + client_info = response.json + client = self.db.Client.query.filter_by(id=client_info["client_id"]).first() + + # Now update + update_query = { + "type": "client_update", + "application_name": "neytiri", + "contacts": "someone@someplace.com abc@cba.com", + "logo_url": "http://place.com/picture.png", + "application_type": "web", + "redirect_uris": "http://blah.gmg/whatever https://inboxen.org/", + } + + update_response = self.register_client(**update_query) + + assert update_response.status_int == 200 + client_info = update_response.json + client = self.db.Client.query.filter_by(id=client_info["client_id"]).first() + + assert client.secret == client_info["client_secret"] + assert client.application_type == update_query["application_type"] + assert client.application_name == update_query["application_name"] + assert client.contacts == update_query["contacts"].split() + assert client.logo_url == update_query["logo_url"] + assert client.redirect_uri == update_query["redirect_uris"].split() + + def to_authorize_headers(self, data): + headers = "" + for key, value in data.items(): + headers += '{0}="{1}",'.format(key, value) + return {"Authorization": "OAuth " + headers[:-1]} + + def test_request_token(self): + """ Test a request for a request token """ + response = self.register_client() + + client_id = response.json["client_id"] + + endpoint = "/oauth/request_token" + request_query = { + "oauth_consumer_key": client_id, + "oauth_nonce": "abcdefghij", + "oauth_timestamp": 123456789.0, + "oauth_callback": "https://some.url/callback", + } + + headers = self.to_authorize_headers(request_query) + + headers["Content-Type"] = self.MIME_FORM + + response = self.test_app.post(endpoint, headers=headers) + response = cgi.parse_qs(response.body) + + # each element is a list, reduce it to a string + for key, value in response.items(): + response[key] = value[0] + + request_token = self.db.RequestToken.query.filter_by( + token=response["oauth_token"] + ).first() + + client = self.db.Client.query.filter_by(id=client_id).first() + + assert request_token is not None + assert request_token.secret == response["oauth_token_secret"] + assert request_token.client == client.id + assert request_token.used == False + assert request_token.callback == request_query["oauth_callback"] + diff --git a/mediagoblin/tests/test_oauth.py b/mediagoblin/tests/test_oauth2.py index 58cc9928..957f4e65 100644 --- a/mediagoblin/tests/test_oauth.py +++ b/mediagoblin/tests/test_oauth2.py @@ -52,7 +52,7 @@ class TestOAuth(object): def register_client(self, name, client_type, description=None, redirect_uri=''): return self.test_app.post( - '/oauth/client/register', { + '/oauth-2/client/register', { 'name': name, 'description': description, 'type': client_type, @@ -116,7 +116,7 @@ class TestOAuth(object): client_identifier = client.identifier redirect_uri = 'https://foo.example' - response = self.test_app.get('/oauth/authorize', { + response = self.test_app.get('/oauth-2/authorize', { 'client_id': client.identifier, 'scope': 'all', 'redirect_uri': redirect_uri}) @@ -130,7 +130,7 @@ class TestOAuth(object): # Short for client authorization post reponse capr = self.test_app.post( - '/oauth/client/authorize', { + '/oauth-2/client/authorize', { 'client_id': form.client_id.data, 'allow': 'Allow', 'next': form.next.data}) @@ -156,7 +156,7 @@ class TestOAuth(object): client = self.db.OAuthClient.query.filter( self.db.OAuthClient.identifier == unicode(client_id)).first() - token_res = self.test_app.get('/oauth/access_token?client_id={0}&\ + token_res = self.test_app.get('/oauth-2/access_token?client_id={0}&\ code={1}&client_secret={2}'.format(client_id, code, client.secret)) assert token_res.status_int == 200 @@ -184,7 +184,7 @@ code={1}&client_secret={2}'.format(client_id, code, client.secret)) client = self.db.OAuthClient.query.filter( self.db.OAuthClient.identifier == unicode(client_id)).first() - token_res = self.test_app.get('/oauth/access_token?\ + token_res = self.test_app.get('/oauth-2/access_token?\ code={0}&client_secret={1}'.format(code, client.secret)) assert token_res.status_int == 200 @@ -205,7 +205,7 @@ code={0}&client_secret={1}'.format(code, client.secret)) client = self.db.OAuthClient.query.filter( self.db.OAuthClient.identifier == client_id).first() - token_res = self.test_app.get('/oauth/access_token', + token_res = self.test_app.get('/oauth-2/access_token', {'refresh_token': token_data['refresh_token'], 'client_id': client_id, 'client_secret': client.secret diff --git a/mediagoblin/tests/test_persona.py b/mediagoblin/tests/test_persona.py new file mode 100644 index 00000000..919877c9 --- /dev/null +++ b/mediagoblin/tests/test_persona.py @@ -0,0 +1,212 @@ +# 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 urlparse +import pkg_resources +import pytest +import mock + +pytest.importorskip("requests") + +from mediagoblin import mg_globals +from mediagoblin.db.base import Session +from mediagoblin.tests.tools import get_app +from mediagoblin.tools import template + + +# App with plugin enabled +@pytest.fixture() +def persona_plugin_app(request): + return get_app( + request, + mgoblin_config=pkg_resources.resource_filename( + 'mediagoblin.tests.auth_configs', + 'persona_appconfig.ini')) + + +class TestPersonaPlugin(object): + def test_authentication_views(self, persona_plugin_app): + res = persona_plugin_app.get('/auth/login/') + + assert urlparse.urlsplit(res.location)[2] == '/' + + res = persona_plugin_app.get('/auth/register/') + + assert urlparse.urlsplit(res.location)[2] == '/' + + res = persona_plugin_app.get('/auth/persona/login/') + + assert urlparse.urlsplit(res.location)[2] == '/auth/login/' + + res = persona_plugin_app.get('/auth/persona/register/') + + assert urlparse.urlsplit(res.location)[2] == '/auth/login/' + + @mock.patch('mediagoblin.plugins.persona.views._get_response', mock.Mock(return_value=u'test@example.com')) + def _test_registration(): + # No register users + template.clear_test_template_context() + res = persona_plugin_app.post( + '/auth/persona/login/', {}) + + assert 'mediagoblin/auth/register.html' in template.TEMPLATE_TEST_CONTEXT + context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/auth/register.html'] + register_form = context['register_form'] + + assert register_form.email.data == u'test@example.com' + assert register_form.persona_email.data == u'test@example.com' + + template.clear_test_template_context() + res = persona_plugin_app.post( + '/auth/persona/register/', {}) + + assert 'mediagoblin/auth/register.html' in template.TEMPLATE_TEST_CONTEXT + context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/auth/register.html'] + register_form = context['register_form'] + + assert register_form.username.errors == [u'This field is required.'] + assert register_form.email.errors == [u'This field is required.'] + assert register_form.persona_email.errors == [u'This field is required.'] + + # Successful register + template.clear_test_template_context() + res = persona_plugin_app.post( + '/auth/persona/register/', + {'username': 'chris', + 'email': 'chris@example.com', + 'persona_email': 'test@example.com'}) + res.follow() + + assert urlparse.urlsplit(res.location)[2] == '/u/chris/' + assert 'mediagoblin/user_pages/user.html' in template.TEMPLATE_TEST_CONTEXT + + # Try to register same Persona email address + template.clear_test_template_context() + res = persona_plugin_app.post( + '/auth/persona/register/', + {'username': 'chris1', + 'email': 'chris1@example.com', + 'persona_email': 'test@example.com'}) + + assert 'mediagoblin/auth/register.html' in template.TEMPLATE_TEST_CONTEXT + context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/auth/register.html'] + register_form = context['register_form'] + + assert register_form.persona_email.errors == [u'Sorry, an account is already registered to that Persona email.'] + + # Logout + persona_plugin_app.get('/auth/logout/') + + # Get user and detach from session + test_user = mg_globals.database.User.query.filter_by( + username=u'chris').first() + test_user.email_verified = True + test_user.status = u'active' + test_user.save() + test_user = mg_globals.database.User.query.filter_by( + username=u'chris').first() + Session.expunge(test_user) + + # Add another user for _test_edit_persona + persona_plugin_app.post( + '/auth/persona/register/', + {'username': 'chris1', + 'email': 'chris1@example.com', + 'persona_email': 'test1@example.com'}) + + # Log back in + template.clear_test_template_context() + res = persona_plugin_app.post( + '/auth/persona/login/') + res.follow() + + assert urlparse.urlsplit(res.location)[2] == '/' + assert 'mediagoblin/root.html' in template.TEMPLATE_TEST_CONTEXT + + # Make sure user is in the session + context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/root.html'] + session = context['request'].session + assert session['user_id'] == unicode(test_user.id) + + _test_registration() + + @mock.patch('mediagoblin.plugins.persona.views._get_response', mock.Mock(return_value=u'new@example.com')) + def _test_edit_persona(): + # Try and delete only Persona email address + template.clear_test_template_context() + res = persona_plugin_app.post( + '/edit/persona/', + {'email': 'test@example.com'}) + + assert 'mediagoblin/plugins/persona/edit.html' in template.TEMPLATE_TEST_CONTEXT + context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/plugins/persona/edit.html'] + form = context['form'] + + assert form.email.errors == [u"You can't delete your only Persona email address unless you have a password set."] + + template.clear_test_template_context() + res = persona_plugin_app.post( + '/edit/persona/', {}) + + assert 'mediagoblin/plugins/persona/edit.html' in template.TEMPLATE_TEST_CONTEXT + context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/plugins/persona/edit.html'] + form = context['form'] + + assert form.email.errors == [u'This field is required.'] + + # Try and delete Persona not owned by the user + template.clear_test_template_context() + res = persona_plugin_app.post( + '/edit/persona/', + {'email': 'test1@example.com'}) + + assert 'mediagoblin/plugins/persona/edit.html' in template.TEMPLATE_TEST_CONTEXT + context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/plugins/persona/edit.html'] + form = context['form'] + + assert form.email.errors == [u'That Persona email address is not registered to this account.'] + + res = persona_plugin_app.get('/edit/persona/add/') + + assert urlparse.urlsplit(res.location)[2] == '/edit/persona/' + + # Add Persona email address + template.clear_test_template_context() + res = persona_plugin_app.post( + '/edit/persona/add/') + res.follow() + + assert urlparse.urlsplit(res.location)[2] == '/edit/account/' + + # Delete a Persona + res = persona_plugin_app.post( + '/edit/persona/', + {'email': 'test@example.com'}) + res.follow() + + assert urlparse.urlsplit(res.location)[2] == '/edit/account/' + + _test_edit_persona() + + @mock.patch('mediagoblin.plugins.persona.views._get_response', mock.Mock(return_value=u'test1@example.com')) + def _test_add_existing(): + template.clear_test_template_context() + res = persona_plugin_app.post( + '/edit/persona/add/') + res.follow() + + assert urlparse.urlsplit(res.location)[2] == '/edit/persona/' + + _test_add_existing() diff --git a/mediagoblin/tools/crypto.py b/mediagoblin/tools/crypto.py index 1379d21b..917e674c 100644 --- a/mediagoblin/tools/crypto.py +++ b/mediagoblin/tools/crypto.py @@ -14,6 +14,8 @@ # 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 base64 +import string import errno import itsdangerous import logging @@ -24,6 +26,9 @@ from mediagoblin import mg_globals _log = logging.getLogger(__name__) +# produces base64 alphabet +alphabet = string.ascii_letters + "-_" +base = len(alphabet) # Use the system (hardware-based) random number generator if it exists. # -- this optimization is lifted from Django @@ -111,3 +116,13 @@ def get_timed_signer_url(namespace): assert __itsda_secret is not None return itsdangerous.URLSafeTimedSerializer(__itsda_secret, salt=namespace) + +def random_string(length): + """ Returns a URL safe base64 encoded crypographically strong string """ + rstring = "" + for i in range(length): + n = getrandbits(6) # 6 bytes = 2^6 = 64 + n = divmod(n, base)[1] + rstring += alphabet[n] + + return rstring diff --git a/mediagoblin/tools/request.py b/mediagoblin/tools/request.py index ee342eae..d4739039 100644 --- a/mediagoblin/tools/request.py +++ b/mediagoblin/tools/request.py @@ -14,12 +14,18 @@ # 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 mediagoblin.db.models import User _log = logging.getLogger(__name__) +# MIME-Types +form_encoded = "application/x-www-form-urlencoded" +json_encoded = "application/json" + + def setup_user_in_request(request): """ Examine a request and tack on a request.user parameter if that's @@ -36,3 +42,15 @@ def setup_user_in_request(request): # this session. _log.warn("Killing session for user id %r", request.session['user_id']) request.session.delete() + +def decode_request(request): + """ Decodes a request based on MIME-Type """ + data = request.get_data() + + if request.content_type == json_encoded: + data = json.loads(data) + elif request.content_type == form_encoded or request.content_type == "": + data = request.form + else: + data = "" + return data diff --git a/mediagoblin/tools/response.py b/mediagoblin/tools/response.py index 54905a0e..a8cf1df9 100644 --- a/mediagoblin/tools/response.py +++ b/mediagoblin/tools/response.py @@ -14,6 +14,8 @@ # 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 werkzeug.utils from werkzeug.wrappers import Response as wz_Response from mediagoblin.tools.template import render_template @@ -33,7 +35,6 @@ def render_to_response(request, template, context, status=200): render_template(request, template, context), status=status) - def render_error(request, status=500, title=_('Oops!'), err_msg=_('An error occured')): """Render any error page with a given error code, title and text body @@ -46,6 +47,14 @@ def render_error(request, status=500, title=_('Oops!'), {'err_code': status, 'title': title, 'err_msg': err_msg}), status=status) +def render_400(request, err_msg=None): + """ Render a standard 400 page""" + _ = pass_to_ugettext + title = _("Bad Request") + if err_msg is None: + err_msg = _("The request sent to the server is invalid, please double check it") + + return render_error(request, 400, title, err_msg) def render_403(request): """Render a standard 403 page""" @@ -121,3 +130,45 @@ def redirect_obj(request, obj): Requires obj to have a .url_for_self method.""" return redirect(request, location=obj.url_for_self(request.urlgen)) + +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 = wz_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 form_response(data, *args, **kwargs): + """ + Responds using application/x-www-form-urlencoded and returns a werkzeug + Response object with the data argument as the body + and 'application/x-www-form-urlencoded' as the Content-Type. + + Any extra arguments and keyword arguments are passed to the + Response.__init__ method. + """ + + response = wz_Response( + data, + content_type="application/x-www-form-urlencoded", + *args, + **kwargs + ) + + return response diff --git a/mediagoblin/tools/template.py b/mediagoblin/tools/template.py index 615ce129..fa290611 100644 --- a/mediagoblin/tools/template.py +++ b/mediagoblin/tools/template.py @@ -32,7 +32,6 @@ from mediagoblin.tools.timesince import timesince from mediagoblin.meddleware.csrf import render_csrf_form_token - SETUP_JINJA_ENVS = {} @@ -50,6 +49,12 @@ def get_jinja_env(template_loader, locale): if locale in SETUP_JINJA_ENVS: return SETUP_JINJA_ENVS[locale] + # The default config does not require a [jinja2] block. + # You may create one if you wish to enable additional jinja2 extensions, + # see example in config_spec.ini + jinja2_config = mg_globals.global_config.get('jinja2', {}) + local_exts = jinja2_config.get('extensions', []) + # jinja2.StrictUndefined will give exceptions on references # to undefined/unknown variables in templates. template_env = jinja2.Environment( @@ -57,7 +62,7 @@ def get_jinja_env(template_loader, locale): undefined=jinja2.StrictUndefined, extensions=[ 'jinja2.ext.i18n', 'jinja2.ext.autoescape', - TemplateHookExtension]) + TemplateHookExtension] + local_exts) template_env.install_gettext_callables( mg_globals.thread_scope.translations.ugettext, @@ -84,6 +89,16 @@ def get_jinja_env(template_loader, locale): template_env.globals = hook_transform( 'template_global_context', template_env.globals) + #### THIS IS TEMPORARY, PLEASE FIX IT + ## Notifications stuff is not yet a plugin (and we're not sure it will be), + ## but it needs to add stuff to the context. This is THE WRONG WAY TO DO IT + from mediagoblin import notifications + template_env.globals['get_notifications'] = notifications.get_notifications + template_env.globals[ + 'get_notification_count'] = notifications.get_notification_count + template_env.globals[ + 'get_comment_subscription'] = notifications.get_comment_subscription + if exists(locale): SETUP_JINJA_ENVS[locale] = template_env diff --git a/mediagoblin/tools/validator.py b/mediagoblin/tools/validator.py new file mode 100644 index 00000000..03598f9c --- /dev/null +++ b/mediagoblin/tools/validator.py @@ -0,0 +1,46 @@ +# 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 wtforms.validators import Email, URL + +def validate_email(email): + """ + Validates an email + + Returns True if valid and False if invalid + """ + + email_re = Email().regex + result = email_re.match(email) + if result is None: + return False + else: + return result.string + +def validate_url(url): + """ + Validates a url + + Returns True if valid and False if invalid + """ + + url_re = URL().regex + result = url_re.match(url) + if result is None: + return False + else: + return result.string + diff --git a/mediagoblin/user_pages/forms.py b/mediagoblin/user_pages/forms.py index d83338e9..eb786f47 100644 --- a/mediagoblin/user_pages/forms.py +++ b/mediagoblin/user_pages/forms.py @@ -23,7 +23,7 @@ class MediaCommentForm(wtforms.Form): _('Comment'), [wtforms.validators.Required()], description=_(u'You can use ' - u'<a href="http://daringfireball.net/projects/markdown/basics">' + u'<a href="http://daringfireball.net/projects/markdown/basics" target="_blank">' u'Markdown</a> for formatting.')) class ConfirmDeleteForm(wtforms.Form): @@ -47,7 +47,7 @@ class MediaCollectForm(wtforms.Form): collection_description = wtforms.TextAreaField( _('Description of this collection'), description=_("""You can use - <a href="http://daringfireball.net/projects/markdown/basics"> + <a href="http://daringfireball.net/projects/markdown/basics" target="_blank"> Markdown</a> for formatting.""")) class CommentReportForm(wtforms.Form): diff --git a/mediagoblin/user_pages/routing.py b/mediagoblin/user_pages/routing.py index adccda9e..b535bbf2 100644 --- a/mediagoblin/user_pages/routing.py +++ b/mediagoblin/user_pages/routing.py @@ -36,6 +36,10 @@ add_route('mediagoblin.user_pages.media_post_comment', '/u/<string:user>/m/<int:media_id>/comment/add/', 'mediagoblin.user_pages.views:media_post_comment') +add_route('mediagoblin.user_pages.media_preview_comment', + '/ajax/comment/preview/', + 'mediagoblin.user_pages.views:media_preview_comment') + add_route('mediagoblin.user_pages.user_gallery', '/u/<string:user>/gallery/', 'mediagoblin.user_pages.views:user_gallery') diff --git a/mediagoblin/user_pages/views.py b/mediagoblin/user_pages/views.py index 6c0bada2..00fcf282 100644 --- a/mediagoblin/user_pages/views.py +++ b/mediagoblin/user_pages/views.py @@ -16,6 +16,7 @@ import logging import datetime +import json from mediagoblin import messages, mg_globals from mediagoblin.db.models import (MediaEntry, MediaTag, Collection, @@ -23,6 +24,7 @@ from mediagoblin.db.models import (MediaEntry, MediaTag, Collection, CommentReport, MediaReport) from mediagoblin.tools.response import render_to_response, render_404, \ redirect, redirect_obj +from mediagoblin.tools.text import cleaned_markdown_conversion from mediagoblin.tools.translate import pass_to_ugettext as _ from mediagoblin.tools.pagination import Pagination from mediagoblin.user_pages import forms as user_forms @@ -39,6 +41,7 @@ from mediagoblin.decorators import (uses_pagination, get_user_media_entry, from werkzeug.contrib.atom import AtomFeed from werkzeug.exceptions import MethodNotAllowed +from werkzeug.wrappers import Response _log = logging.getLogger(__name__) @@ -159,7 +162,6 @@ def media_home(request, media, page, **kwargs): @get_media_entry_by_id @require_active_login -@user_has_privilege(u'commenter') def media_post_comment(request, media): """ recieves POST from a MediaEntry() comment form, saves the comment. @@ -291,8 +293,13 @@ def media_confirm_delete(request, media): messages.add_message( request, messages.SUCCESS, _('You deleted the media.')) - return redirect(request, "mediagoblin.user_pages.user_home", - user=username) + location = media.url_to_next(request.urlgen) + if not location: + location=media.url_to_prev(request.urlgen) + if not location: + location=request.urlgen("mediagoblin.user_pages.user_home", + user=username) + return redirect(request, location=location) else: messages.add_message( request, messages.ERROR, |