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 This means that when people enable your plugin in their config you'll
be able to provide defaults as well as type validation. 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 Context Hooks
------------- -------------

View File

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

View File

@ -37,6 +37,8 @@ from mediagoblin.init import (get_jinja_loader, get_staticdirector,
setup_storage) setup_storage)
from mediagoblin.tools.pluginapi import PluginManager, hook_transform from mediagoblin.tools.pluginapi import PluginManager, hook_transform
from mediagoblin.tools.crypto import setup_crypto 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__) _log = logging.getLogger(__name__)
@ -97,6 +99,11 @@ class MediaGoblinApp(object):
PluginManager().get_template_paths() 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 # Set up storage systems
self.public_store, self.queue_store = setup_storage() self.public_store, self.queue_store = setup_storage()
@ -186,6 +193,11 @@ class MediaGoblinApp(object):
request.urlgen = build_proxy request.urlgen = build_proxy
# Log user out if authentication_disabled
no_auth_logout(request)
request.notifications = notifications
mg_request.setup_user_in_request(request) mg_request.setup_user_in_request(request)
request.controller_name = None request.controller_name = None

View File

@ -13,3 +13,32 @@
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # 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 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): class ForgotPassForm(wtforms.Form):
username = wtforms.TextField( username = wtforms.TextField(
_('Username or email'), _('Username or email'),
@ -58,9 +32,6 @@ class ChangePassForm(wtforms.Form):
'Password', 'Password',
[wtforms.validators.Required(), [wtforms.validators.Required(),
wtforms.validators.Length(min=5, max=1024)]) wtforms.validators.Length(min=5, max=1024)])
userid = wtforms.HiddenField(
'',
[wtforms.validators.Required()])
token = wtforms.HiddenField( token = wtforms.HiddenField(
'', '',
[wtforms.validators.Required()]) [wtforms.validators.Required()])

View File

