diff options
55 files changed, 1722 insertions, 182 deletions
diff --git a/docs/source/siteadmin/deploying.rst b/docs/source/siteadmin/deploying.rst index 0ee6b5b4..9d7f83c1 100644 --- a/docs/source/siteadmin/deploying.rst +++ b/docs/source/siteadmin/deploying.rst @@ -157,9 +157,10 @@ directory. Modify these commands to reflect your own environment:: mkdir -p /srv/mediagoblin.example.org/ cd /srv/mediagoblin.example.org/ -Clone the MediaGoblin repository:: +Clone the MediaGoblin repository and set up the git submodules:: git clone git://gitorious.org/mediagoblin/mediagoblin.git + git submodule init && git submodule fetch And set up the in-package virtualenv:: @@ -194,7 +195,7 @@ This concludes the initial configuration of the development environment. In the future, when you update your codebase, you should also run:: - ./bin/python setup.py develop --upgrade && ./bin/gmg dbupdate + ./bin/python setup.py develop --upgrade && ./bin/gmg dbupdate && git submodule fetch Note: If you are running an active site, depending on your server configuration, you may need to stop it first or the dbupdate command diff --git a/docs/source/siteadmin/relnotes.rst b/docs/source/siteadmin/relnotes.rst index 7b6d8353..8e7ba396 100644 --- a/docs/source/siteadmin/relnotes.rst +++ b/docs/source/siteadmin/relnotes.rst @@ -21,11 +21,28 @@ This chapter has important information for releases in it. If you're upgrading from a previous release, please read it carefully, or at least skim over it. + +0.4.1 +===== + +This is a bugfix release for 0.4.0. This only implements one major +fix in the newly released document support which prevented the +"conversion via libreoffice" feature. + +If you were running 0.4.0 you can upgrade to v0.4.1 via a simple +switch and restarting mediagoblin/celery with no other actions. + +Otherwise, follow 0.4.0 instructions. + + 0.4.0 ===== **Do this to upgrade** -1. Make sure to run ``bin/gmg dbupdate`` after upgrading. + +1. Make sure to run + ``./bin/python setup.py develop --upgrade && ./bin/gmg dbupdate`` + after upgrading. 2. See "For Theme authors" if you have a custom theme. 3. Note that ``./bin/gmg theme assetlink`` is now just ``./bin/gmg assetlink`` and covers both plugins and assets. @@ -45,6 +62,9 @@ carefully, or at least skim over it. 5. We now use itsdangerous for sessions; if you had any references to beaker in your paste config you can remove them. Again, see the default paste.ini config +6. We also now use git submodules. Please do: + ``git submodule init && git submodule fetch`` + You will need to do this to use the new PDF support. **For theme authors** diff --git a/mediagoblin.ini b/mediagoblin.ini index 035e7bcf..30dacadf 100644 --- a/mediagoblin.ini +++ b/mediagoblin.ini @@ -11,9 +11,10 @@ email_sender_address = "notice@mediagoblin.example.org" ## Uncomment and change to your DB's appropiate setting. ## Default is a local sqlite db "mediagoblin.db". +## Don't forget to run `./bin/gmg dbupdate` after having changed it. # sql_engine = postgresql:///mediagoblin -# set to false to enable sending notices +# Set to false to enable sending notices email_debug_mode = true # Set to false to disable registrations @@ -38,8 +39,8 @@ base_url = /mgoblin_media/ [celery] # Put celery stuff here -# place plugins here---each in their own subsection of [plugins]. see -# documentation for details. +# Place plugins here, each in their own subsection of [plugins]. +# See http://docs.mediagoblin.org/siteadmin/plugins.html for details. [plugins] [[mediagoblin.plugins.geolocation]] [[mediagoblin.plugins.basic_auth]] diff --git a/mediagoblin/_version.py b/mediagoblin/_version.py index 2abc105f..94629775 100644 --- a/mediagoblin/_version.py +++ b/mediagoblin/_version.py @@ -23,4 +23,4 @@ # see http://www.python.org/dev/peps/pep-0386/ -__version__ = "0.4.1.dev" +__version__ = "0.5.0.dev" diff --git a/mediagoblin/app.py b/mediagoblin/app.py index 11464a1f..57e09e49 100644 --- a/mediagoblin/app.py +++ b/mediagoblin/app.py @@ -90,7 +90,7 @@ class MediaGoblinApp(object): setup_plugins() # Set up the database - self.db = setup_database() + self.db = setup_database(app_config['run_migrations']) # Register themes self.theme_registry, self.current_theme = register_themes(app_config) diff --git a/mediagoblin/auth/tools.py b/mediagoblin/auth/tools.py index f3f92414..579775ff 100644 --- a/mediagoblin/auth/tools.py +++ b/mediagoblin/auth/tools.py @@ -116,6 +116,7 @@ def send_fp_verification_email(user, request): """ fp_verification_key = get_timed_signer_url('mail_verification_token') \ .dumps(user.id) + rendered_email = render_template( request, 'mediagoblin/auth/fp_verification_email.txt', {'username': user.username, @@ -199,3 +200,11 @@ def no_auth_logout(request): if not mg_globals.app.auth and 'user_id' in request.session: del request.session['user_id'] request.session.save() + + +def create_basic_user(form): + user = User() + user.username = form.username.data + user.email = form.email.data + user.save() + return user diff --git a/mediagoblin/auth/views.py b/mediagoblin/auth/views.py index 34500f91..d54762b0 100644 --- a/mediagoblin/auth/views.py +++ b/mediagoblin/auth/views.py @@ -14,12 +14,12 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -import uuid from itsdangerous import BadSignature from mediagoblin import messages, mg_globals from mediagoblin.db.models import User from mediagoblin.tools.crypto import get_timed_signer_url +from mediagoblin.decorators import auth_enabled, allow_registration from mediagoblin.tools.response import render_to_response, redirect, render_404 from mediagoblin.tools.translate import pass_to_ugettext as _ from mediagoblin.tools.mail import email_debug_message @@ -31,21 +31,14 @@ from mediagoblin.auth.tools import (send_verification_email, register_user, from mediagoblin import auth +@allow_registration +@auth_enabled def register(request): """The registration view. Note that usernames will always be lowercased. Email domains are lowercased while the first part remains case-sensitive. """ - # Redirects to indexpage if registrations are disabled or no authentication - # is enabled - if not mg_globals.app_config["allow_registration"] or not mg_globals.app.auth: - messages.add_message( - request, - messages.WARNING, - _('Sorry, registration is disabled on this instance.')) - return redirect(request, "index") - 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( @@ -71,20 +64,13 @@ def register(request): 'post_url': request.urlgen('mediagoblin.auth.register')}) +@auth_enabled def login(request): """ MediaGoblin login view. If you provide the POST with 'next', it'll redirect to that view. """ - # Redirects to index page if no authentication is enabled - if not mg_globals.app.auth: - messages.add_message( - request, - messages.WARNING, - _('Sorry, authentication is disabled on this instance.')) - return redirect(request, 'index') - 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( @@ -102,6 +88,8 @@ def login(request): if user: # set up login in session + if login_form.stay_logged_in.data: + request.session['stay_logged_in'] = True request.session['user_id'] = unicode(user.id) request.session.save() diff --git a/mediagoblin/config_spec.ini b/mediagoblin/config_spec.ini index 93643ee1..d2ada163 100644 --- a/mediagoblin/config_spec.ini +++ b/mediagoblin/config_spec.ini @@ -8,6 +8,10 @@ source_link = string(default="https://gitorious.org/mediagoblin/mediagoblin") # database stuff sql_engine = string(default="sqlite:///%(here)s/mediagoblin.db") +# This flag is used during testing to allow use of in-memory SQLite +# databases. It is not recommended to be used on a running instance. +run_migrations = boolean(default=False) + # Where temporary files used in processing and etc are kept workbench_path = string(default="%(here)s/user_dev/media/workbench") diff --git a/mediagoblin/db/base.py b/mediagoblin/db/base.py index 699a503a..c0cefdc2 100644 --- a/mediagoblin/db/base.py +++ b/mediagoblin/db/base.py @@ -24,18 +24,6 @@ Session = scoped_session(sessionmaker()) class GMGTableBase(object): query = Session.query_property() - @classmethod - def find(cls, query_dict): - return cls.query.filter_by(**query_dict) - - @classmethod - def find_one(cls, query_dict): - return cls.query.filter_by(**query_dict).first() - - @classmethod - def one(cls, query_dict): - return cls.find(query_dict).one() - def get(self, key): return getattr(self, key) diff --git a/mediagoblin/db/migrations.py b/mediagoblin/db/migrations.py index 98e8b139..fe4ffb3e 100644 --- a/mediagoblin/db/migrations.py +++ b/mediagoblin/db/migrations.py @@ -307,6 +307,7 @@ def drop_token_related_User_columns(db): db.commit() + class CommentSubscription_v0(declarative_base()): __tablename__ = 'core__comment_subscriptions' id = Column(Integer, primary_key=True) @@ -378,4 +379,3 @@ def pw_hash_nullable(db): constraint.create() db.commit() - diff --git a/mediagoblin/db/util.py b/mediagoblin/db/util.py index 6ffec44d..8431361a 100644 --- a/mediagoblin/db/util.py +++ b/mediagoblin/db/util.py @@ -24,7 +24,7 @@ from mediagoblin.db.models import MediaEntry, Tag, MediaTag, Collection def atomic_update(table, query_dict, update_values): - table.find(query_dict).update(update_values, + table.query.filter_by(**query_dict).update(update_values, synchronize_session=False) Session.commit() diff --git a/mediagoblin/decorators.py b/mediagoblin/decorators.py index f3535fcf..ca7be53c 100644 --- a/mediagoblin/decorators.py +++ b/mediagoblin/decorators.py @@ -18,11 +18,12 @@ from functools import wraps from urlparse import urljoin from werkzeug.exceptions import Forbidden, NotFound -from werkzeug.urls import url_quote from mediagoblin import mg_globals as mgg +from mediagoblin import messages from mediagoblin.db.models import MediaEntry, User from mediagoblin.tools.response import redirect, render_404 +from mediagoblin.tools.translate import pass_to_ugettext as _ def require_active_login(controller): @@ -86,8 +87,8 @@ def user_may_alter_collection(controller): """ @wraps(controller) def wrapper(request, *args, **kwargs): - creator_id = request.db.User.find_one( - {'username': request.matchdict['user']}).id + creator_id = request.db.User.query.filter_by( + username=request.matchdict['user']).first().id if not (request.user.is_admin or request.user.id == creator_id): raise Forbidden() @@ -161,15 +162,15 @@ def get_user_collection(controller): """ @wraps(controller) def wrapper(request, *args, **kwargs): - user = request.db.User.find_one( - {'username': request.matchdict['user']}) + user = request.db.User.query.filter_by( + username=request.matchdict['user']).first() if not user: return render_404(request) - collection = request.db.Collection.find_one( - {'slug': request.matchdict['collection'], - 'creator': user.id}) + collection = request.db.Collection.query.filter_by( + slug=request.matchdict['collection'], + creator=user.id).first() # Still no collection? Okay, 404. if not collection: @@ -186,14 +187,14 @@ def get_user_collection_item(controller): """ @wraps(controller) def wrapper(request, *args, **kwargs): - user = request.db.User.find_one( - {'username': request.matchdict['user']}) + user = request.db.User.query.filter_by( + username=request.matchdict['user']).first() if not user: return render_404(request) - collection_item = request.db.CollectionItem.find_one( - {'id': request.matchdict['collection_item'] }) + collection_item = request.db.CollectionItem.query.filter_by( + id=request.matchdict['collection_item']).first() # Still no collection item? Okay, 404. if not collection_item: @@ -235,3 +236,35 @@ def get_workbench(func): return func(*args, workbench=workbench, **kwargs) return new_func + + +def allow_registration(controller): + """ Decorator for if registration is enabled""" + @wraps(controller) + def wrapper(request, *args, **kwargs): + if not mgg.app_config["allow_registration"]: + messages.add_message( + request, + messages.WARNING, + _('Sorry, registration is disabled on this instance.')) + return redirect(request, "index") + + return controller(request, *args, **kwargs) + + return wrapper + + +def auth_enabled(controller): + """Decorator for if an auth plugin is enabled""" + @wraps(controller) + def wrapper(request, *args, **kwargs): + if not mgg.app.auth: + messages.add_message( + request, + messages.WARNING, + _('Sorry, authentication is disabled on this instance.')) + return redirect(request, 'index') + + return controller(request, *args, **kwargs) + + return wrapper diff --git a/mediagoblin/edit/forms.py b/mediagoblin/edit/forms.py index e0147a0c..85c243a0 100644 --- a/mediagoblin/edit/forms.py +++ b/mediagoblin/edit/forms.py @@ -66,7 +66,6 @@ class EditAccountForm(wtforms.Form): [wtforms.validators.Optional(), normalize_user_or_email_field(allow_user=False)]) wants_comment_notification = wtforms.BooleanField( - label='', description=_("Email me when others comment on my media")) license_preference = wtforms.SelectField( _('License preference'), diff --git a/mediagoblin/edit/views.py b/mediagoblin/edit/views.py index 25a02446..6aa2acd9 100644 --- a/mediagoblin/edit/views.py +++ b/mediagoblin/edit/views.py @@ -236,30 +236,7 @@ def edit_account(request): user.license_preference = form.license_preference.data if form.new_email.data: - new_email = form.new_email.data - users_with_email = User.query.filter_by( - email=new_email).count() - if users_with_email: - form.new_email.errors.append( - _('Sorry, a user with that email address' - ' already exists.')) - else: - verification_key = get_timed_signer_url( - 'mail_verification_token').dumps({ - 'user': user.id, - 'email': new_email}) - - rendered_email = render_template( - request, 'mediagoblin/edit/verification.txt', - {'username': user.username, - 'verification_url': EMAIL_VERIFICATION_TEMPLATE.format( - uri=request.urlgen('mediagoblin.edit.verify_email', - qualified=True), - verification_key=verification_key)}) - - email_debug_message(request) - auth_tools.send_verification_email(user, request, new_email, - rendered_email) + _update_email(request, form, user) if not form.errors: user.save() @@ -328,9 +305,9 @@ def edit_collection(request, collection): form.slug.data, collection.id) # Make sure there isn't already a Collection with this title - existing_collection = request.db.Collection.find_one({ - 'creator': request.user.id, - 'title':form.title.data}) + existing_collection = request.db.Collection.query.filter_by( + creator=request.user.id, + title=form.title.data).first() if existing_collection and existing_collection.id != collection.id: messages.add_message( @@ -365,6 +342,10 @@ def edit_collection(request, collection): @require_active_login def change_pass(request): + # If no password authentication, no need to change your password + if 'pass_auth' not in request.template_env.globals: + return redirect(request, 'index') + form = forms.ChangePassForm(request.form) user = request.user @@ -442,3 +423,32 @@ def verify_email(request): return redirect( request, 'mediagoblin.user_pages.user_home', user=user.username) + + +def _update_email(request, form, user): + new_email = form.new_email.data + users_with_email = User.query.filter_by( + email=new_email).count() + + if users_with_email: + form.new_email.errors.append( + _('Sorry, a user with that email address' + ' already exists.')) + + elif not users_with_email: + verification_key = get_timed_signer_url( + 'mail_verification_token').dumps({ + 'user': user.id, + 'email': new_email}) + + rendered_email = render_template( + request, 'mediagoblin/edit/verification.txt', + {'username': user.username, + 'verification_url': EMAIL_VERIFICATION_TEMPLATE.format( + uri=request.urlgen('mediagoblin.edit.verify_email', + qualified=True), + verification_key=verification_key)}) + + email_debug_message(request) + auth_tools.send_verification_email(user, request, new_email, + rendered_email) diff --git a/mediagoblin/gmg_commands/dbupdate.py b/mediagoblin/gmg_commands/dbupdate.py index 4dfd7e92..00007567 100644 --- a/mediagoblin/gmg_commands/dbupdate.py +++ b/mediagoblin/gmg_commands/dbupdate.py @@ -103,13 +103,25 @@ def run_dbupdate(app_config, global_config): in the future, plugins) """ + # Set up the database + db = setup_connection_and_db_from_config(app_config, migrations=True) + #Run the migrations + run_all_migrations(db, app_config, global_config) + + +def run_all_migrations(db, app_config, global_config): + """ + Initializes or migrates a database that already has a + connection setup and also initializes or migrates all + extensions based on the config files. + + It can be used to initialize an in-memory database for + testing. + """ # Gather information from all media managers / projects dbdatas = gather_database_data( global_config.get('plugins', {}).keys()) - # Set up the database - db = setup_connection_and_db_from_config(app_config, migrations=True) - Session = sessionmaker(bind=db.engine) # Setup media managers for all dbdata, run init/migrate and print info diff --git a/mediagoblin/gmg_commands/import_export.py b/mediagoblin/gmg_commands/import_export.py index d51a1e3e..98ec617d 100644 --- a/mediagoblin/gmg_commands/import_export.py +++ b/mediagoblin/gmg_commands/import_export.py @@ -63,7 +63,7 @@ def _import_media(db, args): # TODO: Add import of queue files queue_cache = BasicFileStorage(args._cache_path['queue']) - for entry in db.MediaEntry.find(): + for entry in db.MediaEntry.query.filter_by(): for name, path in entry.media_files.items(): _log.info('Importing: {0} - {1}'.format( entry.title.encode('ascii', 'replace'), @@ -204,7 +204,7 @@ def _export_media(db, args): # TODO: Add export of queue files queue_cache = BasicFileStorage(args._cache_path['queue']) - for entry in db.MediaEntry.find(): + for entry in db.MediaEntry.query.filter_by(): for name, path in entry.media_files.items(): _log.info(u'Exporting {0} - {1}'.format( entry.title, diff --git a/mediagoblin/gmg_commands/users.py b/mediagoblin/gmg_commands/users.py index 1f329459..e44b0aa9 100644 --- a/mediagoblin/gmg_commands/users.py +++ b/mediagoblin/gmg_commands/users.py @@ -40,9 +40,9 @@ def adduser(args): db = mg_globals.database users_with_username = \ - db.User.find({ - 'username': args.username.lower(), - }).count() + db.User.query.filter_by( + username=args.username.lower() + ).count() if users_with_username: print u'Sorry, a user with that name already exists.' @@ -71,7 +71,8 @@ def makeadmin(args): db = mg_globals.database - user = db.User.one({'username': unicode(args.username.lower())}) + user = db.User.query.filter_by( + username=unicode(args.username.lower())).one() if user: user.is_admin = True user.save() @@ -94,7 +95,8 @@ def changepw(args): db = mg_globals.database - user = db.User.one({'username': unicode(args.username.lower())}) + user = db.User.query.filter_by( + username=unicode(args.username.lower())).one() if user: user.pw_hash = auth.gen_password_hash(args.password) user.save() diff --git a/mediagoblin/init/__init__.py b/mediagoblin/init/__init__.py index 444c624f..e0711416 100644 --- a/mediagoblin/init/__init__.py +++ b/mediagoblin/init/__init__.py @@ -58,16 +58,20 @@ def setup_global_and_app_config(config_path): return global_config, app_config -def setup_database(): +def setup_database(run_migrations=False): app_config = mg_globals.app_config + global_config = mg_globals.global_config # Load all models for media types (plugins, ...) load_models(app_config) - # Set up the database - db = setup_connection_and_db_from_config(app_config) - - check_db_migrations_current(db) + db = setup_connection_and_db_from_config(app_config, run_migrations) + if run_migrations: + #Run the migrations to initialize/update the database. + from mediagoblin.gmg_commands.dbupdate import run_all_migrations + run_all_migrations(db, app_config, global_config) + else: + check_db_migrations_current(db) setup_globals(database=db) diff --git a/mediagoblin/listings/views.py b/mediagoblin/listings/views.py index 35af7148..07dbb3d5 100644 --- a/mediagoblin/listings/views.py +++ b/mediagoblin/listings/views.py @@ -14,6 +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 mediagoblin import mg_globals from mediagoblin.db.models import MediaEntry from mediagoblin.db.util import media_entries_for_tag_slug from mediagoblin.tools.pagination import Pagination @@ -80,6 +81,17 @@ def atom_feed(request): link = request.urlgen('index', qualified=True) feed_title += "for all recent items" + atomlinks = [ + {'href': link, + 'rel': 'alternate', + 'type': 'text/html'}] + + if mg_globals.app_config["push_urls"]: + for push_url in mg_globals.app_config["push_urls"]: + atomlinks.append({ + 'rel': 'hub', + 'href': push_url}) + cursor = cursor.order_by(MediaEntry.created.desc()) cursor = cursor.limit(ATOM_DEFAULT_NR_OF_UPDATED_ITEMS) @@ -87,9 +99,8 @@ def atom_feed(request): feed_title, feed_url=request.url, id=link, - links=[{'href': link, - 'rel': 'alternate', - 'type': 'text/html'}]) + links=atomlinks) + for entry in cursor: feed.add(entry.get('title'), entry.description_html, diff --git a/mediagoblin/meddleware/csrf.py b/mediagoblin/meddleware/csrf.py index 661f0ba2..44d42d75 100644 --- a/mediagoblin/meddleware/csrf.py +++ b/mediagoblin/meddleware/csrf.py @@ -111,7 +111,7 @@ class CsrfMeddleware(BaseMeddleware): httponly=True) # update the Vary header - response.vary = (getattr(response, 'vary', None) or []) + ['Cookie'] + response.vary = list(getattr(response, 'vary', None) or []) + ['Cookie'] def _make_token(self, request): """Generate a new token to use for CSRF protection.""" diff --git a/mediagoblin/media_types/pdf/processing.py b/mediagoblin/media_types/pdf/processing.py index a1eeb307..f35b4376 100644 --- a/mediagoblin/media_types/pdf/processing.py +++ b/mediagoblin/media_types/pdf/processing.py @@ -253,8 +253,8 @@ def process_pdf(proc_state): else: pdf_filename = queued_filename.rsplit('.', 1)[0] + '.pdf' unoconv = where('unoconv') - call(executable=unoconv, - args=[unoconv, '-v', '-f', 'pdf', queued_filename]) + Popen(executable=unoconv, + args=[unoconv, '-v', '-f', 'pdf', queued_filename]).wait() if not os.path.exists(pdf_filename): _log.debug('unoconv failed to convert file to pdf') raise BadMediaFail() diff --git a/mediagoblin/plugins/basic_auth/__init__.py b/mediagoblin/plugins/basic_auth/__init__.py index a2efae92..33a554b0 100644 --- a/mediagoblin/plugins/basic_auth/__init__.py +++ b/mediagoblin/plugins/basic_auth/__init__.py @@ -15,13 +15,14 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. from mediagoblin.plugins.basic_auth import forms as auth_forms from mediagoblin.plugins.basic_auth import tools as auth_tools +from mediagoblin.auth.tools import create_basic_user from mediagoblin.db.models import User from mediagoblin.tools import pluginapi from sqlalchemy import or_ def setup_plugin(): - config = pluginapi.get_config('mediagoblin.pluginapi.basic_auth') + config = pluginapi.get_config('mediagoblin.plugins.basic_auth') def get_user(**kwargs): @@ -38,9 +39,7 @@ def get_user(**kwargs): def create_user(registration_form): user = get_user(username=registration_form.username.data) if not user and 'password' in registration_form: - user = User() - user.username = registration_form.username.data - user.email = registration_form.email.data + user = create_basic_user(registration_form) user.pw_hash = gen_password_hash( registration_form.password.data) user.save() @@ -60,7 +59,10 @@ def gen_password_hash(raw_pass, extra_salt=None): def check_password(raw_pass, stored_hash, extra_salt=None): - return auth_tools.bcrypt_check_password(raw_pass, stored_hash, extra_salt) + if stored_hash: + return auth_tools.bcrypt_check_password(raw_pass, + stored_hash, extra_salt) + return None def auth(): @@ -72,11 +74,6 @@ def append_to_global_context(context): return context -def add_to_form_context(context): - context['pass_auth_link'] = True - return context - - hooks = { 'setup': setup_plugin, 'authentication': auth, @@ -88,8 +85,4 @@ hooks = { 'auth_check_password': check_password, 'auth_fake_login_attempt': auth_tools.fake_login_attempt, 'template_global_context': append_to_global_context, - ('mediagoblin.plugins.openid.register', - 'mediagoblin/auth/register.html'): add_to_form_context, - ('mediagoblin.plugins.openid.login', - 'mediagoblin/auth/login.html'): add_to_form_context, } diff --git a/mediagoblin/plugins/basic_auth/forms.py b/mediagoblin/plugins/basic_auth/forms.py index 72d99dff..6cf01b38 100644 --- a/mediagoblin/plugins/basic_auth/forms.py +++ b/mediagoblin/plugins/basic_auth/forms.py @@ -41,3 +41,6 @@ class LoginForm(wtforms.Form): normalize_user_or_email_field()]) password = wtforms.PasswordField( _('Password')) + stay_logged_in = wtforms.BooleanField( + label='', + description=_('Stay logged in')) diff --git a/mediagoblin/plugins/openid/__init__.py b/mediagoblin/plugins/openid/__init__.py new file mode 100644 index 00000000..ee88808c --- /dev/null +++ b/mediagoblin/plugins/openid/__init__.py @@ -0,0 +1,123 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +import os +import uuid + +from sqlalchemy import or_ + +from mediagoblin.auth.tools import create_basic_user +from mediagoblin.db.models import User +from mediagoblin.plugins.openid.models import OpenIDUserURL +from mediagoblin.tools import pluginapi +from mediagoblin.tools.translate import lazy_pass_to_ugettext as _ + +PLUGIN_DIR = os.path.dirname(__file__) + + +def setup_plugin(): + config = pluginapi.get_config('mediagoblin.plugins.openid') + + routes = [ + ('mediagoblin.plugins.openid.register', + '/auth/openid/register/', + 'mediagoblin.plugins.openid.views:register'), + ('mediagoblin.plugins.openid.login', + '/auth/openid/login/', + 'mediagoblin.plugins.openid.views:login'), + ('mediagoblin.plugins.openid.finish_login', + '/auth/openid/login/finish/', + 'mediagoblin.plugins.openid.views:finish_login'), + ('mediagoblin.plugins.openid.edit', + '/edit/openid/', + 'mediagoblin.plugins.openid.views:start_edit'), + ('mediagoblin.plugins.openid.finish_edit', + '/edit/openid/finish/', + 'mediagoblin.plugins.openid.views:finish_edit'), + ('mediagoblin.plugins.openid.delete', + '/edit/openid/delete/', + 'mediagoblin.plugins.openid.views:delete_openid'), + ('mediagoblin.plugins.openid.finish_delete', + '/edit/openid/delete/finish/', + 'mediagoblin.plugins.openid.views:finish_delete')] + + pluginapi.register_routes(routes) + pluginapi.register_template_path(os.path.join(PLUGIN_DIR, 'templates')) + + pluginapi.register_template_hooks( + {'register_link': 'mediagoblin/plugins/openid/register_link.html', + 'login_link': 'mediagoblin/plugins/openid/login_link.html', + 'edit_link': 'mediagoblin/plugins/openid/edit_link.html'}) + + +def create_user(register_form): + if 'openid' 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 = OpenIDUserURL() + new_entry.openid_url = register_form.openid.data + new_entry.user_id = user.id + new_entry.save() + + return user + + +def extra_validation(register_form): + openid = register_form.openid.data if 'openid' in \ + register_form else None + if openid: + openid_url_exists = OpenIDUserURL.query.filter_by( + openid_url=openid + ).count() + + extra_validation_passes = True + + if openid_url_exists: + register_form.openid.errors.append( + _('Sorry, an account is already registered to that OpenID.')) + extra_validation_passes = False + + return extra_validation_passes + + +def no_pass_redirect(): + return 'openid' + + +def add_to_form_context(context): + context['openid_link'] = True + return context + + +def Auth(): + return True + +hooks = { + 'setup': setup_plugin, + 'authentication': Auth, + 'auth_extra_validation': extra_validation, + 'auth_create_user': create_user, + 'auth_no_pass_redirect': no_pass_redirect, + ('mediagoblin.auth.register', + 'mediagoblin/auth/register.html'): add_to_form_context, +} diff --git a/mediagoblin/plugins/openid/forms.py b/mediagoblin/plugins/openid/forms.py new file mode 100644 index 00000000..f26024bd --- /dev/null +++ b/mediagoblin/plugins/openid/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): + openid = wtforms.HiddenField( + '', + [wtforms.validators.Required()]) + 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)]) + + +class LoginForm(wtforms.Form): + openid = wtforms.TextField( + _('OpenID'), + [wtforms.validators.Required(), + # Can openid's only be urls? + wtforms.validators.URL(message='Please enter a valid url.')]) diff --git a/mediagoblin/plugins/openid/models.py b/mediagoblin/plugins/openid/models.py new file mode 100644 index 00000000..6773f0ad --- /dev/null +++ b/mediagoblin/plugins/openid/models.py @@ -0,0 +1,65 @@ +# 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 OpenIDUserURL(Base): + __tablename__ = "openid__user_urls" + + id = Column(Integer, primary_key=True) + openid_url = Column(Unicode, nullable=False) + user_id = Column(Integer, ForeignKey(User.id), nullable=False) + + # OpenID's are owned by their user, so do the full thing. + user = relationship(User, backref=backref('openid_urls', + cascade='all, delete-orphan')) + + +# OpenID Store Models +class Nonce(Base): + __tablename__ = "openid__nonce" + + server_url = Column(Unicode, primary_key=True) + timestamp = Column(Integer, primary_key=True) + salt = Column(Unicode, primary_key=True) + + def __unicode__(self): + return u'Nonce: %r, %r' % (self.server_url, self.salt) + + +class Association(Base): + __tablename__ = "openid__association" + + server_url = Column(Unicode, primary_key=True) + handle = Column(Unicode, primary_key=True) + secret = Column(Unicode) + issued = Column(Integer) + lifetime = Column(Integer) + assoc_type = Column(Unicode) + + def __unicode__(self): + return u'Association: %r, %r' % (self.server_url, self.handle) + + +MODELS = [ + OpenIDUserURL, + Nonce, + Association +] diff --git a/mediagoblin/plugins/openid/store.py b/mediagoblin/plugins/openid/store.py new file mode 100644 index 00000000..8f9a7012 --- /dev/null +++ b/mediagoblin/plugins/openid/store.py @@ -0,0 +1,127 @@ +# 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 base64 +import time + +from openid.association import Association as OIDAssociation +from openid.store.interface import OpenIDStore +from openid.store import nonce + +from mediagoblin.plugins.openid.models import Association, Nonce + + +class SQLAlchemyOpenIDStore(OpenIDStore): + def __init__(self): + self.max_nonce_age = 6 * 60 * 60 + + def storeAssociation(self, server_url, association): + assoc = Association.query.filter_by( + server_url=server_url, handle=association.handle + ).first() + + if not assoc: + assoc = Association() + assoc.server_url = unicode(server_url) + assoc.handle = association.handle + + # django uses base64 encoding, python-openid uses a blob field for + # secret + assoc.secret = unicode(base64.encodestring(association.secret)) + assoc.issued = association.issued + assoc.lifetime = association.lifetime + assoc.assoc_type = association.assoc_type + assoc.save() + + def getAssociation(self, server_url, handle=None): + assocs = [] + if handle is not None: + assocs = Association.query.filter_by( + server_url=server_url, handle=handle + ) + else: + assocs = Association.query.filter_by( + server_url=server_url + ) + + if assocs.count() == 0: + return None + else: + associations = [] + for assoc in assocs: + association = OIDAssociation( + assoc.handle, base64.decodestring(assoc.secret), + assoc.issued, assoc.lifetime, assoc.assoc_type + ) + if association.getExpiresIn() == 0: + assoc.delete() + else: + associations.append((association.issued, association)) + + if not associations: + return None + associations.sort() + return associations[-1][1] + + def removeAssociation(self, server_url, handle): + assocs = Association.query.filter_by( + server_url=server_url, handle=handle + ).first() + + assoc_exists = True if assocs else False + for assoc in assocs: + assoc.delete() + return assoc_exists + + def useNonce(self, server_url, timestamp, salt): + if abs(timestamp - time.time()) > nonce.SKEW: + return False + + ononce = Nonce.query.filter_by( + server_url=server_url, + timestamp=timestamp, + salt=salt + ).first() + + if ononce: + return False + else: + ononce = Nonce() + ononce.server_url = server_url + ononce.timestamp = timestamp + ononce.salt = salt + ononce.save() + return True + + def cleanupNonces(self, _now=None): + if _now is None: + _now = int(time.time()) + expired = Nonce.query.filter( + Nonce.timestamp < (_now - nonce.SKEW) + ) + count = expired.count() + for each in expired: + each.delete() + return count + + def cleanupAssociations(self): + now = int(time.time()) + assoc = Association.query.all() + count = 0 + for each in assoc: + if (each.lifetime + each.issued) <= now: + each.delete() + count = count + 1 + return count diff --git a/mediagoblin/plugins/openid/templates/mediagoblin/plugins/openid/add.html b/mediagoblin/plugins/openid/templates/mediagoblin/plugins/openid/add.html new file mode 100644 index 00000000..8d308c81 --- /dev/null +++ b/mediagoblin/plugins/openid/templates/mediagoblin/plugins/openid/add.html @@ -0,0 +1,44 @@ +{# +# 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.openid.edit') }}" + method="POST" enctype="multipart/form-data"> + {{ csrf_token }} + <div class="form_box"> + <h1>{% trans %}Add an OpenID{% endtrans %}</h1> + <p> + <a href="{{ request.urlgen('mediagoblin.plugins.openid.delete') }}"> + {% trans %}Delete an OpenID{% endtrans %} + </a> + </p> + {{ wtforms_util.render_divs(form, True) }} + <div class="form_submit_buttons"> + <input type="submit" value="{% trans %}Add{% endtrans %}" class="button_form"/> + </div> + </div> + </form> +{% endblock %} + diff --git a/mediagoblin/plugins/openid/templates/mediagoblin/plugins/openid/delete.html b/mediagoblin/plugins/openid/templates/mediagoblin/plugins/openid/delete.html new file mode 100644 index 00000000..84301b9e --- /dev/null +++ b/mediagoblin/plugins/openid/templates/mediagoblin/plugins/openid/delete.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 %}Delete an OpenID{% endtrans %} — {{ super() }} +{%- endblock %} + +{% block mediagoblin_content %} + <form action="{{ request.urlgen('mediagoblin.plugins.openid.delete') }}" + method="POST" enctype="multipart/form-data"> + {{ csrf_token }} + <div class="form_box"> + <h1>{% trans %}Delete an OpenID{% endtrans %}</h1> + <p> + <a href="{{ request.urlgen('mediagoblin.plugins.openid.edit') }}"> + {% trans %}Add an OpenID{% 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/openid/templates/mediagoblin/plugins/openid/edit_link.html b/mediagoblin/plugins/openid/templates/mediagoblin/plugins/openid/edit_link.html new file mode 100644 index 00000000..2e63e1f8 --- /dev/null +++ b/mediagoblin/plugins/openid/templates/mediagoblin/plugins/openid/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 openid_edit_link %} + <p> + <a href="{{ request.urlgen('mediagoblin.plugins.openid.edit') }}"> + {% trans %}Edit your OpenID's{% endtrans %} + </a> + </p> +{% endblock %} diff --git a/mediagoblin/plugins/openid/templates/mediagoblin/plugins/openid/login.html b/mediagoblin/plugins/openid/templates/mediagoblin/plugins/openid/login.html new file mode 100644 index 00000000..33df7200 --- /dev/null +++ b/mediagoblin/plugins/openid/templates/mediagoblin/plugins/openid/login.html @@ -0,0 +1,65 @@ +{# +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +#} +{% extends "mediagoblin/base.html" %} + +{% import "/mediagoblin/utils/wtforms.html" as wtforms_util %} + +{% block mediagoblin_head %} + <script type="text/javascript" + src="{{ request.staticdirect('/js/autofilledin_password.js') }}"></script> +{% endblock %} + +{% block title -%} + {% trans %}Log in{% endtrans %} — {{ super() }} +{%- endblock %} + +{% block mediagoblin_content %} + <form action="{{ post_url }}" + method="POST" enctype="multipart/form-data"> + {{ csrf_token }} + <div class="form_box"> + <h1>{% trans %}Log in{% endtrans %}</h1> + {% if login_failed %} + <div class="form_field_error"> + {% trans %}Logging in failed!{% endtrans %} + </div> + {% endif %} + {% if allow_registration %} + <p> + {% trans %}Log in to create an account!{% endtrans %} + </p> + {% endif %} + {% if pass_auth is defined %} + <p> + <a href="{{ request.urlgen('mediagoblin.auth.login') }}?{{ request.query_string }}"> + {%- trans %}Or login with a password!{% endtrans %} + </a> + </p> + {% endif %} + {{ wtforms_util.render_divs(login_form, True) }} + <div class="form_submit_buttons"> + <input type="submit" value="{% trans %}Log in{% endtrans %}" class="button_form"/> + </div> + {% if next %} + <input type="hidden" name="next" value="{{ next }}" class="button_form" + style="display: none;"/> + {% endif %} + </div> + </form> +{% endblock %} + diff --git a/mediagoblin/plugins/openid/templates/mediagoblin/plugins/openid/login_link.html b/mediagoblin/plugins/openid/templates/mediagoblin/plugins/openid/login_link.html new file mode 100644 index 00000000..e5e77d01 --- /dev/null +++ b/mediagoblin/plugins/openid/templates/mediagoblin/plugins/openid/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 openid_login_link %} + <p> + <a href="{{ request.urlgen('mediagoblin.plugins.openid.login') }}?{{ request.query_string }}"> + {%- trans %}Or login with OpenID!{% endtrans %} + </a> + </p> +{% endblock %} diff --git a/mediagoblin/plugins/openid/templates/mediagoblin/plugins/openid/register_link.html b/mediagoblin/plugins/openid/templates/mediagoblin/plugins/openid/register_link.html new file mode 100644 index 00000000..9bccb4d8 --- /dev/null +++ b/mediagoblin/plugins/openid/templates/mediagoblin/plugins/openid/register_link.html @@ -0,0 +1,27 @@ +{# +# 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 openid_register_link %} + {% if openid_link is defined %} + <p> + <a href="{{ request.urlgen('mediagoblin.plugins.openid.login') }}"> + {%- trans %}Or register with OpenID!{% endtrans %} + </a> + </p> + {% endif %} +{% endblock %} diff --git a/mediagoblin/plugins/openid/templates/mediagoblin/plugins/openid/request_form.html b/mediagoblin/plugins/openid/templates/mediagoblin/plugins/openid/request_form.html new file mode 100644 index 00000000..68d028d0 --- /dev/null +++ b/mediagoblin/plugins/openid/templates/mediagoblin/plugins/openid/request_form.html @@ -0,0 +1,24 @@ +{# +# 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 mediagoblin_content %} + <div onload="document.getElementById('openid_message').submit()"> + {{ html|safe }} + </div> +{% endblock %} diff --git a/mediagoblin/plugins/openid/views.py b/mediagoblin/plugins/openid/views.py new file mode 100644 index 00000000..b639a4cb --- /dev/null +++ b/mediagoblin/plugins/openid/views.py @@ -0,0 +1,404 @@ +# 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 openid.consumer import consumer +from openid.consumer.discover import DiscoveryFailure +from openid.extensions.sreg import SRegRequest, SRegResponse + +from mediagoblin import mg_globals, messages +from mediagoblin.db.models import User +from mediagoblin.decorators import (auth_enabled, allow_registration, + require_active_login) +from mediagoblin.tools.response import redirect, render_to_response +from mediagoblin.tools.translate import pass_to_ugettext as _ +from mediagoblin.plugins.openid import forms as auth_forms +from mediagoblin.plugins.openid.models import OpenIDUserURL +from mediagoblin.plugins.openid.store import SQLAlchemyOpenIDStore +from mediagoblin.auth.tools import register_user + + +def _start_verification(request, form, return_to, sreg=True): + """ + Start OpenID Verification. + + Returns False if verification fails, otherwise, will return either a + redirect or render_to_response object + """ + openid_url = form.openid.data + c = consumer.Consumer(request.session, SQLAlchemyOpenIDStore()) + + # Try to discover provider + try: + auth_request = c.begin(openid_url) + except DiscoveryFailure: + # Discovery failed, return to login page + form.openid.errors.append( + _('Sorry, the OpenID server could not be found')) + + return False + + host = 'http://' + request.host + + if sreg: + # Ask provider for email and nickname + auth_request.addExtension(SRegRequest(required=['email', 'nickname'])) + + # Do we even need this? + if auth_request is None: + form.openid.errors.append( + _('No OpenID service was found for %s' % openid_url)) + + elif auth_request.shouldSendRedirect(): + # Begin the authentication process as a HTTP redirect + redirect_url = auth_request.redirectURL( + host, return_to) + + return redirect( + request, location=redirect_url) + + else: + # Send request as POST + form_html = auth_request.htmlMarkup( + host, host + return_to, + # Is this necessary? + form_tag_attrs={'id': 'openid_message'}) + + # Beware: this renders a template whose content is a form + # and some javascript to submit it upon page load. Non-JS + # users will have to click the form submit button to + # initiate OpenID authentication. + return render_to_response( + request, + 'mediagoblin/plugins/openid/request_form.html', + {'html': form_html}) + + return False + + +def _finish_verification(request): + """ + Complete OpenID Verification Process. + + If the verification failed, will return false, otherwise, will return + the response + """ + c = consumer.Consumer(request.session, SQLAlchemyOpenIDStore()) + + # Check the response from the provider + response = c.complete(request.args, request.base_url) + if response.status == consumer.FAILURE: + messages.add_message( + request, + messages.WARNING, + _('Verification of %s failed: %s' % + (response.getDisplayIdentifier(), response.message))) + + elif response.status == consumer.SUCCESS: + # Verification was successfull + return response + + elif response.status == consumer.CANCEL: + # Verification canceled + messages.add_message( + request, + messages.WARNING, + _('Verification cancelled')) + + return False + + +def _response_email(response): + """ Gets the email from the OpenID providers response""" + sreg_response = SRegResponse.fromSuccessResponse(response) + if sreg_response and 'email' in sreg_response: + return sreg_response.data['email'] + return None + + +def _response_nickname(response): + """ Gets the nickname from the OpenID providers response""" + sreg_response = SRegResponse.fromSuccessResponse(response) + if sreg_response and 'nickname' in sreg_response: + return sreg_response.data['nickname'] + return None + + +@auth_enabled +def login(request): + """OpenID Login View""" + login_form = auth_forms.LoginForm(request.form) + allow_registration = mg_globals.app_config["allow_registration"] + + # Can't store next in request.GET because of redirects to OpenID provider + # Store it in the session + next = request.GET.get('next') + request.session['next'] = next + + login_failed = False + + if request.method == 'POST' and login_form.validate(): + return_to = request.urlgen( + 'mediagoblin.plugins.openid.finish_login') + + success = _start_verification(request, login_form, return_to) + + if success: + return success + + login_failed = True + + return render_to_response( + request, + 'mediagoblin/plugins/openid/login.html', + {'login_form': login_form, + 'next': request.session.get('next'), + 'login_failed': login_failed, + 'post_url': request.urlgen('mediagoblin.plugins.openid.login'), + 'allow_registration': allow_registration}) + + +@auth_enabled +def finish_login(request): + """Complete OpenID Login Process""" + response = _finish_verification(request) + + if not response: + # Verification failed, redirect to login page. + return redirect(request, 'mediagoblin.plugins.openid.login') + + # Verification was successfull + query = OpenIDUserURL.query.filter_by( + openid_url=response.identity_url, + ).first() + user = query.user if query else None + + if user: + # Set up login in session + request.session['user_id'] = unicode(user.id) + request.session.save() + + if request.session.get('next'): + return redirect(request, location=request.session.pop('next')) + else: + return redirect(request, "index") + else: + # No user, need to register + if not mg_globals.app.auth: + messages.add_message( + request, + messages.WARNING, + _('Sorry, authentication is disabled on this instance.')) + return redirect(request, 'index') + + # Get email and nickname from response + email = _response_email(response) + username = _response_nickname(response) + + register_form = auth_forms.RegistrationForm(request.form, + openid=response.identity_url, + email=email, + username=username) + return render_to_response( + request, + 'mediagoblin/auth/register.html', + {'register_form': register_form, + 'post_url': request.urlgen('mediagoblin.plugins.openid.register')}) + + +@allow_registration +@auth_enabled +def register(request): + """OpenID Registration View""" + if request.method == 'GET': + # Need to connect to openid provider before registering a user to + # get the users openid url. If method is 'GET', then this page was + # acessed without logging in first. + return redirect(request, 'mediagoblin.plugins.openid.login') + + register_form = auth_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.openid.register')}) + + +@require_active_login +def start_edit(request): + """Starts the process of adding an openid url to a users account""" + form = auth_forms.LoginForm(request.form) + + if request.method == 'POST' and form.validate(): + query = OpenIDUserURL.query.filter_by( + openid_url=form.openid.data + ).first() + user = query.user if query else None + + if not user: + return_to = request.urlgen('mediagoblin.plugins.openid.finish_edit') + success = _start_verification(request, form, return_to, False) + + if success: + return success + else: + form.openid.errors.append( + _('Sorry, an account is already registered to that OpenID.')) + + return render_to_response( + request, + 'mediagoblin/plugins/openid/add.html', + {'form': form, + 'post_url': request.urlgen('mediagoblin.plugins.openid.edit')}) + + +@require_active_login +def finish_edit(request): + """Finishes the process of adding an openid url to a user""" + response = _finish_verification(request) + + if not response: + # Verification failed, redirect to add openid page. + return redirect(request, 'mediagoblin.plugins.openid.edit') + + # Verification was successfull + query = OpenIDUserURL.query.filter_by( + openid_url=response.identity_url, + ).first() + user_exists = query.user if query else None + + if user_exists: + # user exists with that openid url, redirect back to edit page + messages.add_message( + request, + messages.WARNING, + _('Sorry, an account is already registered to that OpenID.')) + return redirect(request, 'mediagoblin.plugins.openid.edit') + + else: + # Save openid to user + user = User.query.filter_by( + id=request.session['user_id'] + ).first() + + new_entry = OpenIDUserURL() + new_entry.openid_url = response.identity_url + new_entry.user_id = user.id + new_entry.save() + + messages.add_message( + request, + messages.SUCCESS, + _('Your OpenID url was saved successfully.')) + + return redirect(request, 'mediagoblin.edit.account') + + +@require_active_login +def delete_openid(request): + """View to remove an openid from a users account""" + form = auth_forms.LoginForm(request.form) + + if request.method == 'POST' and form.validate(): + # Check if a user has this openid + query = OpenIDUserURL.query.filter_by( + openid_url=form.openid.data + ) + user = query.first().user if query.first() else None + + if user and user.id == int(request.session['user_id']): + count = len(user.openid_urls) + if not count > 1 and not user.pw_hash: + # Make sure the user has a pw or another OpenID + messages.add_message( + request, + messages.WARNING, + _("You can't delete your only OpenID URL unless you" + " have a password set")) + elif user: + # There is a user, but not the same user who is logged in + form.openid.errors.append( + _('That OpenID is not registered to this account.')) + + if not form.errors and not request.session.get('messages'): + # Okay to continue with deleting openid + return_to = request.urlgen( + 'mediagoblin.plugins.openid.finish_delete') + success = _start_verification(request, form, return_to, False) + + if success: + return success + + return render_to_response( + request, + 'mediagoblin/plugins/openid/delete.html', + {'form': form, + 'post_url': request.urlgen('mediagoblin.plugins.openid.delete')}) + + +@require_active_login +def finish_delete(request): + """Finishes the deletion of an OpenID from an user's account""" + response = _finish_verification(request) + + if not response: + # Verification failed, redirect to delete openid page. + return redirect(request, 'mediagoblin.plugins.openid.delete') + + query = OpenIDUserURL.query.filter_by( + openid_url=response.identity_url + ) + user = query.first().user if query.first() else None + + # Need to check this again because of generic openid urls such as google's + if user and user.id == int(request.session['user_id']): + count = len(user.openid_urls) + if count > 1 or user.pw_hash: + # User has more then one openid or also has a password. + query.first().delete() + + messages.add_message( + request, + messages.SUCCESS, + _('OpenID was successfully removed.')) + + return redirect(request, 'mediagoblin.edit.account') + + elif not count > 1: + messages.add_message( + request, + messages.WARNING, + _("You can't delete your only OpenID URL unless you have a " + "password set")) + + return redirect(request, 'mediagoblin.plugins.openid.delete') + + else: + messages.add_message( + request, + messages.WARNING, + _('That OpenID is not registered to this account.')) + + return redirect(request, 'mediagoblin.plugins.openid.delete') diff --git a/mediagoblin/static/css/base.css b/mediagoblin/static/css/base.css index 8b57584d..84d274d1 100644 --- a/mediagoblin/static/css/base.css +++ b/mediagoblin/static/css/base.css @@ -334,6 +334,10 @@ text-align: center; width: 20px; } +.boolean { + margin-bottom: 8px; + } + textarea#description, textarea#bio { resize: vertical; height: 100px; diff --git a/mediagoblin/submit/views.py b/mediagoblin/submit/views.py index 64e6791b..3f9d5b2d 100644 --- a/mediagoblin/submit/views.py +++ b/mediagoblin/submit/views.py @@ -19,6 +19,7 @@ import mediagoblin.mg_globals as mg_globals from os.path import splitext import logging +import uuid _log = logging.getLogger(__name__) @@ -53,6 +54,10 @@ def submit_start(request): try: filename = request.files['file'].filename + # If the filename contains non ascii generate a unique name + if not all(ord(c) < 128 for c in filename): + filename = unicode(uuid.uuid4()) + splitext(filename)[-1] + # Sniff the submitted media to determine which # media plugin should handle processing media_type, media_manager = sniff_media( @@ -63,7 +68,7 @@ def submit_start(request): entry.media_type = unicode(media_type) entry.title = ( unicode(submit_form.title.data) - or unicode(splitext(filename)[0])) + or unicode(splitext(request.files['file'].filename)[0])) entry.description = unicode(submit_form.description.data) @@ -133,9 +138,9 @@ def add_collection(request, media=None): collection.generate_slug() # Make sure this user isn't duplicating an existing collection - existing_collection = request.db.Collection.find_one({ - 'creator': request.user.id, - 'title':collection.title}) + existing_collection = request.db.Collection.query.filter_by( + creator=request.user.id, + title=collection.title).first() if existing_collection: add_message(request, messages.ERROR, diff --git a/mediagoblin/templates/mediagoblin/auth/login.html b/mediagoblin/templates/mediagoblin/auth/login.html index d9f92557..3329b5d0 100644 --- a/mediagoblin/templates/mediagoblin/auth/login.html +++ b/mediagoblin/templates/mediagoblin/auth/login.html @@ -29,7 +29,7 @@ {%- endblock %} {% block mediagoblin_content %} - <form action="{{ request.urlgen('mediagoblin.auth.login') }}" + <form action="{{ post_url }}" method="POST" enctype="multipart/form-data"> {{ csrf_token }} <div class="form_box"> @@ -41,16 +41,18 @@ {% endif %} {% if allow_registration %} <p> - {% trans %}Don't have an account yet?{% endtrans %} <a href="{{ request.urlgen('mediagoblin.auth.register') }}"> + {% trans %}Don't have an account yet?{% endtrans %} + <a href="{{ request.urlgen('mediagoblin.auth.register') }}"> {%- trans %}Create one here!{% endtrans %}</a> </p> {% endif %} + {% template_hook("login_link") %} {{ wtforms_util.render_divs(login_form, True) }} {% if pass_auth %} - <p> - <a href="{{ request.urlgen('mediagoblin.auth.forgot_password') }}" id="forgot_password"> - {% trans %}Forgot your password?{% endtrans %}</a> - </p> + <p> + <a href="{{ request.urlgen('mediagoblin.auth.forgot_password') }}" id="forgot_password"> + {% trans %}Forgot your password?{% endtrans %}</a> + </p> {% endif %} <div class="form_submit_buttons"> <input type="submit" value="{% trans %}Log in{% endtrans %}" class="button_form"/> diff --git a/mediagoblin/templates/mediagoblin/auth/register.html b/mediagoblin/templates/mediagoblin/auth/register.html index b315975c..a7b8033f 100644 --- a/mediagoblin/templates/mediagoblin/auth/register.html +++ b/mediagoblin/templates/mediagoblin/auth/register.html @@ -30,10 +30,11 @@ {% block mediagoblin_content %} - <form action="{{ request.urlgen('mediagoblin.auth.register') }}" + <form action="{{ post_url }}" method="POST" enctype="multipart/form-data"> <div class="form_box"> <h1>{% trans %}Create an account!{% endtrans %}</h1> + {% template_hook("register_link") %} {{ wtforms_util.render_divs(register_form, True) }} {{ csrf_token }} <div class="form_submit_buttons"> diff --git a/mediagoblin/templates/mediagoblin/edit/change_pass.html b/mediagoblin/templates/mediagoblin/edit/change_pass.html index ff909b07..2a1ffee0 100644 --- a/mediagoblin/templates/mediagoblin/edit/change_pass.html +++ b/mediagoblin/templates/mediagoblin/edit/change_pass.html @@ -39,7 +39,7 @@ Changing {{ username }}'s password {%- endtrans -%} </h1> - {{ wtforms_util.render_divs(form) }} + {{ wtforms_util.render_divs(form, True) }} {{ csrf_token }} <div class="form_submit_buttons"> <input type="submit" value="{% trans %}Save{% endtrans %}" diff --git a/mediagoblin/templates/mediagoblin/edit/edit_account.html b/mediagoblin/templates/mediagoblin/edit/edit_account.html index 98b1b224..51293acb 100644 --- a/mediagoblin/templates/mediagoblin/edit/edit_account.html +++ b/mediagoblin/templates/mediagoblin/edit/edit_account.html @@ -41,13 +41,16 @@ Changing {{ username }}'s account settings {%- endtrans -%} </h1> + {% if pass_auth is defined %} <p> <a href="{{ request.urlgen('mediagoblin.edit.pass') }}"> {% trans %}Change your password.{% endtrans %} </a> </p> + {% endif %} + {% template_hook("edit_link") %} {{ wtforms_util.render_divs(form, True) }} - <div class="form_submit_buttons"> + <div class="form_submit_buttons"> <input type="submit" value="{% trans %}Save changes{% endtrans %}" class="button_form" /> {{ csrf_token }} </div> diff --git a/mediagoblin/templates/mediagoblin/media_displays/stl.html b/mediagoblin/templates/mediagoblin/media_displays/stl.html index a89e0b4f..bc12ce4e 100644 --- a/mediagoblin/templates/mediagoblin/media_displays/stl.html +++ b/mediagoblin/templates/mediagoblin/media_displays/stl.html @@ -108,32 +108,26 @@ window.show_things = function () { <div style="padding: 4px;"> - <a class="button_action" onclick="show('perspective');" - title="{%- trans %}Toggle Rotate{% endtrans -%}"> + <a class="button_action" onclick="show('perspective');"> {%- trans %}Perspective{% endtrans -%} </a> - <a class="button_action" onclick="show('front_view');" - title="{%- trans %}Front{% endtrans -%}"> + <a class="button_action" onclick="show('front_view');"> {%- trans %}Front{% endtrans -%} </a> - <a class="button_action" onclick="show('top_view');" - title="{%- trans %}Top{% endtrans -%}"> + <a class="button_action" onclick="show('top_view');"> {%- trans %}Top{% endtrans -%} </a> - <a class="button_action" onclick="show('side_view');" - title="{%- trans %}Side{% endtrans -%}"> + <a class="button_action" onclick="show('side_view');"> {%- trans %}Side{% endtrans -%} </a> {% if media.media_data.file_type == "stl" %} <a id="webgl_button" class="button_action" - onclick="show_things();" - title="{%- trans %}WebGL{% endtrans -%}"> + onclick="show_things();"> {%- trans %}WebGL{% endtrans -%} </a> {% endif %} <a class="button_action" href="{{ model_download }}" - title="{%- trans %}Download{% endtrans -%}" style="float:right;"> {%- trans %}Download model{% endtrans -%} </a> diff --git a/mediagoblin/templates/mediagoblin/utils/comment-subscription.html b/mediagoblin/templates/mediagoblin/utils/comment-subscription.html index 8ee8c883..bd367e80 100644 --- a/mediagoblin/templates/mediagoblin/utils/comment-subscription.html +++ b/mediagoblin/templates/mediagoblin/utils/comment-subscription.html @@ -21,13 +21,13 @@ {% if not subscription or not subscription.notify %} <a type="submit" href="{{ request.urlgen('mediagoblin.notifications.subscribe_comments', user=media.get_uploader.username, - media=media.slug)}}" + media=media.slug_or_id)}}" class="button_action">Subscribe to comments </a> {% else %} <a type="submit" href="{{ request.urlgen('mediagoblin.notifications.silence_comments', user=media.get_uploader.username, - media=media.slug)}}" + media=media.slug_or_id)}}" class="button_action">Silence comments </a> {% endif %} diff --git a/mediagoblin/templates/mediagoblin/utils/wtforms.html b/mediagoblin/templates/mediagoblin/utils/wtforms.html index a4c33f1a..e079274e 100644 --- a/mediagoblin/templates/mediagoblin/utils/wtforms.html +++ b/mediagoblin/templates/mediagoblin/utils/wtforms.html @@ -34,26 +34,26 @@ {# Generically render a field #} {% macro render_field_div(field, autofocus_first=False) %} - {{- render_label_p(field) }} - <div class="form_field_input"> - {% if autofocus_first %} - {{ field(autofocus=True) }} - {% else %} - {{ field }} - {% endif %} - {%- if field.errors -%} - {% for error in field.errors %} - <p class="form_field_error">{{ error }}</p> - {% endfor %} - {%- endif %} - {%- if field.description %} - {% if field.type == 'BooleanField' %} - <label for="{{ field.label.field_id }}">{{ field.description|safe }}</label> + {% if field.type == 'BooleanField' %} + {{ render_bool(field) }} + {% else %} + {{- render_label_p(field) }} + <div class="form_field_input"> + {% if autofocus_first %} + {{ field(autofocus=True) }} {% else %} - <p class="form_field_description">{{ field.description|safe }}</p> + {{ field }} {% endif %} - {%- endif %} - </div> + {%- if field.errors -%} + {% for error in field.errors %} + <p class="form_field_error">{{ error }}</p> + {% endfor %} + {%- endif %} + {%- if field.description %} + <p class="form_field_description">{{ field.description|safe }}</p> + {%- endif %} + </div> + {% endif %} {%- endmacro %} {# Auto-render a form as a series of divs #} @@ -86,3 +86,19 @@ </tr> {% endfor %} {%- endmacro %} + +{# Render a boolean field #} +{% macro render_bool(field) %} + <div class="boolean"> + <label for="{{ field.label.field_id }}"> + {{ field }}</input> + {{ field.description|safe }} + </label> + {%- if field.errors -%} + {% for error in field.errors %} + <p class="form_field_error">{{ error }}</p> + {% endfor %} + {% endif %} + </div> +{% endmacro %} + diff --git a/mediagoblin/tests/appconfig_context_modified.ini b/mediagoblin/tests/appconfig_context_modified.ini index 80ca69b1..cc6721f5 100644 --- a/mediagoblin/tests/appconfig_context_modified.ini +++ b/mediagoblin/tests/appconfig_context_modified.ini @@ -3,8 +3,9 @@ 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" +#Runs with an in-memory sqlite db for speed. +sql_engine = "sqlite://" +run_migrations = true # Celery shouldn't be set up by the application as it's setup via # mediagoblin.init.celery.from_celery diff --git a/mediagoblin/tests/appconfig_static_plugin.ini b/mediagoblin/tests/appconfig_static_plugin.ini index dc251171..5ce5c5bd 100644 --- a/mediagoblin/tests/appconfig_static_plugin.ini +++ b/mediagoblin/tests/appconfig_static_plugin.ini @@ -3,8 +3,9 @@ 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" +#Runs with an in-memory sqlite db for speed. +sql_engine = "sqlite://" +run_migrations = true # Celery shouldn't be set up by the application as it's setup via # mediagoblin.init.celery.from_celery diff --git a/mediagoblin/tests/auth_configs/openid_appconfig.ini b/mediagoblin/tests/auth_configs/openid_appconfig.ini new file mode 100644 index 00000000..c2bd82fd --- /dev/null +++ b/mediagoblin/tests/auth_configs/openid_appconfig.ini @@ -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/>. +[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.openid]] diff --git a/mediagoblin/tests/test_auth.py b/mediagoblin/tests/test_auth.py index f973ebd8..61503d32 100644 --- a/mediagoblin/tests/test_auth.py +++ b/mediagoblin/tests/test_auth.py @@ -14,7 +14,6 @@ # 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 datetime import pkg_resources import pytest @@ -94,8 +93,8 @@ def test_register_views(test_app): assert 'mediagoblin/user_pages/user.html' in template.TEMPLATE_TEST_CONTEXT ## Make sure user is in place - new_user = mg_globals.database.User.find_one( - {'username': u'happygirl'}) + new_user = mg_globals.database.User.query.filter_by( + username=u'happygirl').first() assert new_user assert new_user.status == u'needs_email_verification' assert new_user.email_verified == False @@ -129,8 +128,8 @@ def test_register_views(test_app): # assert context['verification_successful'] == True # TODO: Would be good to test messages here when we can do so... - new_user = mg_globals.database.User.find_one( - {'username': u'happygirl'}) + new_user = mg_globals.database.User.query.filter_by( + username=u'happygirl').first() assert new_user assert new_user.status == u'needs_email_verification' assert new_user.email_verified == False @@ -143,8 +142,8 @@ def test_register_views(test_app): 'mediagoblin/user_pages/user.html'] # assert context['verification_successful'] == True # TODO: Would be good to test messages here when we can do so... - new_user = mg_globals.database.User.find_one( - {'username': u'happygirl'}) + new_user = mg_globals.database.User.query.filter_by( + username=u'happygirl').first() assert new_user assert new_user.status == u'active' assert new_user.email_verified == True @@ -236,6 +235,7 @@ def test_authentication_views(test_app): # Make a new user test_user = fixture_add_user(active_user=False) + # Get login # --------- test_app.get('/auth/login/') diff --git a/mediagoblin/tests/test_edit.py b/mediagoblin/tests/test_edit.py index acc638d9..d70d0478 100644 --- a/mediagoblin/tests/test_edit.py +++ b/mediagoblin/tests/test_edit.py @@ -190,8 +190,8 @@ class TestUserEdit(object): assert urlparse.urlsplit(res.location)[2] == '/' # Email shouldn't be saved - email_in_db = mg_globals.database.User.find_one( - {'email': 'new@example.com'}) + email_in_db = mg_globals.database.User.query.filter_by( + email='new@example.com').first() email = User.query.filter_by(username='chris').first().email assert email_in_db is None assert email == 'chris@example.com' diff --git a/mediagoblin/tests/test_mgoblin_app.ini b/mediagoblin/tests/test_mgoblin_app.ini index ecd6c9d2..535cf1c1 100644 --- a/mediagoblin/tests/test_mgoblin_app.ini +++ b/mediagoblin/tests/test_mgoblin_app.ini @@ -3,8 +3,9 @@ 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" +#Runs with an in-memory sqlite db for speed. +sql_engine = "sqlite://" +run_migrations = true # tag parsing tags_max_length = 50 @@ -30,5 +31,6 @@ BROKER_HOST = "sqlite:///%(here)s/user_dev/kombu.db" [[mediagoblin.plugins.httpapiauth]] [[mediagoblin.plugins.piwigo]] [[mediagoblin.plugins.basic_auth]] +[[mediagoblin.plugins.openid]] [[mediagoblin.media_types.image]] [[mediagoblin.media_types.pdf]] diff --git a/mediagoblin/tests/test_openid.py b/mediagoblin/tests/test_openid.py new file mode 100644 index 00000000..bba46db8 --- /dev/null +++ b/mediagoblin/tests/test_openid.py @@ -0,0 +1,372 @@ +# 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 + +from openid.consumer.consumer import SuccessResponse + +from mediagoblin import mg_globals +from mediagoblin.db.base import Session +from mediagoblin.db.models import User +from mediagoblin.plugins.openid.models import OpenIDUserURL +from mediagoblin.tests.tools import get_app, fixture_add_user +from mediagoblin.tools import template + + +# App with plugin enabled +@pytest.fixture() +def openid_plugin_app(request): + return get_app( + request, + mgoblin_config=pkg_resources.resource_filename( + 'mediagoblin.tests.auth_configs', + 'openid_appconfig.ini')) + + +class TestOpenIDPlugin(object): + def _setup(self, openid_plugin_app, value=True, edit=False, delete=False): + if value: + response = SuccessResponse(mock.Mock(), mock.Mock()) + if edit or delete: + response.identity_url = u'http://add.myopenid.com' + else: + response.identity_url = u'http://real.myopenid.com' + self._finish_verification = mock.Mock(return_value=response) + else: + self._finish_verification = mock.Mock(return_value=False) + + @mock.patch('mediagoblin.plugins.openid.views._response_email', mock.Mock(return_value=None)) + @mock.patch('mediagoblin.plugins.openid.views._response_nickname', mock.Mock(return_value=None)) + @mock.patch('mediagoblin.plugins.openid.views._finish_verification', self._finish_verification) + def _setup_start(self, openid_plugin_app, edit, delete): + if edit: + self._start_verification = mock.Mock(return_value=openid_plugin_app.post( + '/edit/openid/finish/')) + elif delete: + self._start_verification = mock.Mock(return_value=openid_plugin_app.post( + '/edit/openid/delete/finish/')) + else: + self._start_verification = mock.Mock(return_value=openid_plugin_app.post( + '/auth/openid/login/finish/')) + _setup_start(self, openid_plugin_app, edit, delete) + + def test_bad_login(self, openid_plugin_app): + """ Test that attempts to login with invalid paramaters""" + + # Test GET request for auth/register page + res = openid_plugin_app.get('/auth/register/').follow() + + # Make sure it redirected to the correct place + assert urlparse.urlsplit(res.location)[2] == '/auth/openid/login/' + + # Test GET request for auth/login page + res = openid_plugin_app.get('/auth/login/') + res.follow() + + # Correct redirect? + assert urlparse.urlsplit(res.location)[2] == '/auth/openid/login/' + + # Test GET request for auth/openid/register page + res = openid_plugin_app.get('/auth/openid/register/') + res.follow() + + # Correct redirect? + assert urlparse.urlsplit(res.location)[2] == '/auth/openid/login/' + + # Test GET request for auth/openid/login/finish page + res = openid_plugin_app.get('/auth/openid/login/finish/') + res.follow() + + # Correct redirect? + assert urlparse.urlsplit(res.location)[2] == '/auth/openid/login/' + + # Test GET request for auth/openid/login page + res = openid_plugin_app.get('/auth/openid/login/') + + # Correct place? + assert 'mediagoblin/plugins/openid/login.html' in template.TEMPLATE_TEST_CONTEXT + + # Try to login with an empty form + template.clear_test_template_context() + openid_plugin_app.post( + '/auth/openid/login/', {}) + context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/plugins/openid/login.html'] + form = context['login_form'] + assert form.openid.errors == [u'This field is required.'] + + # Try to login with wrong form values + template.clear_test_template_context() + openid_plugin_app.post( + '/auth/openid/login/', { + 'openid': 'not_a_url.com'}) + context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/plugins/openid/login.html'] + form = context['login_form'] + assert form.openid.errors == [u'Please enter a valid url.'] + + # Should be no users in the db + assert User.query.count() == 0 + + # Phony OpenID URl + template.clear_test_template_context() + openid_plugin_app.post( + '/auth/openid/login/', { + 'openid': 'http://phoney.myopenid.com/'}) + context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/plugins/openid/login.html'] + form = context['login_form'] + assert form.openid.errors == [u'Sorry, the OpenID server could not be found'] + + def test_login(self, openid_plugin_app): + """Tests that test login and registion with openid""" + # Test finish_login redirects correctly when response = False + self._setup(openid_plugin_app, False) + + @mock.patch('mediagoblin.plugins.openid.views._finish_verification', self._finish_verification) + @mock.patch('mediagoblin.plugins.openid.views._start_verification', self._start_verification) + def _test_non_response(): + template.clear_test_template_context() + res = openid_plugin_app.post( + '/auth/openid/login/', { + 'openid': 'http://phoney.myopenid.com/'}) + res.follow() + + # Correct Place? + assert urlparse.urlsplit(res.location)[2] == '/auth/openid/login/' + assert 'mediagoblin/plugins/openid/login.html' in template.TEMPLATE_TEST_CONTEXT + _test_non_response() + + # Test login with new openid + # Need to clear_test_template_context before calling _setup + template.clear_test_template_context() + self._setup(openid_plugin_app) + + @mock.patch('mediagoblin.plugins.openid.views._finish_verification', self._finish_verification) + @mock.patch('mediagoblin.plugins.openid.views._start_verification', self._start_verification) + def _test_new_user(): + openid_plugin_app.post( + '/auth/openid/login/', { + 'openid': u'http://real.myopenid.com'}) + + # Right place? + assert 'mediagoblin/auth/register.html' in template.TEMPLATE_TEST_CONTEXT + context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/auth/register.html'] + register_form = context['register_form'] + + # Register User + res = openid_plugin_app.post( + '/auth/openid/register/', { + 'openid': register_form.openid.data, + 'username': u'chris', + 'email': u'chris@example.com'}) + res.follow() + + # Correct place? + assert urlparse.urlsplit(res.location)[2] == '/u/chris/' + assert 'mediagoblin/user_pages/user.html' in template.TEMPLATE_TEST_CONTEXT + + # No need to test if user is in logged in and verification email + # awaits, since openid uses the register_user function which is + # tested in test_auth + + # Logout User + openid_plugin_app.get('/auth/logout') + + # Get user and detach from session + test_user = mg_globals.database.User.query.filter_by( + username=u'chris').first() + Session.expunge(test_user) + + # Log back in + # Could not get it to work by 'POST'ing to /auth/openid/login/ + template.clear_test_template_context() + res = openid_plugin_app.post( + '/auth/openid/login/finish/', { + 'openid': u'http://real.myopenid.com'}) + 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_new_user() + + # Test register with empty form + template.clear_test_template_context() + openid_plugin_app.post( + '/auth/openid/register/', {}) + context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/auth/register.html'] + register_form = context['register_form'] + + assert register_form.openid.errors == [u'This field is required.'] + assert register_form.email.errors == [u'This field is required.'] + assert register_form.username.errors == [u'This field is required.'] + + # Try to register with existing username and email + template.clear_test_template_context() + openid_plugin_app.post( + '/auth/openid/register/', { + 'openid': 'http://real.myopenid.com', + 'email': 'chris@example.com', + 'username': 'chris'}) + context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/auth/register.html'] + register_form = context['register_form'] + + assert register_form.username.errors == [u'Sorry, a user with that name already exists.'] + assert register_form.email.errors == [u'Sorry, a user with that email address already exists.'] + assert register_form.openid.errors == [u'Sorry, an account is already registered to that OpenID.'] + + def test_add_delete(self, openid_plugin_app): + """Test adding and deleting openids""" + # Add user + test_user = fixture_add_user(password='') + openid = OpenIDUserURL() + openid.openid_url = 'http://real.myopenid.com' + openid.user_id = test_user.id + openid.save() + + # Log user in + template.clear_test_template_context() + self._setup(openid_plugin_app) + + @mock.patch('mediagoblin.plugins.openid.views._finish_verification', self._finish_verification) + @mock.patch('mediagoblin.plugins.openid.views._start_verification', self._start_verification) + def _login_user(): + openid_plugin_app.post( + '/auth/openid/login/finish/', { + 'openid': u'http://real.myopenid.com'}) + + _login_user() + + # Try and delete only OpenID url + template.clear_test_template_context() + res = openid_plugin_app.post( + '/edit/openid/delete/', { + 'openid': 'http://real.myopenid.com'}) + assert 'mediagoblin/plugins/openid/delete.html' in template.TEMPLATE_TEST_CONTEXT + + # Add OpenID to user + # Empty form + template.clear_test_template_context() + res = openid_plugin_app.post( + '/edit/openid/', {}) + context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/plugins/openid/add.html'] + form = context['form'] + assert form.openid.errors == [u'This field is required.'] + + # Try with a bad url + template.clear_test_template_context() + openid_plugin_app.post( + '/edit/openid/', { + 'openid': u'not_a_url.com'}) + context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/plugins/openid/add.html'] + form = context['form'] + assert form.openid.errors == [u'Please enter a valid url.'] + + # Try with a url that's already registered + template.clear_test_template_context() + openid_plugin_app.post( + '/edit/openid/', { + 'openid': 'http://real.myopenid.com'}) + context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/plugins/openid/add.html'] + form = context['form'] + assert form.openid.errors == [u'Sorry, an account is already registered to that OpenID.'] + + # Test adding openid to account + # Need to clear_test_template_context before calling _setup + template.clear_test_template_context() + self._setup(openid_plugin_app, edit=True) + + # Need to remove openid_url from db because it was added at setup + openid = OpenIDUserURL.query.filter_by( + openid_url=u'http://add.myopenid.com') + openid.delete() + + @mock.patch('mediagoblin.plugins.openid.views._finish_verification', self._finish_verification) + @mock.patch('mediagoblin.plugins.openid.views._start_verification', self._start_verification) + def _test_add(): + # Successful add + template.clear_test_template_context() + res = openid_plugin_app.post( + '/edit/openid/', { + 'openid': u'http://add.myopenid.com'}) + res.follow() + + # Correct place? + assert urlparse.urlsplit(res.location)[2] == '/edit/account/' + assert 'mediagoblin/edit/edit_account.html' in template.TEMPLATE_TEST_CONTEXT + + # OpenID Added? + new_openid = mg_globals.database.OpenIDUserURL.query.filter_by( + openid_url=u'http://add.myopenid.com').first() + assert new_openid + + _test_add() + + # Test deleting openid from account + # Need to clear_test_template_context before calling _setup + template.clear_test_template_context() + self._setup(openid_plugin_app, delete=True) + + # Need to add OpenID back to user because it was deleted during + # patch + openid = OpenIDUserURL() + openid.openid_url = 'http://add.myopenid.com' + openid.user_id = test_user.id + openid.save() + + @mock.patch('mediagoblin.plugins.openid.views._finish_verification', self._finish_verification) + @mock.patch('mediagoblin.plugins.openid.views._start_verification', self._start_verification) + def _test_delete(self, test_user): + # Delete openid from user + # Create another user to test deleting OpenID that doesn't belong to them + new_user = fixture_add_user(username='newman') + openid = OpenIDUserURL() + openid.openid_url = 'http://realfake.myopenid.com/' + openid.user_id = new_user.id + openid.save() + + # Try and delete OpenID url that isn't the users + template.clear_test_template_context() + res = openid_plugin_app.post( + '/edit/openid/delete/', { + 'openid': 'http://realfake.myopenid.com/'}) + context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/plugins/openid/delete.html'] + form = context['form'] + assert form.openid.errors == [u'That OpenID is not registered to this account.'] + + # Delete OpenID + # Kind of weird to POST to delete/finish + template.clear_test_template_context() + res = openid_plugin_app.post( + '/edit/openid/delete/finish/', { + 'openid': u'http://add.myopenid.com'}) + res.follow() + + # Correct place? + assert urlparse.urlsplit(res.location)[2] == '/edit/account/' + assert 'mediagoblin/edit/edit_account.html' in template.TEMPLATE_TEST_CONTEXT + + # OpenID deleted? + new_openid = mg_globals.database.OpenIDUserURL.query.filter_by( + openid_url=u'http://add.myopenid.com').first() + assert not new_openid + + _test_delete(self, test_user) diff --git a/mediagoblin/tests/test_submission.py b/mediagoblin/tests/test_submission.py index dd7db12a..ac941063 100644 --- a/mediagoblin/tests/test_submission.py +++ b/mediagoblin/tests/test_submission.py @@ -77,7 +77,7 @@ class TestSubmission: return {'upload_files': [('file', filename)]} def check_comments(self, request, media_id, count): - comments = request.db.MediaComment.find({'media_entry': media_id}) + comments = request.db.MediaComment.query.filter_by(media_entry=media_id) assert count == len(list(comments)) def test_missing_fields(self): @@ -122,7 +122,7 @@ class TestSubmission: assert 'mediagoblin/user_pages/user.html' in context def check_media(self, request, find_data, count=None): - media = MediaEntry.find(find_data) + media = MediaEntry.query.filter_by(**find_data) if count is not None: assert media.count() == count if count == 0: @@ -240,8 +240,8 @@ class TestSubmission: request = context['request'] - media = request.db.MediaEntry.find_one({ - u'title': u'UNIQUE_TITLE_PLS_DONT_CREATE_OTHER_MEDIA_WITH_THIS_TITLE'}) + media = request.db.MediaEntry.query.filter_by( + title=u'UNIQUE_TITLE_PLS_DONT_CREATE_OTHER_MEDIA_WITH_THIS_TITLE').first() assert media.media_type == 'mediagoblin.media_types.image' @@ -252,7 +252,7 @@ class TestSubmission: response, context = self.do_post({'title': title}, do_follow=True, **self.upload_data(filename)) self.check_url(response, '/u/{0}/'.format(self.test_user.username)) - entry = mg_globals.database.MediaEntry.find_one({'title': title}) + entry = mg_globals.database.MediaEntry.query.filter_by(title=title).first() assert entry.state == 'failed' assert entry.fail_error == u'mediagoblin.processing:BadMediaFail' diff --git a/mediagoblin/tests/tools.py b/mediagoblin/tests/tools.py index 2584c62f..98361adc 100644 --- a/mediagoblin/tests/tools.py +++ b/mediagoblin/tests/tools.py @@ -164,7 +164,7 @@ def assert_db_meets_expected(db, expected): for collection_name, collection_data in expected.iteritems(): collection = db[collection_name] for expected_document in collection_data: - document = collection.find_one({'id': expected_document['id']}) + document = collection.query.filter_by(id=expected_document['id']).first() assert document is not None # make sure it exists assert document == expected_document # make sure it matches diff --git a/mediagoblin/tools/session.py b/mediagoblin/tools/session.py index fdc32523..d79afb66 100644 --- a/mediagoblin/tools/session.py +++ b/mediagoblin/tools/session.py @@ -21,6 +21,8 @@ import crypto _log = logging.getLogger(__name__) +MAX_AGE = 30 * 24 * 60 * 60 + class Session(dict): def __init__(self, *args, **kwargs): self.send_new_cookie = False @@ -64,5 +66,10 @@ class SessionManager(object): elif not session: response.delete_cookie(self.cookie_name) else: + if session.get('stay_logged_in', False): + max_age = MAX_AGE + else: + max_age = None + response.set_cookie(self.cookie_name, self.signer.dumps(session), - httponly=True) + max_age=max_age, httponly=True) @@ -45,7 +45,7 @@ setup( 'PasteScript', 'wtforms', 'py-bcrypt', - 'pytest>=2.3', + 'pytest>=2.3.1', 'pytest-xdist', 'werkzeug>=0.7', 'celery==2.5.3', |