Merge branch 'master' of git://gitorious.org/mediagoblin/mediagoblin

This commit is contained in:
Aditi 2013-06-26 22:13:23 +05:30
commit 054ef9a76a
57 changed files with 1739 additions and 330 deletions

View File

@ -69,6 +69,32 @@ example might look like::
This means that when people enable your plugin in their config you'll
be able to provide defaults as well as type validation.
You can access this via the app_config variables in mg_globals, or you
can use a shortcut to get your plugin's config section::
>>> from mediagoblin.tools import pluginapi
# Replace with the path to your plugin.
# (If an external package, it won't be part of mediagoblin.plugins)
>>> floobie_config = pluginapi.get_config('mediagoblin.plugins.floobifier')
>>> floobie_dir = floobie_config['floobie_dir']
# This is the same as the above
>>> from mediagoblin import mg_globals
>>> config = mg_globals.global_config['plugins']['mediagoblin.plugins.floobifier']
>>> floobie_dir = floobie_config['floobie_dir']
A tip: you have access to the `%(here)s` variable in your config,
which is the directory that the user's mediagoblin config is running
out of. So for example, your plugin may need a "floobie" directory to
store floobs in. You could give them a reasonable default that makes
use of the default `user_dev` location, but allow users to override
it, like so::
[plugin_spec]
floobie_dir = string(default="%(here)s/user_dev/floobs/")
Note, this is relative to the user's mediagoblin config directory,
*not* your plugin directory!
Context Hooks
-------------

View File

@ -47,3 +47,4 @@ base_url = /mgoblin_media/
# documentation for details.
[plugins]
[[mediagoblin.plugins.geolocation]]
[[mediagoblin.plugins.basic_auth]]

View File

@ -37,6 +37,8 @@ from mediagoblin.init import (get_jinja_loader, get_staticdirector,
setup_storage)
from mediagoblin.tools.pluginapi import PluginManager, hook_transform
from mediagoblin.tools.crypto import setup_crypto
from mediagoblin.auth.tools import check_auth_enabled, no_auth_logout
from mediagoblin import notifications
_log = logging.getLogger(__name__)
@ -97,6 +99,11 @@ class MediaGoblinApp(object):
PluginManager().get_template_paths()
)
# Check if authentication plugin is enabled and respond accordingly.
self.auth = check_auth_enabled()
if not self.auth:
app_config['allow_comments'] = False
# Set up storage systems
self.public_store, self.queue_store = setup_storage()
@ -186,6 +193,11 @@ class MediaGoblinApp(object):
request.urlgen = build_proxy
# Log user out if authentication_disabled
no_auth_logout(request)
request.notifications = notifications
mg_request.setup_user_in_request(request)
request.controller_name = None

View File

@ -13,3 +13,32 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from mediagoblin.tools.pluginapi import hook_handle, hook_runall
def get_user(**kwargs):
""" Takes a kwarg such as username and returns a user object """
return hook_handle("auth_get_user", **kwargs)
def create_user(register_form):
results = hook_runall("auth_create_user", register_form)
return results[0]
def extra_validation(register_form):
from mediagoblin.auth.tools import basic_extra_validation
extra_validation_passes = basic_extra_validation(register_form)
if False in hook_runall("auth_extra_validation", register_form):
extra_validation_passes = False
return extra_validation_passes
def gen_password_hash(raw_pass, extra_salt=None):
return hook_handle("auth_gen_password_hash", raw_pass, extra_salt)
def check_password(raw_pass, stored_hash, extra_salt=None):
return hook_handle("auth_check_password",
raw_pass, stored_hash, extra_salt)

View File

@ -20,32 +20,6 @@ from mediagoblin.tools.translate import lazy_pass_to_ugettext as _
from mediagoblin.auth.tools import normalize_user_or_email_field
class RegistrationForm(wtforms.Form):
username = wtforms.TextField(
_('Username'),
[wtforms.validators.Required(),
normalize_user_or_email_field(allow_email=False)])
password = wtforms.PasswordField(
_('Password'),
[wtforms.validators.Required(),
wtforms.validators.Length(min=5, max=1024)])
email = wtforms.TextField(
_('Email address'),
[wtforms.validators.Required(),
normalize_user_or_email_field(allow_user=False)])
class LoginForm(wtforms.Form):
username = wtforms.TextField(
_('Username or Email'),
[wtforms.validators.Required(),
normalize_user_or_email_field()])
password = wtforms.PasswordField(
_('Password'),
[wtforms.validators.Required(),
wtforms.validators.Length(min=5, max=1024)])
class ForgotPassForm(wtforms.Form):
username = wtforms.TextField(
_('Username or email'),
@ -58,9 +32,6 @@ class ChangePassForm(wtforms.Form):
'Password',
[wtforms.validators.Required(),
wtforms.validators.Length(min=5, max=1024)])
userid = wtforms.HiddenField(
'',
[wtforms.validators.Required()])
token = wtforms.HiddenField(
'',
[wtforms.validators.Required()])

View File

@ -14,19 +14,18 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import uuid
import logging
import wtforms
from sqlalchemy import or_
from mediagoblin import mg_globals
from mediagoblin.auth import lib as auth_lib
from mediagoblin.tools.crypto import get_timed_signer_url
from mediagoblin.db.models import User
from mediagoblin.tools.mail import (normalize_email, send_email,
email_debug_message)
from mediagoblin.tools.template import render_template
from mediagoblin.tools.translate import lazy_pass_to_ugettext as _
from mediagoblin.tools.pluginapi import hook_handle
from mediagoblin import auth
_log = logging.getLogger(__name__)
@ -62,11 +61,12 @@ def normalize_user_or_email_field(allow_email=True, allow_user=True):
EMAIL_VERIFICATION_TEMPLATE = (
u"http://{host}{uri}?"
u"userid={userid}&token={verification_key}")
u"{uri}?"
u"token={verification_key}")
def send_verification_email(user, request):
def send_verification_email(user, request, email=None,
rendered_email=None):
"""
Send the verification email to users to activate their accounts.
@ -74,19 +74,24 @@ def send_verification_email(user, request):
- user: a user object
- request: the request
"""
rendered_email = render_template(
request, 'mediagoblin/auth/verification_email.txt',
{'username': user.username,
'verification_url': EMAIL_VERIFICATION_TEMPLATE.format(
host=request.host,
uri=request.urlgen('mediagoblin.auth.verify_email'),
userid=unicode(user.id),
verification_key=user.verification_key)})
if not email:
email = user.email
if not rendered_email:
verification_key = get_timed_signer_url('mail_verification_token') \
.dumps(user.id)
rendered_email = render_template(
request, 'mediagoblin/auth/verification_email.txt',
{'username': user.username,
'verification_url': EMAIL_VERIFICATION_TEMPLATE.format(
uri=request.urlgen('mediagoblin.auth.verify_email',
qualified=True),
verification_key=verification_key)})
# TODO: There is no error handling in place
send_email(
mg_globals.app_config['email_sender_address'],
[user.email],
[email],
# TODO
# Due to the distributed nature of GNU MediaGoblin, we should
# find a way to send some additional information about the
@ -96,11 +101,42 @@ def send_verification_email(user, request):
rendered_email)
EMAIL_FP_VERIFICATION_TEMPLATE = (
u"{uri}?"
u"token={fp_verification_key}")
def send_fp_verification_email(user, request):
"""
Send the verification email to users to change their password.
Args:
- user: a user object
- request: the 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,
'verification_url': EMAIL_FP_VERIFICATION_TEMPLATE.format(
uri=request.urlgen('mediagoblin.auth.verify_forgot_password',
qualified=True),
fp_verification_key=fp_verification_key)})
# TODO: There is no error handling in place
send_email(
mg_globals.app_config['email_sender_address'],
[user.email],
'GNU MediaGoblin - Change forgotten password!',
rendered_email)
def basic_extra_validation(register_form, *args):
users_with_username = User.query.filter_by(
username=register_form.data['username']).count()
username=register_form.username.data).count()
users_with_email = User.query.filter_by(
email=register_form.data['email']).count()
email=register_form.email.data).count()
extra_validation_passes = True
@ -118,17 +154,11 @@ def basic_extra_validation(register_form, *args):
def register_user(request, register_form):
""" Handle user registration """
extra_validation_passes = basic_extra_validation(register_form)
extra_validation_passes = auth.extra_validation(register_form)
if extra_validation_passes:
# Create the user
user = User()
user.username = register_form.data['username']
user.email = register_form.data['email']
user.pw_hash = auth_lib.bcrypt_gen_password_hash(
register_form.password.data)
user.verification_key = unicode(uuid.uuid4())
user.save()
user = auth.create_user(register_form)
# log the user in
request.session['user_id'] = unicode(user.id)
@ -143,17 +173,29 @@ def register_user(request, register_form):
return None
def check_login_simple(username, password, username_might_be_email=False):
search = (User.username == username)
if username_might_be_email and ('@' in username):
search = or_(search, User.email == username)
user = User.query.filter(search).first()
def check_login_simple(username, password):
user = auth.get_user(username=username)
if not user:
_log.info("User %r not found", username)
auth_lib.fake_login_attempt()
hook_handle("auth_fake_login_attempt")
return None
if not auth_lib.bcrypt_check_password(password, user.pw_hash):
if not auth.check_password(password, user.pw_hash):
_log.warn("Wrong password for %r", username)
return None
_log.info("Logging %r in", username)
return user
def check_auth_enabled():
if not hook_handle('authentication'):
_log.warning('No authentication is enabled')
return False
else:
return True
def no_auth_logout(request):
"""Log out the user if authentication_disabled, but don't delete the messages"""
if not mg_globals.app.auth and 'user_id' in request.session:
del request.session['user_id']
request.session.save()

View File