@ -14,19 +14,18 @@
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import uuid
import logging import logging
import wtforms import wtforms
from sqlalchemy import or_
from mediagoblin import mg_globals 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.db.models import User
from mediagoblin.tools.mail import (normalize_email, send_email, from mediagoblin.tools.mail import (normalize_email, send_email,
email_debug_message) email_debug_message)
from mediagoblin.tools.template import render_template from mediagoblin.tools.template import render_template
from mediagoblin.tools.translate import lazy_pass_to_ugettext as _ 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__) _log = logging.getLogger(__name__)
@ -62,11 +61,12 @@ def normalize_user_or_email_field(allow_email=True, allow_user=True):
EMAIL_VERIFICATION_TEMPLATE = ( EMAIL_VERIFICATION_TEMPLATE = (
u"http://{host}{uri}?" u"{uri}?"
u"userid={userid}&token={verification_key}") 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. Send the verification email to users to activate their accounts.
@ -74,19 +74,24 @@ def send_verification_email(user, request):
- user: a user object - user: a user object
- request: the request - request: the request
""" """
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( rendered_email = render_template(
request, 'mediagoblin/auth/verification_email.txt', request, 'mediagoblin/auth/verification_email.txt',
{'username': user.username, {'username': user.username,
'verification_url': EMAIL_VERIFICATION_TEMPLATE.format( 'verification_url': EMAIL_VERIFICATION_TEMPLATE.format(
host=request.host, uri=request.urlgen('mediagoblin.auth.verify_email',
uri=request.urlgen('mediagoblin.auth.verify_email'), qualified=True),
userid=unicode(user.id), verification_key=verification_key)})
verification_key=user.verification_key)})
# TODO: There is no error handling in place # TODO: There is no error handling in place
send_email( send_email(
mg_globals.app_config['email_sender_address'], mg_globals.app_config['email_sender_address'],
[user.email], [email],
# TODO # TODO
# Due to the distributed nature of GNU MediaGoblin, we should # Due to the distributed nature of GNU MediaGoblin, we should
# find a way to send some additional information about the # find a way to send some additional information about the
@ -96,11 +101,42 @@ def send_verification_email(user, request):
rendered_email) 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): def basic_extra_validation(register_form, *args):
users_with_username = User.query.filter_by( 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( users_with_email = User.query.filter_by(
email=register_form.data['email']).count() email=register_form.email.data).count()
extra_validation_passes = True extra_validation_passes = True
@ -118,17 +154,11 @@ def basic_extra_validation(register_form, *args):
def register_user(request, register_form): def register_user(request, register_form):
""" Handle user registration """ """ Handle user registration """
extra_validation_passes = basic_extra_validation(register_form) extra_validation_passes = auth.extra_validation(register_form)
if extra_validation_passes: if extra_validation_passes:
# Create the user # Create the user
user = User() user = auth.create_user(register_form)
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()
# log the user in # log the user in
request.session['user_id'] = unicode(user.id) request.session['user_id'] = unicode(user.id)
@ -143,17 +173,29 @@ def register_user(request, register_form):
return None return None
def check_login_simple(username, password, username_might_be_email=False): def check_login_simple(username, password):
search = (User.username == username) user = auth.get_user(username=username)
if username_might_be_email and ('@' in username):
search = or_(search, User.email == username)
user = User.query.filter(search).first()
if not user: if not user:
_log.info("User %r not found", username) _log.info("User %r not found", username)
auth_lib.fake_login_attempt() hook_handle("auth_fake_login_attempt")
return None 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) _log.warn("Wrong password for %r", username)
return None return None
_log.info("Logging %r in", username) _log.info("Logging %r in", username)
return user 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/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import uuid import uuid
import datetime from itsdangerous import BadSignature
from mediagoblin import messages, mg_globals from mediagoblin import messages, mg_globals
from mediagoblin.db.models import User 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.response import render_to_response, redirect, render_404
from mediagoblin.tools.translate import pass_to_ugettext as _ from mediagoblin.tools.translate import pass_to_ugettext as _
from mediagoblin.tools.mail import email_debug_message 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 import forms as auth_forms
from mediagoblin.auth.lib import send_fp_verification_email
from mediagoblin.auth.tools import (send_verification_email, register_user, from mediagoblin.auth.tools import (send_verification_email, register_user,
send_fp_verification_email,
check_login_simple) check_login_simple)
from mediagoblin import auth
def register(request): def register(request):
@ -35,15 +37,21 @@ def register(request):
Note that usernames will always be lowercased. Email domains are lowercased while Note that usernames will always be lowercased. Email domains are lowercased while
the first part remains case-sensitive. the first part remains case-sensitive.
""" """
# Redirects to indexpage if registrations are disabled # Redirects to indexpage if registrations are disabled or no authentication
if not mg_globals.app_config["allow_registration"]: # is enabled
if not mg_globals.app_config["allow_registration"] or not mg_globals.app.auth:
messages.add_message( messages.add_message(
request, request,
messages.WARNING, messages.WARNING,
_('Sorry, registration is disabled on this instance.')) _('Sorry, registration is disabled on this instance.'))
return redirect(request, "index") 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(): if request.method == 'POST' and register_form.validate():
# TODO: Make sure the user doesn't exist already # TODO: Make sure the user doesn't exist already
@ -59,7 +67,8 @@ def register(request):
return render_to_response( return render_to_response(
request, request,
'mediagoblin/auth/register.html', 'mediagoblin/auth/register.html',
{'register_form': register_form}) {'register_form': register_form,
'post_url': request.urlgen('mediagoblin.auth.register')})
def login(request): def login(request):
@ -68,16 +77,28 @@ def login(request):
If you provide the POST with 'next', it'll redirect to that view. 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 login_failed = False
if request.method == 'POST': if request.method == 'POST':
username = login_form.username.data
username = login_form.data['username']
if login_form.validate(): 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: if user:
# set up login in session # set up login in session
@ -97,6 +118,7 @@ def login(request):
{'login_form': login_form, {'login_form': login_form,
'next': request.GET.get('next') or request.form.get('next'), 'next': request.GET.get('next') or request.form.get('next'),
'login_failed': login_failed, 'login_failed': login_failed,
'post_url': request.urlgen('mediagoblin.auth.login'),
'allow_registration': mg_globals.app_config["allow_registration"]}) 'allow_registration': mg_globals.app_config["allow_registration"]})
@ -115,16 +137,28 @@ def verify_email(request):
you are lucky :) you are lucky :)
""" """
# If we don't have userid and token parameters, we can't do anything; 404 # 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) 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.status = u'active'
user.email_verified = True user.email_verified = True
user.verification_key = None
user.save() user.save()
messages.add_message( messages.add_message(
@ -166,9 +200,6 @@ def resend_activation(request):
return redirect(request, "mediagoblin.user_pages.user_home", user=request.user['username']) 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) email_debug_message(request)
send_verification_email(request.user, 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. Sends an email with an url to renew forgotten password.
Use GET querystring parameter 'username' to pre-populate the input field 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, fp_form = auth_forms.ForgotPassForm(request.form,
username=request.args.get('username')) username=request.args.get('username'))
if not (request.method == 'POST' and fp_form.validate()): if not (request.method == 'POST' and fp_form.validate()):
# Either GET request, or invalid form submitted. Display the template # Either GET request, or invalid form submitted. Display the template
return render_to_response(request, 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 # 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 # 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 # SUCCESS. Send reminder and return to login page
if user: 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) email_debug_message(request)
send_fp_verification_email(user, 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 # get form data variables, and specifically check for presence of token
formdata = _process_for_token(request) formdata = _process_for_token(request)
if not formdata['has_userid_and_token']: if not formdata['has_token']:
return render_404(request) return render_404(request)
formdata_token = formdata['vars']['token']
formdata_userid = formdata['vars']['userid']
formdata_vars = formdata['vars'] formdata_vars = formdata['vars']
# check if it's a valid user id # Catch error if token is faked or expired
user = User.query.filter_by(id=formdata_userid).first() try:
if not user: token = get_timed_signer_url("mail_verification_token") \
return render_404(request) .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 return redirect(
if ((user and user.fp_verification_key and request,
user.fp_verification_key == unicode(formdata_token) and 'index')
datetime.datetime.now() < user.fp_token_expire
and user.email_verified and user.status == 'active')): # 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) cp_form = auth_forms.ChangePassForm(formdata_vars)
if request.method == 'POST' and cp_form.validate(): 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) cp_form.password.data)
user.fp_verification_key = None
user.fp_token_expire = None
user.save() user.save()
messages.add_message( messages.add_message(
@ -290,12 +332,22 @@ def verify_forgot_password(request):
return render_to_response( return render_to_response(
request, request,
'mediagoblin/auth/change_fp.html', '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 if not user.email_verified:
# or the token expired messages.add_message(
else: request, messages.ERROR,
return render_404(request) _('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): def _process_for_token(request):
@ -313,7 +365,6 @@ def _process_for_token(request):
formdata = { formdata = {
'vars': formdata_vars, 'vars': formdata_vars,
'has_userid_and_token': 'has_token': 'token' in formdata_vars}
'userid' in formdata_vars and 'token' in formdata_vars}
return formdata return formdata

View File

@ -22,9 +22,10 @@ direct_remote_path = string(default="/mgoblin_static/")
# set to false to enable sending notices # set to false to enable sending notices
email_debug_mode = boolean(default=True) email_debug_mode = boolean(default=True)
email_smtp_use_ssl = boolean(default=False)
email_sender_address = string(default="notice@mediagoblin.example.org") email_sender_address = string(default="notice@mediagoblin.example.org")
email_smtp_host = string(default='') 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_user = string(default=None)
email_smtp_pass = 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 migrate.changeset.constraint import UniqueConstraint
from mediagoblin.db.migration_tools import RegisterMigration, inspect_table 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 = {} MIGRATIONS = {}
@ -287,3 +287,95 @@ def unique_collections_slug(db):
constraint.create() constraint.create()
db.commit() 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 re
import datetime import datetime
from datetime import datetime
from werkzeug.utils import cached_property from werkzeug.utils import cached_property
from mediagoblin import mg_globals from mediagoblin import mg_globals
@ -288,6 +290,13 @@ class MediaCommentMixin(object):
""" """
return cleaned_markdown_conversion(self.content) 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): class CollectionMixin(GenerateSlugMixin):
def check_slug_used(self, slug): def check_slug_used(self, slug):

View File

@ -24,15 +24,17 @@ import datetime
from sqlalchemy import Column, Integer, Unicode, UnicodeText, DateTime, \ from sqlalchemy import Column, Integer, Unicode, UnicodeText, DateTime, \
Boolean, ForeignKey, UniqueConstraint, PrimaryKeyConstraint, \ Boolean, ForeignKey, UniqueConstraint, PrimaryKeyConstraint, \
SmallInteger 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.orm.collections import attribute_mapped_collection
from sqlalchemy.sql.expression import desc from sqlalchemy.sql.expression import desc
from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.util import memoized_property from sqlalchemy.util import memoized_property
from mediagoblin.db.extratypes import PathTupleWithSlashes, JSONEncoded from mediagoblin.db.extratypes import PathTupleWithSlashes, JSONEncoded
from mediagoblin.db.base import Base, DictReadAttrProxy 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.files import delete_media_files
from mediagoblin.tools.common import import_component 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 # the RFC) and because it would be a mess to implement at this
# point. # point.
email = Column(Unicode, nullable=False) email = Column(Unicode, nullable=False)
created = Column(DateTime, nullable=False, default=datetime.datetime.now) pw_hash = Column(Unicode)
pw_hash = Column(Unicode, nullable=False)
email_verified = Column(Boolean, default=False) 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) status = Column(Unicode, default=u"needs_email_verification", nullable=False)
# Intented to be nullable=False, but migrations would not work for it # Intented to be nullable=False, but migrations would not work for it
# set to nullable=True implicitly. # set to nullable=True implicitly.
wants_comment_notification = Column(Boolean, default=True) wants_comment_notification = Column(Boolean, default=True)
license_preference = Column(Unicode) license_preference = Column(Unicode)
verification_key = Column(Unicode)
is_admin = Column(Boolean, default=False, nullable=False) is_admin = Column(Boolean, default=False, nullable=False)
url = Column(Unicode) url = Column(Unicode)
bio = Column(UnicodeText) # ?? bio = Column(UnicodeText) # ??
fp_verification_key = Column(Unicode)
fp_token_expire = Column(DateTime)
## TODO ## TODO
# plugin data would be in a separate model # plugin data would be in a separate model
@ -392,6 +391,10 @@ class MediaComment(Base, MediaCommentMixin):
backref=backref("posted_comments", backref=backref("posted_comments",
lazy="dynamic", lazy="dynamic",
cascade="all, delete-orphan")) 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. # Cascade: Comments are somewhat owned by their MediaEntry.
# So do the full thing. # So do the full thing.
@ -484,9 +487,103 @@ class ProcessingMetaData(Base):
return DictReadAttrProxy(self) 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 = [ MODELS = [
User, MediaEntry, Tag, MediaTag, MediaComment, Collection, CollectionItem, MediaFile, FileKeynames, User, MediaEntry, Tag, MediaTag, MediaComment, Collection, CollectionItem,
MediaAttachmentFile, ProcessingMetaData] MediaFile, FileKeynames, MediaAttachmentFile, ProcessingMetaData,
Notification, CommentNotification, ProcessingNotification,
CommentSubscription]
###################################################### ######################################################

View File

@ -16,9 +16,11 @@
import wtforms 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.translate import lazy_pass_to_ugettext as _
from mediagoblin.tools.licenses import licenses_as_choices from mediagoblin.tools.licenses import licenses_as_choices
from mediagoblin.auth.forms import normalize_user_or_email_field
class EditForm(wtforms.Form): class EditForm(wtforms.Form):
title = wtforms.TextField( title = wtforms.TextField(
@ -59,6 +61,13 @@ class EditProfileForm(wtforms.Form):
class EditAccountForm(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 = wtforms.SelectField(
_('License preference'), _('License preference'),
[ [
@ -67,8 +76,6 @@ class EditAccountForm(wtforms.Form):
], ],
choices=licenses_as_choices(), choices=licenses_as_choices(),
description=_('This will be your default license on upload forms.')) 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): class EditAttachmentsForm(wtforms.Form):

View File

@ -26,3 +26,5 @@ add_route('mediagoblin.edit.delete_account', '/edit/account/delete/',
'mediagoblin.edit.views:delete_account') 'mediagoblin.edit.views:delete_account')
add_route('mediagoblin.edit.pass', '/edit/password/', add_route('mediagoblin.edit.pass', '/edit/password/',
'mediagoblin.edit.views:change_pass') '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 datetime import datetime
from itsdangerous import BadSignature
from werkzeug.exceptions import Forbidden from werkzeug.exceptions import Forbidden
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from mediagoblin import messages from mediagoblin import messages
from mediagoblin import mg_globals 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 import forms
from mediagoblin.edit.lib import may_edit_media from mediagoblin.edit.lib import may_edit_media
from mediagoblin.decorators import (require_active_login, active_user_from_url, from mediagoblin.decorators import (require_active_login, active_user_from_url,
get_media_entry_by_id, get_media_entry_by_id, user_may_alter_collection,
user_may_alter_collection, get_user_collection) get_user_collection)
from mediagoblin.tools.response import render_to_response, \ from mediagoblin.tools.crypto import get_timed_signer_url
redirect, redirect_obj 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.translate import pass_to_ugettext as _
from mediagoblin.tools.template import render_template
from mediagoblin.tools.text import ( from mediagoblin.tools.text import (
convert_to_tag_list_of_dicts, media_tags_as_string) convert_to_tag_list_of_dicts, media_tags_as_string)
from mediagoblin.tools.url import slugify from mediagoblin.tools.url import slugify
from mediagoblin.db.util import check_media_slug_used, check_collection_slug_used from mediagoblin.db.util import check_media_slug_used, check_collection_slug_used
from mediagoblin.db.models import User
import mimetypes import mimetypes
@ -212,6 +218,10 @@ def edit_profile(request, url_user=None):
{'user': user, {'user': user,
'form': form}) 'form': form})
EMAIL_VERIFICATION_TEMPLATE = (
u'{uri}?'
u'token={verification_key}')
@require_active_login @require_active_login
def edit_account(request): def edit_account(request):
@ -220,20 +230,38 @@ def edit_account(request):
wants_comment_notification=user.wants_comment_notification, wants_comment_notification=user.wants_comment_notification,
license_preference=user.license_preference) license_preference=user.license_preference)
if request.method == 'POST': if request.method == 'POST' and form.validate():
form_validated = form.validate() user.wants_comment_notification = form.wants_comment_notification.data
if form_validated and \ user.license_preference = form.license_preference.data
form.wants_comment_notification.validate(form):
user.wants_comment_notification = \
form.wants_comment_notification.data
if form_validated and \ if form.new_email.data:
form.license_preference.validate(form): new_email = form.new_email.data
user.license_preference = \ users_with_email = User.query.filter_by(
form.license_preference.data 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() user.save()
messages.add_message(request, messages.add_message(request,
messages.SUCCESS, messages.SUCCESS,
@ -342,7 +370,7 @@ def change_pass(request):
if request.method == 'POST' and form.validate(): 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.data, user.pw_hash):
form.old_password.errors.append( form.old_password.errors.append(
_('Wrong password')) _('Wrong password'))
@ -354,7 +382,7 @@ def change_pass(request):
'user': user}) 'user': user})
# Password matches # Password matches
user.pw_hash = auth_lib.bcrypt_gen_password_hash( user.pw_hash = auth.gen_password_hash(
form.new_password.data) form.new_password.data)
user.save() user.save()
@ -369,3 +397,48 @@ def change_pass(request):
'mediagoblin/edit/change_pass.html', 'mediagoblin/edit/change_pass.html',
{'form': form, {'form': form,
'user': user}) '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/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from mediagoblin.gmg_commands import util as commands_util 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 from mediagoblin import mg_globals
def adduser_parser_setup(subparser): def adduser_parser_setup(subparser):
@ -52,7 +52,7 @@ def adduser(args):
entry = db.User() entry = db.User()
entry.username = unicode(args.username.lower()) entry.username = unicode(args.username.lower())
entry.email = unicode(args.email) 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.status = u'active'
entry.email_verified = True entry.email_verified = True
entry.save() entry.save()
@ -96,7 +96,7 @@ def changepw(args):
user = db.User.one({'username': unicode(args.username.lower())}) user = db.User.one({'username': unicode(args.username.lower())})
if user: if user:
user.pw_hash = auth_lib.bcrypt_gen_password_hash(args.password) user.pw_hash = auth.gen_password_hash(args.password)
user.save() user.save()
print 'Password successfully changed' print 'Password successfully changed'
else: else:

View File

@ -16,12 +16,18 @@
import os import os
import sys import sys
import logging
from celery import Celery from celery import Celery
from mediagoblin.tools.pluginapi import hook_runall 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' 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: if set_environ:
os.environ['CELERY_CONFIG_MODULE'] = settings_module 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

@ -22,9 +22,15 @@ import logging
import urllib import urllib
import multiprocessing import multiprocessing
import gobject import gobject
old_argv = sys.argv
sys.argv = []
import pygst import pygst
pygst.require('0.10') pygst.require('0.10')
import gst import gst
sys.argv = old_argv
import struct import struct
try: try:
from PIL import Image 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 # 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/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import random
import bcrypt import bcrypt
import random
from mediagoblin.tools.mail import send_email
from mediagoblin.tools.template import render_template
from mediagoblin import mg_globals
def bcrypt_check_password(raw_pass, stored_hash, extra_salt=None): 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_hashed_pass = bcrypt.hashpw(hashed_pass, rand_salt)
randplus_stored_hash == randplus_hashed_pass 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.edit.routing
import mediagoblin.webfinger.routing import mediagoblin.webfinger.routing
import mediagoblin.listings.routing import mediagoblin.listings.routing
import mediagoblin.notifications.routing
for route in PluginManager().get_routes(): for route in PluginManager().get_routes():
add_route(*route) add_route(*route)