@ -15,18 +15,20 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import uuid
import datetime
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.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
from mediagoblin.auth import lib as auth_lib
from mediagoblin.tools.pluginapi import hook_handle
from mediagoblin.auth import forms as auth_forms
from mediagoblin.auth.lib import send_fp_verification_email
from mediagoblin.auth.tools import (send_verification_email, register_user,
send_fp_verification_email,
check_login_simple)
from mediagoblin import auth
def register(request):
@ -35,15 +37,21 @@ def register(request):
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
if not mg_globals.app_config["allow_registration"]:
# 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")
register_form = auth_forms.RegistrationForm(request.form)
if 'pass_auth' not in request.template_env.globals:
redirect_name = hook_handle('auth_no_pass_redirect')
return redirect(request, 'mediagoblin.plugins.{0}.register'.format(
redirect_name))
register_form = hook_handle("auth_get_registration_form", request)
if request.method == 'POST' and register_form.validate():
# TODO: Make sure the user doesn't exist already
@ -59,7 +67,8 @@ def register(request):
return render_to_response(
request,
'mediagoblin/auth/register.html',
{'register_form': register_form})
{'register_form': register_form,
'post_url': request.urlgen('mediagoblin.auth.register')})
def login(request):
@ -68,16 +77,28 @@ def login(request):
If you provide the POST with 'next', it'll redirect to that view.
"""
login_form = auth_forms.LoginForm(request.form)
# 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(
redirect_name))
login_form = hook_handle("auth_get_login_form", request)
login_failed = False
if request.method == 'POST':
username = login_form.data['username']
username = login_form.username.data
if login_form.validate():
user = check_login_simple(username, login_form.password.data, True)
user = check_login_simple(username, login_form.password.data)
if user:
# set up login in session
@ -97,6 +118,7 @@ def login(request):
{'login_form': login_form,
'next': request.GET.get('next') or request.form.get('next'),
'login_failed': login_failed,
'post_url': request.urlgen('mediagoblin.auth.login'),
'allow_registration': mg_globals.app_config["allow_registration"]})
@ -115,16 +137,28 @@ def verify_email(request):
you are lucky :)
"""
# If we don't have userid and token parameters, we can't do anything; 404
if not 'userid' in request.GET or not 'token' in request.GET:
if not 'token' in request.GET:
return render_404(request)
user = User.query.filter_by(id=request.args['userid']).first()
# Catch error if token is faked or expired
try:
token = get_timed_signer_url("mail_verification_token") \
.loads(request.GET['token'], max_age=10*24*3600)
except BadSignature:
messages.add_message(
request,
messages.ERROR,
_('The verification key or user id is incorrect.'))
if user and user.verification_key == unicode(request.GET['token']):
return redirect(
request,
'index')
user = User.query.filter_by(id=int(token)).first()
if user and user.email_verified is False:
user.status = u'active'
user.email_verified = True
user.verification_key = None
user.save()
messages.add_message(
@ -166,9 +200,6 @@ def resend_activation(request):
return redirect(request, "mediagoblin.user_pages.user_home", user=request.user['username'])
request.user.verification_key = unicode(uuid.uuid4())
request.user.save()
email_debug_message(request)
send_verification_email(request.user, request)
@ -188,13 +219,16 @@ def forgot_password(request):
Sends an email with an url to renew forgotten password.
Use GET querystring parameter 'username' to pre-populate the input field
"""
if not 'pass_auth' in request.template_env.globals:
return redirect(request, 'index')
fp_form = auth_forms.ForgotPassForm(request.form,
username=request.args.get('username'))
if not (request.method == 'POST' and fp_form.validate()):
# Either GET request, or invalid form submitted. Display the template
return render_to_response(request,
'mediagoblin/auth/forgot_password.html', {'fp_form': fp_form})
'mediagoblin/auth/forgot_password.html', {'fp_form': fp_form,})
# If we are here: method == POST and form is valid. username casing
# has been sanitized. Store if a user was found by email. We should
@ -235,11 +269,6 @@ def forgot_password(request):
# SUCCESS. Send reminder and return to login page
if user:
user.fp_verification_key = unicode(uuid.uuid4())
user.fp_token_expire = datetime.datetime.now() + \
datetime.timedelta(days=10)
user.save()
email_debug_message(request)
send_fp_verification_email(user, request)
@ -254,31 +283,44 @@ def verify_forgot_password(request):
"""
# get form data variables, and specifically check for presence of token
formdata = _process_for_token(request)
if not formdata['has_userid_and_token']:
if not formdata['has_token']:
return render_404(request)
formdata_token = formdata['vars']['token']
formdata_userid = formdata['vars']['userid']
formdata_vars = formdata['vars']
# check if it's a valid user id
user = User.query.filter_by(id=formdata_userid).first()
if not user:
return render_404(request)
# Catch error if token is faked or expired
try:
token = get_timed_signer_url("mail_verification_token") \
.loads(formdata_vars['token'], max_age=10*24*3600)
except BadSignature:
messages.add_message(
request,
messages.ERROR,
_('The verification key or user id is incorrect.'))
# check if we have a real user and correct token
if ((user and user.fp_verification_key and
user.fp_verification_key == unicode(formdata_token) and
datetime.datetime.now() < user.fp_token_expire
and user.email_verified and user.status == 'active')):
return redirect(
request,
'index')
# check if it's a valid user id
user = User.query.filter_by(id=int(token)).first()
# no user in db
if not user:
messages.add_message(
request, messages.ERROR,
_('The user id is incorrect.'))
return redirect(
request, 'index')
# check if user active and has email verified
if user.email_verified and user.status == 'active':
cp_form = auth_forms.ChangePassForm(formdata_vars)
if request.method == 'POST' and cp_form.validate():
user.pw_hash = auth_lib.bcrypt_gen_password_hash(
user.pw_hash = auth.gen_password_hash(
cp_form.password.data)
user.fp_verification_key = None
user.fp_token_expire = None
user.save()
messages.add_message(
@ -290,12 +332,22 @@ def verify_forgot_password(request):
return render_to_response(
request,
'mediagoblin/auth/change_fp.html',
{'cp_form': cp_form})
{'cp_form': cp_form,})
# in case there is a valid id but no user with that id in the db
# or the token expired
else:
return render_404(request)
if not user.email_verified:
messages.add_message(
request, messages.ERROR,
_('You need to verify your email before you can reset your'
' password.'))
if not user.status == 'active':
messages.add_message(
request, messages.ERROR,
_('You are no longer an active user. Please contact the system'
' admin to reactivate your accoutn.'))
return redirect(
request, 'index')
def _process_for_token(request):
@ -313,7 +365,6 @@ def _process_for_token(request):
formdata = {
'vars': formdata_vars,
'has_userid_and_token':
'userid' in formdata_vars and 'token' in formdata_vars}
'has_token': 'token' in formdata_vars}
return formdata

View File

@ -22,9 +22,10 @@ direct_remote_path = string(default="/mgoblin_static/")
# set to false to enable sending notices
email_debug_mode = boolean(default=True)
email_smtp_use_ssl = boolean(default=False)
email_sender_address = string(default="notice@mediagoblin.example.org")
email_smtp_host = string(default='')
email_smtp_port = integer(default=25)
email_smtp_port = integer(default=0)
email_smtp_user = string(default=None)
email_smtp_pass = string(default=None)

View File

@ -26,7 +26,7 @@ from sqlalchemy.sql import and_
from migrate.changeset.constraint import UniqueConstraint
from mediagoblin.db.migration_tools import RegisterMigration, inspect_table
from mediagoblin.db.models import MediaEntry, Collection, User
from mediagoblin.db.models import MediaEntry, Collection, User, MediaComment
MIGRATIONS = {}
@ -287,3 +287,95 @@ def unique_collections_slug(db):
constraint.create()
db.commit()
@RegisterMigration(11, MIGRATIONS)
def drop_token_related_User_columns(db):
"""
Drop unneeded columns from the User table after switching to using
itsdangerous tokens for email and forgot password verification.
"""
metadata = MetaData(bind=db.bind)
user_table = inspect_table(metadata, 'core__users')
verification_key = user_table.columns['verification_key']
fp_verification_key = user_table.columns['fp_verification_key']
fp_token_expire = user_table.columns['fp_token_expire']
verification_key.drop()
fp_verification_key.drop()
fp_token_expire.drop()
db.commit()
class CommentSubscription_v0(declarative_base()):
__tablename__ = 'core__comment_subscriptions'
id = Column(Integer, primary_key=True)
created = Column(DateTime, nullable=False, default=datetime.datetime.now)
media_entry_id = Column(Integer, ForeignKey(MediaEntry.id), nullable=False)
user_id = Column(Integer, ForeignKey(User.id), nullable=False)
notify = Column(Boolean, nullable=False, default=True)
send_email = Column(Boolean, nullable=False, default=True)
class Notification_v0(declarative_base()):
__tablename__ = 'core__notifications'
id = Column(Integer, primary_key=True)
type = Column(Unicode)
created = Column(DateTime, nullable=False, default=datetime.datetime.now)
user_id = Column(Integer, ForeignKey(User.id), nullable=False,
index=True)
seen = Column(Boolean, default=lambda: False, index=True)
class CommentNotification_v0(Notification_v0):
__tablename__ = 'core__comment_notifications'
id = Column(Integer, ForeignKey(Notification_v0.id), primary_key=True)
subject_id = Column(Integer, ForeignKey(MediaComment.id))
class ProcessingNotification_v0(Notification_v0):
__tablename__ = 'core__processing_notifications'
id = Column(Integer, ForeignKey(Notification_v0.id), primary_key=True)
subject_id = Column(Integer, ForeignKey(MediaEntry.id))
@RegisterMigration(12, MIGRATIONS)
def add_new_notification_tables(db):
metadata = MetaData(bind=db.bind)
user_table = inspect_table(metadata, 'core__users')
mediaentry_table = inspect_table(metadata, 'core__media_entries')
mediacomment_table = inspect_table(metadata, 'core__media_comments')
CommentSubscription_v0.__table__.create(db.bind)
Notification_v0.__table__.create(db.bind)
CommentNotification_v0.__table__.create(db.bind)
ProcessingNotification_v0.__table__.create(db.bind)
@RegisterMigration(13, MIGRATIONS)
def pw_hash_nullable(db):
"""Make pw_hash column nullable"""
metadata = MetaData(bind=db.bind)
user_table = inspect_table(metadata, "core__users")
user_table.c.pw_hash.alter(nullable=True)
# sqlite+sqlalchemy seems to drop this constraint during the
# migration, so we add it back here for now a bit manually.
if db.bind.url.drivername == 'sqlite':
constraint = UniqueConstraint('username', table=user_table)
constraint.create()
db.commit()

View File

@ -31,6 +31,8 @@ import uuid
import re
import datetime
from datetime import datetime
from werkzeug.utils import cached_property
from mediagoblin import mg_globals
@ -288,6 +290,13 @@ class MediaCommentMixin(object):
"""
return cleaned_markdown_conversion(self.content)
def __repr__(self):
return '<{klass} #{id} {author} "{comment}">'.format(
klass=self.__class__.__name__,
id=self.id,
author=self.get_author,
comment=self.content)
class CollectionMixin(GenerateSlugMixin):
def check_slug_used(self, slug):

View File

@ -24,15 +24,17 @@ import datetime
from sqlalchemy import Column, Integer, Unicode, UnicodeText, DateTime, \
Boolean, ForeignKey, UniqueConstraint, PrimaryKeyConstraint, \
SmallInteger
from sqlalchemy.orm import relationship, backref
from sqlalchemy.orm import relationship, backref, with_polymorphic
from sqlalchemy.orm.collections import attribute_mapped_collection
from sqlalchemy.sql.expression import desc
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.util import memoized_property
from mediagoblin.db.extratypes import PathTupleWithSlashes, JSONEncoded
from mediagoblin.db.base import Base, DictReadAttrProxy
from mediagoblin.db.mixin import UserMixin, MediaEntryMixin, MediaCommentMixin, CollectionMixin, CollectionItemMixin
from mediagoblin.db.mixin import UserMixin, MediaEntryMixin, \
MediaCommentMixin, CollectionMixin, CollectionItemMixin
from mediagoblin.tools.files import delete_media_files
from mediagoblin.tools.common import import_component
@ -60,20 +62,17 @@ class User(Base, UserMixin):
# the RFC) and because it would be a mess to implement at this
# point.
email = Column(Unicode, nullable=False)
created = Column(DateTime, nullable=False, default=datetime.datetime.now)
pw_hash = Column(Unicode, nullable=False)
pw_hash = Column(Unicode)
email_verified = Column(Boolean, default=False)
created = Column(DateTime, nullable=False, default=datetime.datetime.now)
status = Column(Unicode, default=u"needs_email_verification", nullable=False)
# Intented to be nullable=False, but migrations would not work for it
# set to nullable=True implicitly.
wants_comment_notification = Column(Boolean, default=True)
license_preference = Column(Unicode)
verification_key = Column(Unicode)
is_admin = Column(Boolean, default=False, nullable=False)
url = Column(Unicode)
bio = Column(UnicodeText) # ??
fp_verification_key = Column(Unicode)
fp_token_expire = Column(DateTime)
## TODO
# plugin data would be in a separate model
@ -392,6 +391,10 @@ class MediaComment(Base, MediaCommentMixin):
backref=backref("posted_comments",
lazy="dynamic",
cascade="all, delete-orphan"))
get_entry = relationship(MediaEntry,
backref=backref("comments",
lazy="dynamic",
cascade="all, delete-orphan"))
# Cascade: Comments are somewhat owned by their MediaEntry.
# So do the full thing.
@ -484,9 +487,103 @@ class ProcessingMetaData(Base):
return DictReadAttrProxy(self)
class CommentSubscription(Base):
__tablename__ = 'core__comment_subscriptions'
id = Column(Integer, primary_key=True)
created = Column(DateTime, nullable=False, default=datetime.datetime.now)
media_entry_id = Column(Integer, ForeignKey(MediaEntry.id), nullable=False)
media_entry = relationship(MediaEntry,
backref=backref('comment_subscriptions',
cascade='all, delete-orphan'))
user_id = Column(Integer, ForeignKey(User.id), nullable=False)
user = relationship(User,
backref=backref('comment_subscriptions',
cascade='all, delete-orphan'))
notify = Column(Boolean, nullable=False, default=True)
send_email = Column(Boolean, nullable=False, default=True)
def __repr__(self):
return ('<{classname} #{id}: {user} {media} notify: '
'{notify} email: {email}>').format(
id=self.id,
classname=self.__class__.__name__,
user=self.user,
media=self.media_entry,
notify=self.notify,
email=self.send_email)
class Notification(Base):
__tablename__ = 'core__notifications'
id = Column(Integer, primary_key=True)
type = Column(Unicode)
created = Column(DateTime, nullable=False, default=datetime.datetime.now)
user_id = Column(Integer, ForeignKey('core__users.id'), nullable=False,
index=True)
seen = Column(Boolean, default=lambda: False, index=True)
user = relationship(
User,
backref=backref('notifications', cascade='all, delete-orphan'))
__mapper_args__ = {
'polymorphic_identity': 'notification',
'polymorphic_on': type
}
def __repr__(self):
return '<{klass} #{id}: {user}: {subject} ({seen})>'.format(
id=self.id,
klass=self.__class__.__name__,
user=self.user,
subject=getattr(self, 'subject', None),
seen='unseen' if not self.seen else 'seen')
class CommentNotification(Notification):
__tablename__ = 'core__comment_notifications'
id = Column(Integer, ForeignKey(Notification.id), primary_key=True)
subject_id = Column(Integer, ForeignKey(MediaComment.id))
subject = relationship(
MediaComment,
backref=backref('comment_notifications', cascade='all, delete-orphan'))
__mapper_args__ = {
'polymorphic_identity': 'comment_notification'
}
class ProcessingNotification(Notification):
__tablename__ = 'core__processing_notifications'
id = Column(Integer, ForeignKey(Notification.id), primary_key=True)
subject_id = Column(Integer, ForeignKey(MediaEntry.id))
subject = relationship(
MediaEntry,
backref=backref('processing_notifications',
cascade='all, delete-orphan'))
__mapper_args__ = {
'polymorphic_identity': 'processing_notification'
}
with_polymorphic(
Notification,
[ProcessingNotification, CommentNotification])
MODELS = [
User, MediaEntry, Tag, MediaTag, MediaComment, Collection, CollectionItem, MediaFile, FileKeynames,
MediaAttachmentFile, ProcessingMetaData]
User, MediaEntry, Tag, MediaTag, MediaComment, Collection, CollectionItem,
MediaFile, FileKeynames, MediaAttachmentFile, ProcessingMetaData,
Notification, CommentNotification, ProcessingNotification,
CommentSubscription]
######################################################

View File

@ -16,9 +16,11 @@
import wtforms
from mediagoblin.tools.text import tag_length_validator, TOO_LONG_TAG_WARNING
from mediagoblin.tools.text import tag_length_validator
from mediagoblin.tools.translate import lazy_pass_to_ugettext as _
from mediagoblin.tools.licenses import licenses_as_choices
from mediagoblin.auth.forms import normalize_user_or_email_field
class EditForm(wtforms.Form):
title = wtforms.TextField(
@ -59,6 +61,13 @@ class EditProfileForm(wtforms.Form):
class EditAccountForm(wtforms.Form):
new_email = wtforms.TextField(
_('New email address'),
[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'),
[
@ -67,8 +76,6 @@ class EditAccountForm(wtforms.Form):
],
choices=licenses_as_choices(),
description=_('This will be your default license on upload forms.'))
wants_comment_notification = wtforms.BooleanField(
label=_("Email me when others comment on my media"))
class EditAttachmentsForm(wtforms.Form):

View File

@ -26,3 +26,5 @@ add_route('mediagoblin.edit.delete_account', '/edit/account/delete/',
'mediagoblin.edit.views:delete_account')
add_route('mediagoblin.edit.pass', '/edit/password/',
'mediagoblin.edit.views:change_pass')
add_route('mediagoblin.edit.verify_email', '/edit/verify_email/',
'mediagoblin.edit.views:verify_email')

View File

@ -16,25 +16,31 @@
from datetime import datetime
from itsdangerous import BadSignature
from werkzeug.exceptions import Forbidden
from werkzeug.utils import secure_filename
from mediagoblin import messages
from mediagoblin import mg_globals
from mediagoblin.auth import lib as auth_lib
from mediagoblin import auth
from mediagoblin.auth import tools as auth_tools
from mediagoblin.edit import forms
from mediagoblin.edit.lib import may_edit_media
from mediagoblin.decorators import (require_active_login, active_user_from_url,
get_media_entry_by_id,
user_may_alter_collection, get_user_collection)
from mediagoblin.tools.response import render_to_response, \
redirect, redirect_obj
get_media_entry_by_id, user_may_alter_collection,
get_user_collection)
from mediagoblin.tools.crypto import get_timed_signer_url
from mediagoblin.tools.mail import email_debug_message
from mediagoblin.tools.response import (render_to_response,
redirect, redirect_obj, render_404)
from mediagoblin.tools.translate import pass_to_ugettext as _
from mediagoblin.tools.template import render_template
from mediagoblin.tools.text import (
convert_to_tag_list_of_dicts, media_tags_as_string)
from mediagoblin.tools.url import slugify
from mediagoblin.db.util import check_media_slug_used, check_collection_slug_used
from mediagoblin.db.models import User
import mimetypes
@ -212,6 +218,10 @@ def edit_profile(request, url_user=None):
{'user': user,
'form': form})
EMAIL_VERIFICATION_TEMPLATE = (
u'{uri}?'
u'token={verification_key}')
@require_active_login
def edit_account(request):
@ -220,27 +230,45 @@ def edit_account(request):
wants_comment_notification=user.wants_comment_notification,
license_preference=user.license_preference)
if request.method == 'POST':
form_validated = form.validate()
if request.method == 'POST' and form.validate():
user.wants_comment_notification = form.wants_comment_notification.data
if form_validated and \
form.wants_comment_notification.validate(form):
user.wants_comment_notification = \
form.wants_comment_notification.data
user.license_preference = form.license_preference.data
if form_validated and \
form.license_preference.validate(form):
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})
if form_validated and not form.errors:
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)
if not form.errors:
user.save()
messages.add_message(request,
messages.SUCCESS,
_("Account settings saved"))
messages.SUCCESS,
_("Account settings saved"))
return redirect(request,
'mediagoblin.user_pages.user_home',
user=user.username)
'mediagoblin.user_pages.user_home',
user=user.username)
return render_to_response(
request,
@ -342,7 +370,7 @@ def change_pass(request):
if request.method == 'POST' and form.validate():
if not auth_lib.bcrypt_check_password(
if not auth.check_password(
form.old_password.data, user.pw_hash):
form.old_password.errors.append(
_('Wrong password'))
@ -354,7 +382,7 @@ def change_pass(request):
'user': user})
# Password matches
user.pw_hash = auth_lib.bcrypt_gen_password_hash(
user.pw_hash = auth.gen_password_hash(
form.new_password.data)
user.save()
@ -369,3 +397,48 @@ def change_pass(request):
'mediagoblin/edit/change_pass.html',
{'form': form,
'user': user})
def verify_email(request):
"""
Email verification view for changing email address
"""
# If no token, we can't do anything
if not 'token' in request.GET:
return render_404(request)
# Catch error if token is faked or expired
token = None
try:
token = get_timed_signer_url("mail_verification_token") \
.loads(request.GET['token'], max_age=10*24*3600)
except BadSignature:
messages.add_message(
request,
messages.ERROR,
_('The verification key or user id is incorrect.'))
return redirect(
request,
'index')
user = User.query.filter_by(id=int(token['user'])).first()
if user:
user.email = token['email']
user.save()
messages.add_message(
request,
messages.SUCCESS,
_('Your email address has been verified.'))
else:
messages.add_message(
request,
messages.ERROR,
_('The verification key or user id is incorrect.'))
return redirect(
request, 'mediagoblin.user_pages.user_home',
user=user.username)

View File

@ -15,7 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from mediagoblin.gmg_commands import util as commands_util
from mediagoblin.auth import lib as auth_lib
from mediagoblin import auth
from mediagoblin import mg_globals
def adduser_parser_setup(subparser):
@ -52,7 +52,7 @@ def adduser(args):
entry = db.User()
entry.username = unicode(args.username.lower())
entry.email = unicode(args.email)
entry.pw_hash = auth_lib.bcrypt_gen_password_hash(args.password)
entry.pw_hash = auth.gen_password_hash(args.password)
entry.status = u'active'
entry.email_verified = True
entry.save()
@ -96,7 +96,7 @@ def changepw(args):
user = db.User.one({'username': unicode(args.username.lower())})
if user:
user.pw_hash = auth_lib.bcrypt_gen_password_hash(args.password)
user.pw_hash = auth.gen_password_hash(args.password)
user.save()
print 'Password successfully changed'
else:

View File

@ -16,12 +16,18 @@
import os
import sys
import logging
from celery import Celery
from mediagoblin.tools.pluginapi import hook_runall
MANDATORY_CELERY_IMPORTS = ['mediagoblin.processing.task']
_log = logging.getLogger(__name__)
MANDATORY_CELERY_IMPORTS = [
'mediagoblin.processing.task',
'mediagoblin.notifications.task']
DEFAULT_SETTINGS_MODULE = 'mediagoblin.init.celery.dummy_settings_module'
@ -97,3 +103,13 @@ def setup_celery_from_config(app_config, global_config,
if set_environ:
os.environ['CELERY_CONFIG_MODULE'] = settings_module
# Replace the default celery.current_app.conf if celery has already been
# initiated
from celery import current_app
_log.info('Setting celery configuration from object "{0}"'.format(
settings_module))
current_app.config_from_object(this_module)
_log.debug('Celery broker host: {0}'.format(current_app.conf['BROKER_HOST']))

View File

@ -46,7 +46,7 @@ def sniff_handler(media_file, **kw):
if kw.get('media') is not None:
name, ext = os.path.splitext(kw['media'].filename)
clean_ext = ext[1:].lower()
if clean_ext in SUPPORTED_FILETYPES:
_log.info('Found file extension in supported filetypes')
return True

View File

@ -22,9 +22,15 @@ import logging
import urllib
import multiprocessing
import gobject
old_argv = sys.argv
sys.argv = []
import pygst
pygst.require('0.10')
import gst
sys.argv = old_argv
import struct
try:
from PIL import Image

View File

@ -0,0 +1,141 @@
# GNU MediaGoblin -- federated, autonomous media hosting
# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging
from mediagoblin.db.models import Notification, \
CommentNotification, CommentSubscription
from mediagoblin.notifications.task import email_notification_task
from mediagoblin.notifications.tools import generate_comment_message
_log = logging.getLogger(__name__)
def trigger_notification(comment, media_entry, request):
'''
Send out notifications about a new comment.
'''
subscriptions = CommentSubscription.query.filter_by(
media_entry_id=media_entry.id).all()
for subscription in subscriptions:
if not subscription.notify:
continue
if comment.get_author == subscription.user:
continue
cn = CommentNotification(
user_id=subscription.user_id,
subject_id=comment.id)
cn.save()
if subscription.send_email:
message = generate_comment_message(
subscription.user,
comment,
media_entry,
request)
email_notification_task.apply_async([cn.id, message])
def mark_notification_seen(notification):
if notification:
notification.seen = True
notification.save()
def mark_comment_notification_seen(comment_id, user):
notification = CommentNotification.query.filter_by(
user_id=user.id,
subject_id=comment_id).first()
_log.debug('Marking {0} as seen.'.format(notification))
mark_notification_seen(notification)
def get_comment_subscription(user_id, media_entry_id):
return CommentSubscription.query.filter_by(
user_id=user_id,
media_entry_id=media_entry_id).first()
def add_comment_subscription(user, media_entry):
'''
Create a comment subscription for a User on a MediaEntry.
Uses the User's wants_comment_notification to set email notifications for
the subscription to enabled/disabled.
'''
cn = get_comment_subscription(user.id, media_entry.id)
if not cn:
cn = CommentSubscription(
user_id=user.id,
media_entry_id=media_entry.id)
cn.notify = True
if not user.wants_comment_notification:
cn.send_email = False
cn.save()
def silence_comment_subscription(user, media_entry):
'''
Silence a subscription so that the user is never notified in any way about
new comments on an entry
'''
cn = get_comment_subscription(user.id, media_entry.id)
if cn:
cn.notify = False
cn.send_email = False
cn.save()
def remove_comment_subscription(user, media_entry):
cn = get_comment_subscription(user.id, media_entry.id)
if cn:
cn.delete()
NOTIFICATION_FETCH_LIMIT = 100
def get_notifications(user_id, only_unseen=True):
query = Notification.query.filter_by(user_id=user_id)
if only_unseen:
query = query.filter_by(seen=False)
notifications = query.limit(
NOTIFICATION_FETCH_LIMIT).all()
return notifications
def get_notification_count(user_id, only_unseen=True):
query = Notification.query.filter_by(user_id=user_id)
if only_unseen:
query = query.filter_by(seen=False)
count = query.count()
return count

View File

@ -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/>.
from mediagoblin.tools.routing import add_route
add_route('mediagoblin.notifications.subscribe_comments',
'/u/<string:user>/m/<string:media>/notifications/subscribe/comments/',
'mediagoblin.notifications.views:subscribe_comments')
add_route('mediagoblin.notifications.silence_comments',
'/u/<string:user>/m/<string:media>/notifications/silence/',
'mediagoblin.notifications.views:silence_comments')

View File

@ -0,0 +1,46 @@
# GNU MediaGoblin -- federated, autonomous media hosting
# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging
from celery import registry
from celery.task import Task
from mediagoblin.tools.mail import send_email
from mediagoblin.db.models import CommentNotification
_log = logging.getLogger(__name__)
class EmailNotificationTask(Task):
'''
Celery notification task.
This task is executed by celeryd to offload long-running operations from
the web server.
'''
def run(self, notification_id, message):
cn = CommentNotification.query.filter_by(id=notification_id).first()
_log.info('Sending notification email about {0}'.format(cn))
return send_email(
message['from'],
[message['to']],
message['subject'],
message['body'])
email_notification_task = registry.tasks[EmailNotificationTask.name]

View File

@ -0,0 +1,55 @@
# GNU MediaGoblin -- federated, autonomous media hosting
# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from mediagoblin.tools.template import render_template
from mediagoblin.tools.translate import pass_to_ugettext as _
from mediagoblin import mg_globals
def generate_comment_message(user, comment, media, request):
"""
Sends comment email to user when a comment is made on their media.
Args:
- user: the user object to whom the email is sent
- comment: the comment object referencing user's media
- media: the media object the comment is about
- request: the request
"""
comment_url = request.urlgen(
'mediagoblin.user_pages.media_home.view_comment',
comment=comment.id,
user=media.get_uploader.username,
media=media.slug_or_id,
qualified=True) + '#comment'
comment_author = comment.get_author.username
rendered_email = render_template(
request, 'mediagoblin/user_pages/comment_email.txt',
{'username': user.username,
'comment_author': comment_author,
'comment_content': comment.content,
'comment_url': comment_url})
return {
'from': mg_globals.app_config['email_sender_address'],
'to': user.email,
'subject': '{instance_title} - {comment_author} '.format(
comment_author=comment_author,
instance_title=mg_globals.app_config['html_title']) \
+ _('commented on your post'),
'body': rendered_email}

View File

@ -0,0 +1,54 @@
# GNU MediaGoblin -- federated, autonomous media hosting
# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from mediagoblin.tools.response import render_to_response, render_404, redirect
from mediagoblin.tools.translate import pass_to_ugettext as _
from mediagoblin.decorators import (uses_pagination, get_user_media_entry,
get_media_entry_by_id,
require_active_login, user_may_delete_media, user_may_alter_collection,
get_user_collection, get_user_collection_item, active_user_from_url)
from mediagoblin import messages
from mediagoblin.notifications import add_comment_subscription, \
silence_comment_subscription
from werkzeug.exceptions import BadRequest
@get_user_media_entry
@require_active_login
def subscribe_comments(request, media):
add_comment_subscription(request.user, media)
messages.add_message(request,
messages.SUCCESS,
_('Subscribed to comments on %s!')
% media.title)
return redirect(request, location=media.url_for_self(request.urlgen))
@get_user_media_entry
@require_active_login
def silence_comments(request, media):
silence_comment_subscription(request.user, media)
messages.add_message(request,
messages.SUCCESS,
_('You will not receive notifications for comments on'
' %s.') % media.title)
return redirect(request, location=media.url_for_self(request.urlgen))

View File

@ -0,0 +1,95 @@
# GNU MediaGoblin -- federated, autonomous media hosting
# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from mediagoblin.plugins.basic_auth import forms as auth_forms
from mediagoblin.plugins.basic_auth import tools as auth_tools
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')
def get_user(**kwargs):
username = kwargs.pop('username', None)
if username:
user = User.query.filter(
or_(
User.username == username,
User.email == username,
)).first()
return user
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.pw_hash = gen_password_hash(
registration_form.password.data)
user.save()
return user
def get_login_form(request):
return auth_forms.LoginForm(request.form)
def get_registration_form(request):
return auth_forms.RegistrationForm(request.form)
def gen_password_hash(raw_pass, extra_salt=None):
return auth_tools.bcrypt_gen_password_hash(raw_pass, extra_salt)
def check_password(raw_pass, stored_hash, extra_salt=None):
return auth_tools.bcrypt_check_password(raw_pass, stored_hash, extra_salt)
def auth():
return True
def append_to_global_context(context):
context['pass_auth'] = True
return context
def add_to_form_context(context):
context['pass_auth_link'] = True
return context
hooks = {
'setup': setup_plugin,
'authentication': auth,
'auth_get_user': get_user,
'auth_create_user': create_user,
'auth_get_login_form': get_login_form,
'auth_get_registration_form': get_registration_form,
'auth_gen_password_hash': gen_password_hash,
'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,
}

View File

@ -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/>.
import wtforms
from mediagoblin.tools.translate import lazy_pass_to_ugettext as _
from mediagoblin.auth.tools import normalize_user_or_email_field
class RegistrationForm(wtforms.Form):
username = wtforms.TextField(
_('Username'),
[wtforms.validators.Required(),
normalize_user_or_email_field(allow_email=False)])
password = wtforms.PasswordField(
_('Password'),
[wtforms.validators.Required(),
wtforms.validators.Length(min=5, max=1024)])
email = wtforms.TextField(
_('Email address'),
[wtforms.validators.Required(),
normalize_user_or_email_field(allow_user=False)])
class LoginForm(wtforms.Form):
username = wtforms.TextField(
_('Username or Email'),
[wtforms.validators.Required(),
normalize_user_or_email_field()])
password = wtforms.PasswordField(
_('Password'))

View File

@ -13,14 +13,8 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import random
import bcrypt
from mediagoblin.tools.mail import send_email
from mediagoblin.tools.template import render_template
from mediagoblin import mg_globals
import random
def bcrypt_check_password(raw_pass, stored_hash, extra_salt=None):
@ -88,33 +82,3 @@ def fake_login_attempt():
randplus_hashed_pass = bcrypt.hashpw(hashed_pass, rand_salt)
randplus_stored_hash == randplus_hashed_pass
EMAIL_FP_VERIFICATION_TEMPLATE = (
u"http://{host}{uri}?"
u"userid={userid}&token={fp_verification_key}")
def send_fp_verification_email(user, request):
"""
Send the verification email to users to change their password.
Args:
- user: a user object
- request: the request
"""
rendered_email = render_template(
request, 'mediagoblin/auth/fp_verification_email.txt',
{'username': user.username,
'verification_url': EMAIL_FP_VERIFICATION_TEMPLATE.format(
host=request.host,
uri=request.urlgen('mediagoblin.auth.verify_forgot_password'),
userid=unicode(user.id),
fp_verification_key=user.fp_verification_key)})
# TODO: There is no error handling in place
send_email(
mg_globals.app_config['email_sender_address'],
[user.email],
'GNU MediaGoblin - Change forgotten password!',
rendered_email)

View File

@ -35,6 +35,7 @@ def get_url_map():
import mediagoblin.edit.routing
import mediagoblin.webfinger.routing
import mediagoblin.listings.routing
import mediagoblin.notifications.routing
for route in PluginManager().get_routes():
add_route(*route)

View File

@ -129,6 +129,7 @@ header {
.header_dropdown {
margin-bottom: 20px;
padding: 0px 10px 0px 10px;
}
.header_dropdown li {
@ -384,6 +385,12 @@ a.comment_whenlink:hover {
margin-top: 8px;
}
.comment_active {
box-shadow: 0px 0px 15px 15px #378566;
background: #378566;
color: #f7f7f7;
}
textarea#comment_content {
resize: vertical;
width: 100%;

View File

@ -0,0 +1,36 @@
'use strict';
/**
* 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/>.
*/
var notifications = {};
(function (n) {
n._base = '/';
n._endpoint = 'notifications/json';
n.init = function () {
$('.notification-gem').on('click', function () {
$('.header_dropdown_down:visible').click();
});
}
})(notifications)
$(document).ready(function () {
notifications.init();
});

View File

@ -34,6 +34,8 @@ from mediagoblin.media_types import sniff_media, \
from mediagoblin.submit.lib import check_file_field, prepare_queue_task, \
run_process_media, new_upload_entry
from mediagoblin.notifications import add_comment_subscription
@require_active_login
def submit_start(request):
@ -92,6 +94,8 @@ def submit_start(request):
run_process_media(entry, feed_url)
add_message(request, SUCCESS, _('Woohoo! Submitted!'))
add_comment_subscription(request.user, entry)
return redirect(request, "mediagoblin.user_pages.user_home",
user=request.user.username)
except Exception as e:

View File

@ -34,11 +34,10 @@
{{ csrf_token }}
<div class="form_box">
<h1>{% trans %}Set your new password{% endtrans %}</h1>
{{ wtforms_util.render_divs(cp_form) }}
{{ wtforms_util.render_divs(cp_form, True) }}
<div class="form_submit_buttons">
<input type="submit" value="{% trans %}Set password{% endtrans %}" class="button_form"/>
</div>
</div>
</form>
{% endblock %}

View File

@ -29,7 +29,7 @@
{{ csrf_token }}
<div class="form_box">
<h1>{% trans %}Recover password{% endtrans %}</h1>
{{ wtforms_util.render_divs(fp_form) }}
{{ wtforms_util.render_divs(fp_form, True) }}
<div class="form_submit_buttons">
<input type="submit" value="{% trans %}Send instructions{% endtrans %}" class="button_form"/>
</div>

View File

@ -45,11 +45,13 @@
{%- trans %}Create one here!{% endtrans %}</a>
</p>
{% endif %}
{{ wtforms_util.render_divs(login_form) }}
{{ 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>
{% endif %}
<div class="form_submit_buttons">
<input type="submit" value="{% trans %}Log in{% endtrans %}" class="button_form"/>
</div>

View File

@ -34,7 +34,7 @@
method="POST" enctype="multipart/form-data">
<div class="form_box">
<h1>{% trans %}Create an account!{% endtrans %}</h1>
{{ wtforms_util.render_divs(register_form) }}
{{ wtforms_util.render_divs(register_form, True) }}
{{ csrf_token }}
<div class="form_submit_buttons">
<input type="submit" value="{% trans %}Create{% endtrans %}"
@ -42,6 +42,4 @@
</div>
</div>
</form>
<!-- Focus the username field by default -->
<script>$(document).ready(function(){$("#username").focus();});</script>
{% endblock %}

View File

@ -34,6 +34,8 @@
src="{{ request.staticdirect('/js/extlib/jquery.js') }}"></script>
<script type="text/javascript"
src="{{ request.staticdirect('/js/header_dropdown.js') }}"></script>
<script type="text/javascript"
src="{{ request.staticdirect('/js/notifications.js') }}"></script>
{# For clarification, the difference between the extra_head.html template
# and the head template hook is that the former should be used by
@ -57,6 +59,12 @@
<div class="header_right">
{%- if request.user %}
{% if request.user and request.user.status == 'active' %}
{% set notification_count = request.notifications.get_notification_count(request.user.id) %}
{% if notification_count %}
<a href="#notifications" class="notification-gem button_action" title="Notifications">
{{ notification_count }}</a>
{% endif %}
<div class="button_action header_dropdown_down">&#9660;</div>
<div class="button_action header_dropdown_up">&#9650;</div>
{% elif request.user and request.user.status == "needs_email_verification" %}
@ -67,7 +75,7 @@
{% trans %}Verify your email!{% endtrans %}</a>
or <a href="{{ request.urlgen('mediagoblin.auth.logout') }}">{% trans %}log out{% endtrans %}</a>
{% endif %}
{%- else %}
{%- elif auth %}
<a href="{{ request.urlgen('mediagoblin.auth.login') }}?next={{
request.base_url|urlencode }}">
{%- trans %}Log in{% endtrans -%}
@ -109,6 +117,7 @@
</a>
</p>
{% endif %}
{% include 'mediagoblin/fragments/header_notifications.html' %}
</div>
{% endif %}
</header>

View File

@ -17,19 +17,25 @@
#}
{% if request.user %}
<h1>{% trans %}Explore{% endtrans %}</h1>
{% else %}
<h1>{% trans %}Hi there, welcome to this MediaGoblin site!{% endtrans %}</h1>
<img class="right_align" src="{{ request.staticdirect('/images/frontpage_image.png') }}" />
<p>{% trans %}This site is running <a href="http://mediagoblin.org">MediaGoblin</a>, an extraordinarily great piece of media hosting software.{% endtrans %}</p>
<p>{% trans %}To add your own media, place comments, and more, you can log in with your MediaGoblin account.{% endtrans %}</p>
{% if allow_registration %}
<p>{% trans %}Don't have one yet? It's easy!{% endtrans %}</p>
{% trans register_url=request.urlgen('mediagoblin.auth.register') -%}
<a class="button_action_highlight" href="{{ register_url }}">Create an account at this site</a>
or
<a class="button_action" href="http://wiki.mediagoblin.org/HackingHowto">Set up MediaGoblin on your own server</a>
{%- endtrans %}
<h1>{% trans %}Explore{% endtrans %}</h1>
{% else %}
<h1>{% trans %}Hi there, welcome to this MediaGoblin site!{% endtrans %}</h1>
<img class="right_align" src="{{ request.staticdirect('/images/frontpage_image.png') }}" />
<p>{% trans %}This site is running <a href="http://mediagoblin.org">MediaGoblin</a>, an extraordinarily great piece of media hosting software.{% endtrans %}</p>
{% if auth %}
<p>{% trans %}To add your own media, place comments, and more, you can log in with your MediaGoblin account.{% endtrans %}</p>
{% if allow_registration %}
<p>{% trans %}Don't have one yet? It's easy!{% endtrans %}</p>
{% trans register_url=request.urlgen('mediagoblin.auth.register') -%}
<a class="button_action_highlight" href="{{ register_url }}">Create an account at this site</a>
or
{%- endtrans %}
{% endif %}
{% endif %}
{% trans %}
<a class="button_action" href="http://wiki.mediagoblin.org/HackingHowto">Set up MediaGoblin on your own server</a>
{%- endtrans %}
<div class="clear"></div>
{% endif %}
<div class="clear"></div>
{% endif %}

View File

@ -46,11 +46,7 @@
{% trans %}Change your password.{% endtrans %}
</a>
</p>
<div class="form_field_input">
<p>{{ form.wants_comment_notification }}
{{ wtforms_util.render_label(form.wants_comment_notification) }}</p>
</div>
{{- wtforms_util.render_field_div(form.license_preference) }}
{{ wtforms_util.render_divs(form, True) }}
<div class="form_submit_buttons">
<input type="submit" value="{% trans %}Save changes{% endtrans %}" class="button_form" />
{{ csrf_token }}

View File

@ -0,0 +1,29 @@
{#
# 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/>.
-#}
{% trans username=username, verification_url=verification_url|safe -%}
Hi,
We wanted to verify that you are {{ username }}. If this is the case, then
please follow the link below to verify your new email address.
{{ verification_url }}
If you are not {{ username }} or didn't request an email change, you can ignore
this email.
{%- endtrans %}

View File

@ -0,0 +1,40 @@
{% set notifications = request.notifications.get_notifications(request.user.id) %}
{% if notifications %}
<div class="header_notifications">
<h3>{% trans %}New comments{% endtrans %}</h3>
<ul>
{% for notification in notifications %}
{% set comment = notification.subject %}
{% set comment_author = comment.get_author %}
{% set media = comment.get_entry %}
<li class="comment_wrapper">
<div class="comment_author">
<img src="{{ request.staticdirect('/images/icon_comment.png') }}" />
<a href="{{ request.urlgen('mediagoblin.user_pages.user_home',
user=comment_author.username) }}"
class="comment_authorlink">
{{- comment_author.username -}}
</a>
<a href="{{ request.urlgen('mediagoblin.user_pages.media_home.view_comment',
comment=comment.id,
user=media.get_uploader.username,
media=media.slug_or_id) }}#comment"
class="comment_whenlink">
<span title='{{- comment.created.strftime("%I:%M%p %Y-%m-%d") -}}'>
{%- trans formatted_time=timesince(comment.created) -%}
{{ formatted_time }} ago
{%- endtrans -%}
</span>
</a>:
</div>
<div class="comment_content">
{% autoescape False -%}
{{ comment.content_html }}
{%- endautoescape %}
</div>
</li>
{% endfor %}
</ul>
</div>
{% endif %}

View File

@ -81,6 +81,7 @@
user= media.get_uploader.username,
media_id=media.id) %}
<a class="button_action" href="{{ delete_url }}">{% trans %}Delete{% endtrans %}</a>
{% endif %}
{% autoescape False %}
<p>{{ media.description_html }}</p>
@ -94,6 +95,8 @@
class="button_action" id="button_addcomment" title="Add a comment">
{% trans %}Add a comment{% endtrans %}
</a>
{% include "mediagoblin/utils/comment-subscription.html" %}
{% endif %}
{% if request.user %}
<form action="{{ request.urlgen('mediagoblin.user_pages.media_post_comment',

View File

@ -0,0 +1,34 @@
{#
# GNU MediaGoblin -- federated, autonomous media hosting
# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#}
{%- if request.user %}
{% set subscription = request.notifications.get_comment_subscription(
request.user.id, media.id) %}
{% if not subscription or not subscription.notify %}
<a type="submit" href="{{ request.urlgen('mediagoblin.notifications.subscribe_comments',
user=media.get_uploader.username,
media=media.slug)}}"
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)}}"
class="button_action">Silence comments
</a>
{% endif %}
{%- endif %}

View File

@ -33,25 +33,37 @@
{%- endmacro %}
{# Generically render a field #}
{% macro render_field_div(field) %}
{% macro render_field_div(field, autofocus_first=False) %}
{{- render_label_p(field) }}
<div class="form_field_input">
{{ field }}
{% 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 %}
<p class="form_field_description">{{ field.description|safe }}</p>
{% if field.type == 'BooleanField' %}
<label for="{{ field.label.field_id }}">{{ field.description|safe }}</label>
{% else %}
<p class="form_field_description">{{ field.description|safe }}</p>
{% endif %}
{%- endif %}
</div>
{%- endmacro %}
{# Auto-render a form as a series of divs #}
{% macro render_divs(form) -%}
{% macro render_divs(form, autofocus_first=False) -%}
{% for field in form %}
{{ render_field_div(field) }}
{% if autofocus_first and loop.first %}
{{ render_field_div(field, True) }}
{% else %}
{{ render_field_div(field) }}
{% endif %}
{% endfor %}
{%- endmacro %}

View File

@ -0,0 +1,25 @@
[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]

View File

@ -13,54 +13,16 @@
#
# 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
from mediagoblin import mg_globals
from mediagoblin.auth import lib as auth_lib
from mediagoblin.db.models import User
from mediagoblin.tests.tools import fixture_add_user
from mediagoblin.tests.tools import get_app, fixture_add_user
from mediagoblin.tools import template, mail
########################
# Test bcrypt auth funcs
########################
def test_bcrypt_check_password():
# Check known 'lollerskates' password against check function
assert auth_lib.bcrypt_check_password(
'lollerskates',
'$2a$12$PXU03zfrVCujBhVeICTwtOaHTUs5FFwsscvSSTJkqx/2RQ0Lhy/nO')
assert not auth_lib.bcrypt_check_password(
'notthepassword',
'$2a$12$PXU03zfrVCujBhVeICTwtOaHTUs5FFwsscvSSTJkqx/2RQ0Lhy/nO')
# Same thing, but with extra fake salt.
assert not auth_lib.bcrypt_check_password(
'notthepassword',
'$2a$12$ELVlnw3z1FMu6CEGs/L8XO8vl0BuWSlUHgh0rUrry9DUXGMUNWwl6',
'3><7R45417')
def test_bcrypt_gen_password_hash():
pw = 'youwillneverguessthis'
# Normal password hash generation, and check on that hash
hashed_pw = auth_lib.bcrypt_gen_password_hash(pw)
assert auth_lib.bcrypt_check_password(
pw, hashed_pw)
assert not auth_lib.bcrypt_check_password(
'notthepassword', hashed_pw)
# Same thing, extra salt.
hashed_pw = auth_lib.bcrypt_gen_password_hash(pw, '3><7R45417')
assert auth_lib.bcrypt_check_password(
pw, hashed_pw, '3><7R45417')
assert not auth_lib.bcrypt_check_password(
'notthepassword', hashed_pw, '3><7R45417')
from mediagoblin.auth import tools as auth_tools
def test_register_views(test_app):
@ -156,20 +118,15 @@ def test_register_views(test_app):
assert path == u'/auth/verify_email/'
parsed_get_params = urlparse.parse_qs(get_params)
### user should have these same parameters
assert parsed_get_params['userid'] == [
unicode(new_user.id)]
assert parsed_get_params['token'] == [
new_user.verification_key]
## Try verifying with bs verification key, shouldn't work
template.clear_test_template_context()
response = test_app.get(
"/auth/verify_email/?userid=%s&token=total_bs" % unicode(
new_user.id))
"/auth/verify_email/?token=total_bs")
response.follow()
context = template.TEMPLATE_TEST_CONTEXT[
'mediagoblin/user_pages/user.html']
# Correct redirect?
assert urlparse.urlsplit(response.location)[2] == '/'
# 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(
@ -233,35 +190,17 @@ def test_register_views(test_app):
path = urlparse.urlsplit(email_context['verification_url'])[2]
get_params = urlparse.urlsplit(email_context['verification_url'])[3]
assert path == u'/auth/forgot_password/verify/'
parsed_get_params = urlparse.parse_qs(get_params)
# user should have matching parameters
new_user = mg_globals.database.User.find_one({'username': u'happygirl'})
assert parsed_get_params['userid'] == [unicode(new_user.id)]
assert parsed_get_params['token'] == [new_user.fp_verification_key]
### The forgotten password token should be set to expire in ~ 10 days
# A few ticks have expired so there are only 9 full days left...
assert (new_user.fp_token_expire - datetime.datetime.now()).days == 9
assert path == u'/auth/forgot_password/verify/'
## Try using a bs password-changing verification key, shouldn't work
template.clear_test_template_context()
response = test_app.get(
"/auth/forgot_password/verify/?userid=%s&token=total_bs" % unicode(
new_user.id), status=404)
assert response.status.split()[0] == u'404' # status="404 NOT FOUND"
"/auth/forgot_password/verify/?token=total_bs")
response.follow()
## Try using an expired token to change password, shouldn't work
template.clear_test_template_context()
new_user = mg_globals.database.User.find_one({'username': u'happygirl'})
real_token_expiration = new_user.fp_token_expire
new_user.fp_token_expire = datetime.datetime.now()
new_user.save()
response = test_app.get("%s?%s" % (path, get_params), status=404)
assert response.status.split()[0] == u'404' # status="404 NOT FOUND"
new_user.fp_token_expire = real_token_expiration
new_user.save()
# Correct redirect?
assert urlparse.urlsplit(response.location)[2] == '/'
## Verify step 1 of password-change works -- can see form to change password
template.clear_test_template_context()
@ -272,7 +211,6 @@ def test_register_views(test_app):
template.clear_test_template_context()
response = test_app.post(
'/auth/forgot_password/verify/', {
'userid': parsed_get_params['userid'],
'password': 'iamveryveryhappy',
'token': parsed_get_params['token']})
response.follow()
@ -310,7 +248,6 @@ def test_authentication_views(test_app):
context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/auth/login.html']
form = context['login_form']
assert form.username.errors == [u'This field is required.']
assert form.password.errors == [u'This field is required.']
# Failed login - blank user
# -------------------------
@ -328,9 +265,7 @@ def test_authentication_views(test_app):
response = test_app.post(
'/auth/login/', {
'username': u'chris'})
context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/auth/login.html']
form = context['login_form']
assert form.password.errors == [u'This field is required.']
assert 'mediagoblin/auth/login.html' in template.TEMPLATE_TEST_CONTEXT
# Failed login - bad user
# -----------------------
@ -394,3 +329,47 @@ def test_authentication_views(test_app):
'password': 'toast',
'next' : '/u/chris/'})
assert urlparse.urlsplit(response.location)[2] == '/u/chris/'
@pytest.fixture()
def authentication_disabled_app(request):
return get_app(
request,
mgoblin_config=pkg_resources.resource_filename(
'mediagoblin.tests.auth_configs',
'authentication_disabled_appconfig.ini'))
def test_authentication_disabled_app(authentication_disabled_app):
# app.auth should = false
assert mg_globals.app.auth is False
# Try to visit register page
template.clear_test_template_context()
response = authentication_disabled_app.get('/auth/register/')
response.follow()
# Correct redirect?
assert urlparse.urlsplit(response.location)[2] == '/'
assert 'mediagoblin/root.html' in template.TEMPLATE_TEST_CONTEXT
# Try to vist login page
template.clear_test_template_context()
response = authentication_disabled_app.get('/auth/login/')
response.follow()
# Correct redirect?
assert urlparse.urlsplit(response.location)[2] == '/'
assert 'mediagoblin/root.html' in template.TEMPLATE_TEST_CONTEXT
## Test check_login_simple should return None
assert auth_tools.check_login_simple('test', 'simple') is None
# Try to visit the forgot password page
template.clear_test_template_context()
response = authentication_disabled_app.get('/auth/register/')
response.follow()
# Correct redirect?
assert urlparse.urlsplit(response.location)[2] == '/'
assert 'mediagoblin/root.html' in template.TEMPLATE_TEST_CONTEXT

View File

@ -0,0 +1,59 @@
# GNU MediaGoblin -- federated, autonomous media hosting
# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from mediagoblin.plugins.basic_auth import tools as auth_tools
from mediagoblin.tools.testing import _activate_testing
_activate_testing()
########################
# Test bcrypt auth funcs
########################
def test_bcrypt_check_password():
# Check known 'lollerskates' password against check function
assert auth_tools.bcrypt_check_password(
'lollerskates',
'$2a$12$PXU03zfrVCujBhVeICTwtOaHTUs5FFwsscvSSTJkqx/2RQ0Lhy/nO')
assert not auth_tools.bcrypt_check_password(
'notthepassword',
'$2a$12$PXU03zfrVCujBhVeICTwtOaHTUs5FFwsscvSSTJkqx/2RQ0Lhy/nO')
# Same thing, but with extra fake salt.
assert not auth_tools.bcrypt_check_password(
'notthepassword',
'$2a$12$ELVlnw3z1FMu6CEGs/L8XO8vl0BuWSlUHgh0rUrry9DUXGMUNWwl6',
'3><7R45417')
def test_bcrypt_gen_password_hash():
pw = 'youwillneverguessthis'
# Normal password hash generation, and check on that hash
hashed_pw = auth_tools.bcrypt_gen_password_hash(pw)
assert auth_tools.bcrypt_check_password(
pw, hashed_pw)
assert not auth_tools.bcrypt_check_password(
'notthepassword', hashed_pw)
# Same thing, extra salt.
hashed_pw = auth_tools.bcrypt_gen_password_hash(pw, '3><7R45417')
assert auth_tools.bcrypt_check_password(
pw, hashed_pw, '3><7R45417')
assert not auth_tools.bcrypt_check_password(
'notthepassword', hashed_pw, '3><7R45417')

View File

@ -48,7 +48,7 @@ def test_setup_celery_from_config():
assert isinstance(fake_celery_module.CELERYD_ETA_SCHEDULER_PRECISION, float)
assert fake_celery_module.CELERY_RESULT_PERSISTENT is True
assert fake_celery_module.CELERY_IMPORTS == [
'foo.bar.baz', 'this.is.an.import', 'mediagoblin.processing.task']
'foo.bar.baz', 'this.is.an.import', 'mediagoblin.processing.task', 'mediagoblin.notifications.task']
assert fake_celery_module.CELERY_RESULT_BACKEND == 'database'
assert fake_celery_module.CELERY_RESULT_DBURI == (
'sqlite:///' +

View File

@ -15,13 +15,13 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import urlparse
import pytest
from mediagoblin import mg_globals
from mediagoblin.db.models import User
from mediagoblin.tests.tools import fixture_add_user
from mediagoblin.tools import template
from mediagoblin.auth.lib import bcrypt_check_password
from mediagoblin import auth
from mediagoblin.tools import template, mail
class TestUserEdit(object):
def setup(self):
@ -74,7 +74,7 @@ class TestUserEdit(object):
# test_user has to be fetched again in order to have the current values
test_user = User.query.filter_by(username=u'chris').first()
assert bcrypt_check_password('123456', test_user.pw_hash)
assert auth.check_password('123456', test_user.pw_hash)
# Update current user passwd
self.user_password = '123456'
@ -88,7 +88,7 @@ class TestUserEdit(object):
})
test_user = User.query.filter_by(username=u'chris').first()
assert not bcrypt_check_password('098765', test_user.pw_hash)
assert not auth.check_password('098765', test_user.pw_hash)
def test_change_bio_url(self, test_app):
@ -141,4 +141,68 @@ class TestUserEdit(object):
assert form.url.errors == [
u'This address contains errors']
def test_email_change(self, test_app):
self.login(test_app)
# Test email already in db
template.clear_test_template_context()
test_app.post(
'/edit/account/', {
'new_email': 'chris@example.com',
'password': 'toast'})
# Check form errors
context = template.TEMPLATE_TEST_CONTEXT[
'mediagoblin/edit/edit_account.html']
assert context['form'].new_email.errors == [
u'Sorry, a user with that email address already exists.']
# Test successful email change
template.clear_test_template_context()
res = test_app.post(
'/edit/account/', {
'new_email': 'new@example.com',
'password': 'toast'})
res.follow()
# Correct redirect?
assert urlparse.urlsplit(res.location)[2] == '/u/chris/'
# Make sure we get email verification and try verifying
assert len(mail.EMAIL_TEST_INBOX) == 1
message = mail.EMAIL_TEST_INBOX.pop()
assert message['To'] == 'new@example.com'
email_context = template.TEMPLATE_TEST_CONTEXT[
'mediagoblin/edit/verification.txt']
assert email_context['verification_url'] in \
message.get_payload(decode=True)
path = urlparse.urlsplit(email_context['verification_url'])[2]
assert path == u'/edit/verify_email/'
## Try verifying with bs verification key, shouldn't work
template.clear_test_template_context()
res = test_app.get(
"/edit/verify_email/?token=total_bs")
res.follow()
# Correct redirect?
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 = User.query.filter_by(username='chris').first().email
assert email_in_db is None
assert email == 'chris@example.com'
# Verify email activation works
template.clear_test_template_context()
get_params = urlparse.urlsplit(email_context['verification_url'])[3]
res = test_app.get('%s?%s' % (path, get_params))
res.follow()
# New email saved?
email = User.query.filter_by(username='chris').first().email
assert email == 'new@example.com'
# test changing the url inproperly

View File

@ -31,3 +31,4 @@ BROKER_HOST = "sqlite:///%(here)s/user_dev/kombu.db"
[[mediagoblin.plugins.oauth]]
[[mediagoblin.plugins.httpapiauth]]
[[mediagoblin.plugins.piwigo]]
[[mediagoblin.plugins.basic_auth]]

View File

@ -28,8 +28,10 @@ def test_user_deletes_other_comments(test_app):
user_a = fixture_add_user(u"chris_a")
user_b = fixture_add_user(u"chris_b")
media_a = fixture_media_entry(uploader=user_a.id, save=False)
media_b = fixture_media_entry(uploader=user_b.id, save=False)
media_a = fixture_media_entry(uploader=user_a.id, save=False,
expunge=False, fake_upload=False)
media_b = fixture_media_entry(uploader=user_b.id, save=False,
expunge=False, fake_upload=False)
Session.add(media_a)
Session.add(media_b)
Session.flush()
@ -79,7 +81,7 @@ def test_user_deletes_other_comments(test_app):
def test_media_deletes_broken_attachment(test_app):
user_a = fixture_add_user(u"chris_a")
media = fixture_media_entry(uploader=user_a.id, save=False)
media = fixture_media_entry(uploader=user_a.id, save=False, expunge=False)
media.attachment_files.append(dict(
name=u"some name",
filepath=[u"does", u"not", u"exist"],

View File

@ -0,0 +1,151 @@
# 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 pytest
import urlparse
from mediagoblin.tools import template, mail
from mediagoblin.db.models import Notification, CommentNotification, \
CommentSubscription
from mediagoblin.db.base import Session
from mediagoblin.notifications import mark_comment_notification_seen
from mediagoblin.tests.tools import fixture_add_comment, \
fixture_media_entry, fixture_add_user, \
fixture_comment_subscription
class TestNotifications:
@pytest.fixture(autouse=True)
def setup(self, test_app):
self.test_app = test_app
# TODO: Possibly abstract into a decorator like:
# @as_authenticated_user('chris')
self.test_user = fixture_add_user()
self.current_user = None
self.login()
def login(self, username=u'chris', password=u'toast'):
response = self.test_app.post(
'/auth/login/', {
'username': username,
'password': password})
response.follow()
assert urlparse.urlsplit(response.location)[2] == '/'
assert 'mediagoblin/root.html' in template.TEMPLATE_TEST_CONTEXT
ctx = template.TEMPLATE_TEST_CONTEXT['mediagoblin/root.html']
assert Session.merge(ctx['request'].user).username == username
self.current_user = ctx['request'].user
def logout(self):
self.test_app.get('/auth/logout/')
self.current_user = None
@pytest.mark.parametrize('wants_email', [True, False])
def test_comment_notification(self, wants_email):
'''
Test
- if a notification is created when posting a comment on
another users media entry.
- that the comment data is consistent and exists.
'''
user = fixture_add_user('otherperson', password='nosreprehto',
wants_comment_notification=wants_email)
user_id = user.id
media_entry = fixture_media_entry(uploader=user.id, state=u'processed')
media_entry_id = media_entry.id
subscription = fixture_comment_subscription(media_entry)
subscription_id = subscription.id
media_uri_id = '/u/{0}/m/{1}/'.format(user.username,
media_entry.id)
media_uri_slug = '/u/{0}/m/{1}/'.format(user.username,
media_entry.slug)
self.test_app.post(
media_uri_id + 'comment/add/',
{
'comment_content': u'Test comment #42'
}
)
notifications = Notification.query.filter_by(
user_id=user.id).all()
assert len(notifications) == 1
notification = notifications[0]
assert type(notification) == CommentNotification
assert notification.seen == False
assert notification.user_id == user.id
assert notification.subject.get_author.id == self.test_user.id
assert notification.subject.content == u'Test comment #42'
if wants_email == True:
assert mail.EMAIL_TEST_MBOX_INBOX == [
{'from': 'notice@mediagoblin.example.org',
'message': 'Content-Type: text/plain; \
charset="utf-8"\nMIME-Version: 1.0\nContent-Transfer-Encoding: \
base64\nSubject: GNU MediaGoblin - chris commented on your \
post\nFrom: notice@mediagoblin.example.org\nTo: \
otherperson@example.com\n\nSGkgb3RoZXJwZXJzb24sCmNocmlzIGNvbW1lbnRlZCBvbiB5b3VyIHBvc3QgKGh0dHA6Ly9sb2Nh\nbGhvc3Q6ODAvdS9vdGhlcnBlcnNvbi9tL3NvbWUtdGl0bGUvYy8xLyNjb21tZW50KSBhdCBHTlUg\nTWVkaWFHb2JsaW4KClRlc3QgY29tbWVudCAjNDIKCkdOVSBNZWRpYUdvYmxpbg==\n',
'to': [u'otherperson@example.com']}]
else:
assert mail.EMAIL_TEST_MBOX_INBOX == []
# Save the ids temporarily because of DetachedInstanceError
notification_id = notification.id
comment_id = notification.subject.id
self.logout()
self.login('otherperson', 'nosreprehto')
self.test_app.get(media_uri_slug + '/c/{0}/'.format(comment_id))
notification = Notification.query.filter_by(id=notification_id).first()
assert notification.seen == True
self.test_app.get(media_uri_slug + '/notifications/silence/')
subscription = CommentSubscription.query.filter_by(id=subscription_id)\
.first()
assert subscription.notify == False
notifications = Notification.query.filter_by(
user_id=user_id).all()
# User should not have been notified
assert len(notifications) == 1

View File

@ -15,23 +15,22 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import sys
import os
import pkg_resources
import shutil
from functools import wraps
from paste.deploy import loadapp
from webtest import TestApp
from mediagoblin import mg_globals
from mediagoblin.db.models import User, MediaEntry, Collection
from mediagoblin.db.models import User, MediaEntry, Collection, MediaComment, \
CommentSubscription, CommentNotification
from mediagoblin.tools import testing
from mediagoblin.init.config import read_mediagoblin_config
from mediagoblin.db.base import Session
from mediagoblin.meddleware import BaseMeddleware
from mediagoblin.auth.lib import bcrypt_gen_password_hash
from mediagoblin.auth import gen_password_hash
from mediagoblin.gmg_commands.dbupdate import run_dbupdate
@ -171,7 +170,7 @@ def assert_db_meets_expected(db, expected):
def fixture_add_user(username=u'chris', password=u'toast',
active_user=True):
active_user=True, wants_comment_notification=True):
# Reuse existing user or create a new one
test_user = User.query.filter_by(username=username).first()
if test_user is None:
@ -179,11 +178,13 @@ def fixture_add_user(username=u'chris', password=u'toast',
test_user.username = username
test_user.email = username + u'@example.com'
if password is not None:
test_user.pw_hash = bcrypt_gen_password_hash(password)
test_user.pw_hash = gen_password_hash(password)
if active_user:
test_user.email_verified = True
test_user.status = u'active'
test_user.wants_comment_notification = wants_comment_notification
test_user.save()
# Reload
@ -195,19 +196,79 @@ def fixture_add_user(username=u'chris', password=u'toast',
return test_user
def fixture_comment_subscription(entry, notify=True, send_email=None):
if send_email is None:
uploader = User.query.filter_by(id=entry.uploader).first()
send_email = uploader.wants_comment_notification
cs = CommentSubscription(
media_entry_id=entry.id,
user_id=entry.uploader,
notify=notify,
send_email=send_email)
cs.save()
cs = CommentSubscription.query.filter_by(id=cs.id).first()
Session.expunge(cs)
return cs
def fixture_add_comment_notification(entry_id, subject_id, user_id,
seen=False):
cn = CommentNotification(user_id=user_id,
seen=seen,
subject_id=subject_id)
cn.save()
cn = CommentNotification.query.filter_by(id=cn.id).first()
Session.expunge(cn)
return cn
def fixture_media_entry(title=u"Some title", slug=None,
uploader=None, save=True, gen_slug=True):
uploader=None, save=True, gen_slug=True,
state=u'unprocessed', fake_upload=True,
expunge=True):
"""
Add a media entry for testing purposes.
Caution: if you're adding multiple entries with fake_upload=True,
make sure you save between them... otherwise you'll hit an
IntegrityError from multiple newly-added-MediaEntries adding
FileKeynames at once. :)
"""
if uploader is None:
uploader = fixture_add_user().id
entry = MediaEntry()
entry.title = title
entry.slug = slug
entry.uploader = uploader or fixture_add_user().id
entry.uploader = uploader
entry.media_type = u'image'
entry.state = state
if fake_upload:
entry.media_files = {'thumb': ['a', 'b', 'c.jpg'],
'medium': ['d', 'e', 'f.png'],
'original': ['g', 'h', 'i.png']}
entry.media_type = u'mediagoblin.media_types.image'
if gen_slug:
entry.generate_slug()
if save:
entry.save()
if expunge:
entry = MediaEntry.query.filter_by(id=entry.id).first()
Session.expunge(entry)
return entry
@ -231,3 +292,25 @@ def fixture_add_collection(name=u"My first Collection", user=None):
return coll
def fixture_add_comment(author=None, media_entry=None, comment=None):
if author is None:
author = fixture_add_user().id
if media_entry is None:
media_entry = fixture_media_entry().id
if comment is None:
comment = \
'Auto-generated test comment by user #{0} on media #{0}'.format(
author, media_entry)
comment = MediaComment(author=author,
media_entry=media_entry,
content=comment)
comment.save()
Session.expunge(comment)
return comment

View File

@ -90,7 +90,12 @@ def send_email(from_addr, to_addrs, subject, message_body):
if common.TESTS_ENABLED or mg_globals.app_config['email_debug_mode']:
mhost = FakeMhost()
elif not mg_globals.app_config['email_debug_mode']:
mhost = smtplib.SMTP(
if mg_globals.app_config['email_smtp_use_ssl']:
smtp_init = smtplib.SMTP_SSL
else:
smtp_init = smtplib.SMTP
mhost = smtp_init(
mg_globals.app_config['email_smtp_host'],
mg_globals.app_config['email_smtp_port'])

View File

@ -77,7 +77,7 @@ def render_http_exception(request, exc, description):
elif stock_desc and exc.code == 404:
return render_404(request)
return render_error(request, title=exc.args[0],
return render_error(request, title='{0} {1}'.format(exc.code, exc.name),
err_msg=description,
status=exc.code)

View File

@ -71,6 +71,7 @@ def get_jinja_env(template_loader, locale):
template_env.globals['app_config'] = mg_globals.app_config
template_env.globals['global_config'] = mg_globals.global_config
template_env.globals['version'] = _version.__version__
template_env.globals['auth'] = mg_globals.app.auth
template_env.filters['urlencode'] = url_quote_plus

View File

@ -25,8 +25,9 @@ from mediagoblin.tools.response import render_to_response, render_404, \
from mediagoblin.tools.translate import pass_to_ugettext as _
from mediagoblin.tools.pagination import Pagination
from mediagoblin.user_pages import forms as user_forms
from mediagoblin.user_pages.lib import (send_comment_email,
add_media_to_collection)
from mediagoblin.user_pages.lib import add_media_to_collection
from mediagoblin.notifications import trigger_notification, \
add_comment_subscription, mark_comment_notification_seen
from mediagoblin.decorators import (uses_pagination, get_user_media_entry,
get_media_entry_by_id,
@ -34,6 +35,7 @@ from mediagoblin.decorators import (uses_pagination, get_user_media_entry,
get_user_collection, get_user_collection_item, active_user_from_url)
from werkzeug.contrib.atom import AtomFeed
from werkzeug.exceptions import MethodNotAllowed
_log = logging.getLogger(__name__)
@ -110,6 +112,7 @@ def user_gallery(request, page, url_user=None):
'media_entries': media_entries,
'pagination': pagination})
MEDIA_COMMENTS_PER_PAGE = 50
@ -121,6 +124,9 @@ def media_home(request, media, page, **kwargs):
"""
comment_id = request.matchdict.get('comment', None)
if comment_id:
if request.user:
mark_comment_notification_seen(comment_id, request.user)
pagination = Pagination(
page, media.get_comments(
mg_globals.app_config['comments_ascending']),
@ -154,7 +160,8 @@ def media_post_comment(request, media):
"""
recieves POST from a MediaEntry() comment form, saves the comment.
"""
assert request.method == 'POST'
if not request.method == 'POST':
raise MethodNotAllowed()
comment = request.db.MediaComment()
comment.media_entry = media.id
@ -179,11 +186,9 @@ def media_post_comment(request, media):
request, messages.SUCCESS,
_('Your comment has been posted!'))
media_uploader = media.get_uploader
#don't send email if you comment on your own post
if (comment.author != media_uploader and
media_uploader.wants_comment_notification):
send_comment_email(media_uploader, comment, media, request)
trigger_notification(comment, media, request)
add_comment_subscription(request.user, media)
return redirect_obj(request, media)

View File

@ -14,7 +14,7 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from setuptools import setup
from setuptools import setup, find_packages
import os
import re
@ -36,7 +36,7 @@ def get_version():
setup(
name="mediagoblin",
version=get_version(),
packages=['mediagoblin'],
packages=find_packages(exclude=['ez_setup', 'examples', 'tests']),
zip_safe=False,
include_package_data = True,
# scripts and dependencies
@ -57,7 +57,7 @@ setup(
'webtest<2',
'ConfigObj',
'Markdown',
'sqlalchemy>=0.7.0',
'sqlalchemy>=0.8.0',
'sqlalchemy-migrate',
'mock',
'itsdangerous',