View File

@ -129,6 +129,7 @@ header {
.header_dropdown { .header_dropdown {
margin-bottom: 20px; margin-bottom: 20px;
padding: 0px 10px 0px 10px;
} }
.header_dropdown li { .header_dropdown li {
@ -384,6 +385,12 @@ a.comment_whenlink:hover {
margin-top: 8px; margin-top: 8px;
} }
.comment_active {
box-shadow: 0px 0px 15px 15px #378566;
background: #378566;
color: #f7f7f7;
}
textarea#comment_content { textarea#comment_content {
resize: vertical; resize: vertical;
width: 100%; 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, \ from mediagoblin.submit.lib import check_file_field, prepare_queue_task, \
run_process_media, new_upload_entry run_process_media, new_upload_entry
from mediagoblin.notifications import add_comment_subscription
@require_active_login @require_active_login
def submit_start(request): def submit_start(request):
@ -92,6 +94,8 @@ def submit_start(request):
run_process_media(entry, feed_url) run_process_media(entry, feed_url)
add_message(request, SUCCESS, _('Woohoo! Submitted!')) add_message(request, SUCCESS, _('Woohoo! Submitted!'))
add_comment_subscription(request.user, entry)
return redirect(request, "mediagoblin.user_pages.user_home", return redirect(request, "mediagoblin.user_pages.user_home",
user=request.user.username) user=request.user.username)
except Exception as e: except Exception as e:

View File

@ -34,11 +34,10 @@
{{ csrf_token }} {{ csrf_token }}
<div class="form_box"> <div class="form_box">
<h1>{% trans %}Set your new password{% endtrans %}</h1> <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"> <div class="form_submit_buttons">
<input type="submit" value="{% trans %}Set password{% endtrans %}" class="button_form"/> <input type="submit" value="{% trans %}Set password{% endtrans %}" class="button_form"/>
</div> </div>
</div> </div>
</form>
{% endblock %} {% endblock %}

View File

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

View File

@ -45,11 +45,13 @@
{%- trans %}Create one here!{% endtrans %}</a> {%- trans %}Create one here!{% endtrans %}</a>
</p> </p>
{% endif %} {% endif %}
{{ wtforms_util.render_divs(login_form) }} {{ wtforms_util.render_divs(login_form, True) }}
{% if pass_auth %}
<p> <p>
<a href="{{ request.urlgen('mediagoblin.auth.forgot_password') }}" id="forgot_password"> <a href="{{ request.urlgen('mediagoblin.auth.forgot_password') }}" id="forgot_password">
{% trans %}Forgot your password?{% endtrans %}</a> {% trans %}Forgot your password?{% endtrans %}</a>
</p> </p>
{% endif %}
<div class="form_submit_buttons"> <div class="form_submit_buttons">
<input type="submit" value="{% trans %}Log in{% endtrans %}" class="button_form"/> <input type="submit" value="{% trans %}Log in{% endtrans %}" class="button_form"/>
</div> </div>

View File

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

View File

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

View File

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

View File

@ -46,11 +46,7 @@
{% trans %}Change your password.{% endtrans %} {% trans %}Change your password.{% endtrans %}
</a> </a>
</p> </p>
<div class="form_field_input"> {{ wtforms_util.render_divs(form, True) }}
<p>{{ form.wants_comment_notification }}
{{ wtforms_util.render_label(form.wants_comment_notification) }}</p>
</div>
{{- wtforms_util.render_field_div(form.license_preference) }}
<div class="form_submit_buttons"> <div class="form_submit_buttons">
<input type="submit" value="{% trans %}Save changes{% endtrans %}" class="button_form" /> <input type="submit" value="{% trans %}Save changes{% endtrans %}" class="button_form" />
{{ csrf_token }} {{ 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, user= media.get_uploader.username,
media_id=media.id) %} media_id=media.id) %}
<a class="button_action" href="{{ delete_url }}">{% trans %}Delete{% endtrans %}</a> <a class="button_action" href="{{ delete_url }}">{% trans %}Delete{% endtrans %}</a>
{% endif %} {% endif %}
{% autoescape False %} {% autoescape False %}
<p>{{ media.description_html }}</p> <p>{{ media.description_html }}</p>
@ -94,6 +95,8 @@
class="button_action" id="button_addcomment" title="Add a comment"> class="button_action" id="button_addcomment" title="Add a comment">
{% trans %}Add a comment{% endtrans %} {% trans %}Add a comment{% endtrans %}
</a> </a>
{% include "mediagoblin/utils/comment-subscription.html" %}
{% endif %} {% endif %}
{% if request.user %} {% if request.user %}
<form action="{{ request.urlgen('mediagoblin.user_pages.media_post_comment', <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 %} {%- endmacro %}
{# Generically render a field #} {# Generically render a field #}
{% macro render_field_div(field) %} {% macro render_field_div(field, autofocus_first=False) %}
{{- render_label_p(field) }} {{- render_label_p(field) }}
<div class="form_field_input"> <div class="form_field_input">
{% if autofocus_first %}
{{ field(autofocus=True) }}
{% else %}
{{ field }} {{ field }}
{% endif %}
{%- if field.errors -%} {%- if field.errors -%}
{% for error in field.errors %} {% for error in field.errors %}
<p class="form_field_error">{{ error }}</p> <p class="form_field_error">{{ error }}</p>
{% endfor %} {% endfor %}
{%- endif %} {%- endif %}
{%- if field.description %} {%- if field.description %}
{% if field.type == 'BooleanField' %}
<label for="{{ field.label.field_id }}">{{ field.description|safe }}</label>
{% else %}
<p class="form_field_description">{{ field.description|safe }}</p> <p class="form_field_description">{{ field.description|safe }}</p>
{% endif %}
{%- endif %} {%- endif %}
</div> </div>
{%- endmacro %} {%- endmacro %}
{# Auto-render a form as a series of divs #} {# Auto-render a form as a series of divs #}
{% macro render_divs(form) -%} {% macro render_divs(form, autofocus_first=False) -%}
{% for field in form %} {% for field in form %}
{% if autofocus_first and loop.first %}
{{ render_field_div(field, True) }}
{% else %}
{{ render_field_div(field) }} {{ render_field_div(field) }}
{% endif %}
{% endfor %} {% endfor %}
{%- endmacro %} {%- 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 # 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/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import urlparse import urlparse
import datetime import datetime
import pkg_resources
import pytest
from mediagoblin import mg_globals from mediagoblin import mg_globals
from mediagoblin.auth import lib as auth_lib
from mediagoblin.db.models import User 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 from mediagoblin.tools import template, mail
from mediagoblin.auth import tools as auth_tools
########################
# 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')
def test_register_views(test_app): def test_register_views(test_app):
@ -156,20 +118,15 @@ def test_register_views(test_app):
assert path == u'/auth/verify_email/' assert path == u'/auth/verify_email/'
parsed_get_params = urlparse.parse_qs(get_params) 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 ## Try verifying with bs verification key, shouldn't work
template.clear_test_template_context() template.clear_test_template_context()
response = test_app.get( response = test_app.get(
"/auth/verify_email/?userid=%s&token=total_bs" % unicode( "/auth/verify_email/?token=total_bs")
new_user.id))
response.follow() 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 # assert context['verification_successful'] == True
# TODO: Would be good to test messages here when we can do so... # TODO: Would be good to test messages here when we can do so...
new_user = mg_globals.database.User.find_one( 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] path = urlparse.urlsplit(email_context['verification_url'])[2]
get_params = urlparse.urlsplit(email_context['verification_url'])[3] get_params = urlparse.urlsplit(email_context['verification_url'])[3]
assert path == u'/auth/forgot_password/verify/'
parsed_get_params = urlparse.parse_qs(get_params) parsed_get_params = urlparse.parse_qs(get_params)
assert path == u'/auth/forgot_password/verify/'
# 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
## Try using a bs password-changing verification key, shouldn't work ## Try using a bs password-changing verification key, shouldn't work
template.clear_test_template_context() template.clear_test_template_context()
response = test_app.get( response = test_app.get(
"/auth/forgot_password/verify/?userid=%s&token=total_bs" % unicode( "/auth/forgot_password/verify/?token=total_bs")
new_user.id), status=404) response.follow()
assert response.status.split()[0] == u'404' # status="404 NOT FOUND"
## Try using an expired token to change password, shouldn't work # Correct redirect?
template.clear_test_template_context() assert urlparse.urlsplit(response.location)[2] == '/'
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()
## Verify step 1 of password-change works -- can see form to change password ## Verify step 1 of password-change works -- can see form to change password
template.clear_test_template_context() template.clear_test_template_context()
@ -272,7 +211,6 @@ def test_register_views(test_app):
template.clear_test_template_context() template.clear_test_template_context()
response = test_app.post( response = test_app.post(
'/auth/forgot_password/verify/', { '/auth/forgot_password/verify/', {
'userid': parsed_get_params['userid'],
'password': 'iamveryveryhappy', 'password': 'iamveryveryhappy',
'token': parsed_get_params['token']}) 'token': parsed_get_params['token']})
response.follow() response.follow()
@ -310,7 +248,6 @@ def test_authentication_views(test_app):
context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/auth/login.html'] context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/auth/login.html']
form = context['login_form'] form = context['login_form']
assert form.username.errors == [u'This field is required.'] assert form.username.errors == [u'This field is required.']
assert form.password.errors == [u'This field is required.']
# Failed login - blank user # Failed login - blank user
# ------------------------- # -------------------------
@ -328,9 +265,7 @@ def test_authentication_views(test_app):
response = test_app.post( response = test_app.post(
'/auth/login/', { '/auth/login/', {
'username': u'chris'}) 'username': u'chris'})
context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/auth/login.html'] assert 'mediagoblin/auth/login.html' in template.TEMPLATE_TEST_CONTEXT
form = context['login_form']
assert form.password.errors == [u'This field is required.']
# Failed login - bad user # Failed login - bad user
# ----------------------- # -----------------------
@ -394,3 +329,47 @@ def test_authentication_views(test_app):
'password': 'toast', 'password': 'toast',
'next' : '/u/chris/'}) 'next' : '/u/chris/'})
assert urlparse.urlsplit(response.location)[2] == '/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 isinstance(fake_celery_module.CELERYD_ETA_SCHEDULER_PRECISION, float)
assert fake_celery_module.CELERY_RESULT_PERSISTENT is True assert fake_celery_module.CELERY_RESULT_PERSISTENT is True
assert fake_celery_module.CELERY_IMPORTS == [ 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_BACKEND == 'database'
assert fake_celery_module.CELERY_RESULT_DBURI == ( assert fake_celery_module.CELERY_RESULT_DBURI == (
'sqlite:///' + 'sqlite:///' +

View File

@ -15,13 +15,13 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import urlparse import urlparse
import pytest
from mediagoblin import mg_globals from mediagoblin import mg_globals
from mediagoblin.db.models import User from mediagoblin.db.models import User
from mediagoblin.tests.tools import fixture_add_user from mediagoblin.tests.tools import fixture_add_user
from mediagoblin.tools import template from mediagoblin import auth
from mediagoblin.auth.lib import bcrypt_check_password from mediagoblin.tools import template, mail
class TestUserEdit(object): class TestUserEdit(object):
def setup(self): 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 has to be fetched again in order to have the current values
test_user = User.query.filter_by(username=u'chris').first() 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 # Update current user passwd
self.user_password = '123456' self.user_password = '123456'
@ -88,7 +88,7 @@ class TestUserEdit(object):
}) })
test_user = User.query.filter_by(username=u'chris').first() 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): def test_change_bio_url(self, test_app):
@ -141,4 +141,68 @@ class TestUserEdit(object):
assert form.url.errors == [ assert form.url.errors == [
u'This address contains 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 # 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.oauth]]
[[mediagoblin.plugins.httpapiauth]] [[mediagoblin.plugins.httpapiauth]]
[[mediagoblin.plugins.piwigo]] [[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_a = fixture_add_user(u"chris_a")
user_b = fixture_add_user(u"chris_b") user_b = fixture_add_user(u"chris_b")
media_a = fixture_media_entry(uploader=user_a.id, save=False) media_a = fixture_media_entry(uploader=user_a.id, save=False,
media_b = fixture_media_entry(uploader=user_b.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_a)
Session.add(media_b) Session.add(media_b)
Session.flush() Session.flush()
@ -79,7 +81,7 @@ def test_user_deletes_other_comments(test_app):
def test_media_deletes_broken_attachment(test_app): def test_media_deletes_broken_attachment(test_app):
user_a = fixture_add_user(u"chris_a") 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( media.attachment_files.append(dict(
name=u"some name", name=u"some name",
filepath=[u"does", u"not", u"exist"], 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/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import sys
import os import os
import pkg_resources import pkg_resources
import shutil import shutil
from functools import wraps
from paste.deploy import loadapp from paste.deploy import loadapp
from webtest import TestApp from webtest import TestApp
from mediagoblin import mg_globals 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.tools import testing
from mediagoblin.init.config import read_mediagoblin_config from mediagoblin.init.config import read_mediagoblin_config
from mediagoblin.db.base import Session from mediagoblin.db.base import Session
from mediagoblin.meddleware import BaseMeddleware 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 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', 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 # Reuse existing user or create a new one
test_user = User.query.filter_by(username=username).first() test_user = User.query.filter_by(username=username).first()
if test_user is None: 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.username = username
test_user.email = username + u'@example.com' test_user.email = username + u'@example.com'
if password is not None: 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: if active_user:
test_user.email_verified = True test_user.email_verified = True
test_user.status = u'active' test_user.status = u'active'
test_user.wants_comment_notification = wants_comment_notification
test_user.save() test_user.save()
# Reload # Reload
@ -195,19 +196,79 @@ def fixture_add_user(username=u'chris', password=u'toast',
return test_user 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, 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 = MediaEntry()
entry.title = title entry.title = title
entry.slug = slug entry.slug = slug
entry.uploader = uploader or fixture_add_user().id entry.uploader = uploader
entry.media_type = u'image' 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: if gen_slug:
entry.generate_slug() entry.generate_slug()
if save: if save:
entry.save() entry.save()
if expunge:
entry = MediaEntry.query.filter_by(id=entry.id).first()
Session.expunge(entry)
return entry return entry
@ -231,3 +292,25 @@ def fixture_add_collection(name=u"My first Collection", user=None):
return coll 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']: if common.TESTS_ENABLED or mg_globals.app_config['email_debug_mode']:
mhost = FakeMhost() mhost = FakeMhost()
elif not mg_globals.app_config['email_debug_mode']: 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_host'],
mg_globals.app_config['email_smtp_port']) 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: elif stock_desc and exc.code == 404:
return render_404(request) 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, err_msg=description,
status=exc.code) 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['app_config'] = mg_globals.app_config
template_env.globals['global_config'] = mg_globals.global_config template_env.globals['global_config'] = mg_globals.global_config
template_env.globals['version'] = _version.__version__ template_env.globals['version'] = _version.__version__
template_env.globals['auth'] = mg_globals.app.auth
template_env.filters['urlencode'] = url_quote_plus 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.translate import pass_to_ugettext as _
from mediagoblin.tools.pagination import Pagination from mediagoblin.tools.pagination import Pagination
from mediagoblin.user_pages import forms as user_forms from mediagoblin.user_pages import forms as user_forms
from mediagoblin.user_pages.lib import (send_comment_email, from mediagoblin.user_pages.lib import add_media_to_collection
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, from mediagoblin.decorators import (uses_pagination, get_user_media_entry,
get_media_entry_by_id, 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) get_user_collection, get_user_collection_item, active_user_from_url)
from werkzeug.contrib.atom import AtomFeed from werkzeug.contrib.atom import AtomFeed
from werkzeug.exceptions import MethodNotAllowed
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
@ -110,6 +112,7 @@ def user_gallery(request, page, url_user=None):
'media_entries': media_entries, 'media_entries': media_entries,
'pagination': pagination}) 'pagination': pagination})
MEDIA_COMMENTS_PER_PAGE = 50 MEDIA_COMMENTS_PER_PAGE = 50
@ -121,6 +124,9 @@ def media_home(request, media, page, **kwargs):
""" """
comment_id = request.matchdict.get('comment', None) comment_id = request.matchdict.get('comment', None)
if comment_id: if comment_id:
if request.user:
mark_comment_notification_seen(comment_id, request.user)
pagination = Pagination( pagination = Pagination(
page, media.get_comments( page, media.get_comments(
mg_globals.app_config['comments_ascending']), 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. 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 = request.db.MediaComment()
comment.media_entry = media.id comment.media_entry = media.id
@ -179,11 +186,9 @@ def media_post_comment(request, media):
request, messages.SUCCESS, request, messages.SUCCESS,
_('Your comment has been posted!')) _('Your comment has been posted!'))
media_uploader = media.get_uploader trigger_notification(comment, media, request)
#don't send email if you comment on your own post
if (comment.author != media_uploader and add_comment_subscription(request.user, media)
media_uploader.wants_comment_notification):
send_comment_email(media_uploader, comment, media, request)
return redirect_obj(request, 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 # 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/>. # 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 os
import re import re
@ -36,7 +36,7 @@ def get_version():
setup( setup(
name="mediagoblin", name="mediagoblin",
version=get_version(), version=get_version(),
packages=['mediagoblin'], packages=find_packages(exclude=['ez_setup', 'examples', 'tests']),
zip_safe=False, zip_safe=False,
include_package_data = True, include_package_data = True,
# scripts and dependencies # scripts and dependencies
@ -57,7 +57,7 @@ setup(
'webtest<2', 'webtest<2',
'ConfigObj', 'ConfigObj',
'Markdown', 'Markdown',
'sqlalchemy>=0.7.0', 'sqlalchemy>=0.8.0',
'sqlalchemy-migrate', 'sqlalchemy-migrate',
'mock', 'mock',
'itsdangerous', 'itsdangerous',