diff --git a/.gitignore b/.gitignore index 47e079a..fa45eca 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,8 @@ nbactions.xml ################# .idea +build +copy-test +deploy-test +deploy2coppa +/CloudSession-Templates.tar.gz diff --git a/Failures.py b/Failures.py index d12ed9f..c97b29c 100644 --- a/Failures.py +++ b/Failures.py @@ -98,11 +98,17 @@ def screen_name_already_in_use(screen_name): }, 500 -def rate_exceeded(): +def rate_exceeded(time): + """ + Service requested to frequently. + + time - string representing the date and time the service will be available again + """ logging.debug('Failures: Rate exceeded') return { 'success': False, 'message': 'Insufficient bucket tokens', + 'data': time, 'code': 470 }, 500 diff --git a/app/AuthToken/controllers.py b/app/AuthToken/controllers.py index 5f0664a..0fb64fa 100644 --- a/app/AuthToken/controllers.py +++ b/app/AuthToken/controllers.py @@ -1,10 +1,9 @@ # Import the database object from the main app module -import json import logging import uuid import datetime - import Failures + from app import db from app.User import services as user_service diff --git a/app/AuthToken/models.py b/app/AuthToken/models.py index dd6e1d6..bea10aa 100644 --- a/app/AuthToken/models.py +++ b/app/AuthToken/models.py @@ -2,7 +2,6 @@ # We will define this inside /app/__init__.py in the next sections. from app import db - class AuthenticationToken(db.Model): id = db.Column(db.BigInteger, primary_key=True) id_user = db.Column(db.BigInteger, db.ForeignKey('user.id')) diff --git a/app/Authenticate/controllers.py b/app/Authenticate/controllers.py index 836517d..efc02a6 100644 --- a/app/Authenticate/controllers.py +++ b/app/Authenticate/controllers.py @@ -1,9 +1,7 @@ # Import the database object from the main app module import logging -import uuid -import datetime - import Failures + from app import db from app.User import services as user_services from app.RateLimiting import services as rate_limiting_services @@ -66,7 +64,11 @@ def post(self): 'email': user.email, 'locale': user.locale, 'screenname': user.screen_name, - 'authentication-source': user.auth_source + 'authentication-source': user.auth_source, + 'bdmonth': user.birth_month, + 'bdyear': user.birth_year, + 'parent-email': user.parent_email, + 'parent-email-source': user.parent_email_source }} api.add_resource(AuthenticateLocalUser, '/local') diff --git a/app/Email/services.py b/app/Email/services.py index 0f13575..54d5df9 100644 --- a/app/Email/services.py +++ b/app/Email/services.py @@ -1,45 +1,102 @@ -from app import mail, app, db - +from app import mail, app from os.path import expanduser, isfile - from flask.ext.mail import Message +from app.User.coppa import Coppa, SponsorType import pystache import logging +""" +TODO: System documentation goes here +""" + + def send_email_template_for_user(id_user, template, server, **kwargs): from app.User.services import get_user - logging.info("Sending email to user: %s (%s)", id_user, template) + # Get a copy of the user record + logging.info("Checking for a valid user record for user ID: %s", id_user) + user = get_user(id_user) + + if user is None: + logging.error("Cannot send email: Invalid user record") + return False + else: + logging.info("Valid record found for user: %s", user.id) + + logging.info("Sending email to user: %s using template: '%s'.", user.id, template) params = {} for key, value in kwargs.items(): + logging.debug("Logging parameter %s = %s", key, value) params[key] = value - user = get_user(id_user) - if user is None: - return False - + # The elements in the params array represent the data elements that are + # available to the email templates. params['screenname'] = user.screen_name + params['email'] = user.email + params['registrant-email'] = user.email + params['sponsoremail'] = user.parent_email + params['blocklyprop-host'] = app.config['CLOUD_SESSION_PROPERTIES']['response.host'] + + #Default the recipient email address + user_email = user.email + coppa = Coppa() + + # Send email to parent if user is under 13 years old + if template == 'confirm' and coppa.is_coppa_covered(user.birth_month, user.birth_year): + # Send email only to the sponsor address + user_email = user.parent_email + logging.info("COPPA account has a sponsor type of %s", user.parent_email_source) + + if user.parent_email_source == SponsorType.TEACHER: + # Teacher handles the account confirmation + send_email_template_to_address(user_email, 'confirm-teacher', server, user.locale, params) + elif user.parent_email_source == SponsorType.PARENT or\ + user.parent_email_source == SponsorType.GUARDIAN: + # Parent handles the account confirmation + send_email_template_to_address(user_email, 'confirm-parent', server, user.locale, params) + else: + logging.info("COPPA account %s has invalid sponsor type [%s]", user.id, user.parent_email_source) - send_email_template_to_address(user.email, template, server, user.locale, params) + return + elif template == 'reset' and coppa.is_coppa_covered(user.birth_month, user.birth_year): + # Send email only to the sponsor address + logging.info("COPPA account has a sponsor type of %s", user.parent_email_source) + + # Send password reset to student and parent + send_email_template_to_address(user.email, 'reset-coppa', server, user.locale, params) + send_email_template_to_address(user.parent_email, 'reset-coppa', server, user.locale, params) + return + else: + # Registration not subject to COPPA regulations + send_email_template_to_address(user_email, template, server, user.locale, params) + + return def send_email_template_to_address(recipient, template, server, locale, params=None, **kwargs): - # Read templates + logging.info("Preparing email template: %s", template) params = params or {} + + # Add any supplied arguments to the parameter dictionary for key, value in kwargs.items(): params[key] = value + params['email'] = recipient params['locale'] = locale + # Read templates (subject, plain, rich) = _read_templates(template, server, locale, params) + # Add error checking here to detect any issues with parsing the template. + logging.info("Sending email to %s", recipient) send_email(recipient, subject, plain, rich) def send_email(recipient, subject, email_text, rich_email_text=None): + msg = Message( recipients=[recipient], subject=subject.rstrip(), @@ -51,8 +108,13 @@ def send_email(recipient, subject, email_text, rich_email_text=None): def _read_templates(template, server, locale, params): + logging.info("Loading header text for template: %s", template) header = _read_template(template, server, locale, 'header', params) + + logging.info("Loading plain message text for template: %s", template) plain = _read_template(template, server, locale, 'plain', params) + + logging.info("Loading rich message text for template: %s", template) rich = _read_template(template, server, locale, 'rich', params, True) return header, plain, rich @@ -73,10 +135,21 @@ def _read_template(template, server, locale, part, params, none_if_missing=False error message if the none_is_missing flag is false """ template_file = expanduser("~/templates/%s/%s/%s/%s.mustache" % (locale, template, server, part)) + if isfile(template_file): logging.debug('Looking for template file: %s', template_file) + renderer = pystache.Renderer() - rendered = renderer.render_path(template_file, params) + + logging.debug('Rendering the template file') + try: + rendered = renderer.render_path(template_file, params) + except Exception as ex: + logging.error('Unable to render template file %s', template_file) + logging.error('Error message: %s', ex.message) + return 'Template format error.' + + logging.debug('Returning rendered template file.') return rendered else: logging.warn('Looking for template file: %s, but the file is missing', template_file) diff --git a/app/LocalUser/controllers.py b/app/LocalUser/controllers.py index ba0a45e..e3ec51d 100644 --- a/app/LocalUser/controllers.py +++ b/app/LocalUser/controllers.py @@ -46,7 +46,7 @@ def post(self): confirm_token = ConfirmToken.query.filter_by(token=token).first() if confirm_token is None: - # Unkown token + # Unknown token return {'success': False, 'code': 510} if confirm_token.id_user != user.id: # Token is not for this user @@ -65,9 +65,11 @@ def post(self): class RequestConfirm(Resource): def get(self, email): - # Get values + # Get server URL server = request.headers.get('server') + logging.info("Requesting email confirmation for %s from server %s", email, server) + # Validate required fields validation = Validation() validation.add_required_field('email', email) @@ -95,11 +97,18 @@ def get(self, email): else: if code == 10: return Failures.rate_exceeded() - return { - 'success': False, - 'message': message, - 'code': 520 - } + elif code == 99: + return { + 'success': False, + 'message': message, + 'code': 540 + } + else: + return { + 'success': False, + 'message': message, + 'code': 520 + } class PasswordReset(Resource): diff --git a/app/RateLimiting/controllers.py b/app/RateLimiting/controllers.py index 2e7b972..989e183 100644 --- a/app/RateLimiting/controllers.py +++ b/app/RateLimiting/controllers.py @@ -44,12 +44,16 @@ def get(self, bucket_type, id_user): return Failures.email_not_confirmed() bucket_types = app.config['CLOUD_SESSION_PROPERTIES']['bucket.types'].split(',') + if bucket_type not in bucket_types: return Failures.unknown_bucket_type(bucket_type) - if not rate_limiting_services.consume_tokens(user.id, bucket_type, 1): + # Decrement a token count + result, next_time = rate_limiting_services.consume_tokens(user.id, bucket_type, 1) + + if not result: db.session.commit() - return Failures.rate_exceeded() + return Failures.rate_exceeded(next_time.strftime("%Y-%m-%d %H:%M:%S")) db.session.commit() @@ -82,6 +86,7 @@ def get(self, bucket_type, id_user, count): # Validate user exists, is validated and is not blocked user = user_services.get_user(id_user) + if user is None: return Failures.unknown_user_id(id_user) if user.blocked: @@ -90,12 +95,15 @@ def get(self, bucket_type, id_user, count): return Failures.email_not_confirmed() bucket_types = app.config['CLOUD_SESSION_PROPERTIES']['bucket.types'].split(',') + if bucket_type not in bucket_types: return Failures.unknown_bucket_type(bucket_type) - if not rate_limiting_services.consume_tokens(user.id, bucket_type, 1): + result, next_time = rate_limiting_services.consume_tokens(user.id, bucket_type, 1) + + if not result: db.session.commit() - return Failures.rate_exceeded() + return Failures.rate_exceeded(next_time.strftime("%Y-%m-%d %H:%M:%S")) db.session.commit() diff --git a/app/RateLimiting/services.py b/app/RateLimiting/services.py index 8507ced..ec75319 100644 --- a/app/RateLimiting/services.py +++ b/app/RateLimiting/services.py @@ -36,11 +36,11 @@ def consume_tokens(id_user, bucket_type, token_count): milliseconds_till_enough = (token_count - old_bucket_content) * bucket_stream_frequency date_when_enough = bucket.timestamp + datetime.timedelta(milliseconds=milliseconds_till_enough) # Log and return or throw error - return False + return False, date_when_enough bucket.content = bucket.content - token_count bucket.timestamp = datetime.datetime.now() - return True + return True, bucket.timestamp def has_sufficient_tokens(id_user, bucket_type, token_count): @@ -70,6 +70,6 @@ def has_sufficient_tokens(id_user, bucket_type, token_count): milliseconds_till_enough = (token_count - old_bucket_content) * bucket_stream_frequency date_when_enough = bucket.timestamp + datetime.timedelta(milliseconds=milliseconds_till_enough) # Log and return or throw error - return False + return False, date_when_enough return True diff --git a/app/User/controllers.py b/app/User/controllers.py index c4c8dbc..769baf9 100644 --- a/app/User/controllers.py +++ b/app/User/controllers.py @@ -10,12 +10,14 @@ from Validation import Validation from app.User import services as user_service -from models import User +# from models import User +# Define the endpoint prefix for user services user_app = Blueprint('user', __name__, url_prefix='/user') api = Api(user_app) +# Register a new user class Register(Resource): def post(self): @@ -27,6 +29,12 @@ def post(self): locale = request.form.get('locale') screen_name = request.form.get('screenname') + # COPPA support + birth_month = request.form.get('bdmonth') + birth_year = request.form.get('bdyear') + parent_email = request.form.get('parent-email') + parent_email_source = request.form.get('parent-email-source') + # Validate required fields validation = Validation() validation.add_required_field('server', server) @@ -35,6 +43,16 @@ def post(self): validation.add_required_field('password-confirm', password_confirm) validation.add_required_field('locale', locale) validation.add_required_field('screenname', screen_name) + + # COPPA support + validation.add_required_field('bdmonth', birth_month) + validation.add_required_field('bdyear', birth_year) + if parent_email: + validation.check_email('parent-email', parent_email) + if not validation.is_valid(): + return validation.get_validation_response() + + # Verify user email address validation.check_email('email', email) if not validation.is_valid(): return validation.get_validation_response() @@ -55,15 +73,23 @@ def post(self): if not user_service.check_password_complexity(password): return Failures.password_complexity() - id_user = user_service.create_local_user(server, email, password, locale, screen_name) - user_service.send_email_confirm(id_user, server) - - db.session.commit() + # Write user details to the database + id_user = user_service.create_local_user( + server, email, password, locale, screen_name, + birth_month, birth_year, parent_email, parent_email_source) - logging.info('User-controller: register success: %s', id_user) + # Send a confirmation request email to user or parent + (result, errno, mesg) = user_service.send_email_confirm(id_user, server) + if result: + # Commit the database record + db.session.commit() + logging.info('User-controller: register success: %s', id_user) - # Create user - return {'success': True, 'user': id_user} + # Create user + return {'success': True, 'user': id_user} + else: + logging.error("Unable to register user. Error %s: %s", errno, mesg) + return {'success': False, 'user': 0} class GetUserById(Resource): @@ -72,7 +98,7 @@ def get(self, id_user): # Parse numbers try: id_user = int(id_user) - except: + except ValueError: return Failures.not_a_number('idUser', id_user) # Validate user exists, is validated and is not blocked @@ -87,7 +113,11 @@ def get(self, id_user): 'email': user.email, 'locale': user.locale, 'screenname': user.screen_name, - 'authentication-source': user.auth_source + 'authentication-source': user.auth_source, + 'bdmonth': user.birth_month, + 'bdyear': user.birth_year, + 'parent-email': user.parent_email, + 'parent-email-source': user.parent_email_source }} @@ -106,7 +136,11 @@ def get(self, email): 'email': user.email, 'locale': user.locale, 'screenname': user.screen_name, - 'authentication-source': user.auth_source + 'authentication-source': user.auth_source, + 'bdmonth': user.birth_month, + 'bdyear': user.birth_year, + 'parent-email': user.parent_email, + 'parent-email-source': user.parent_email_source }} @@ -125,7 +159,11 @@ def get(self, screen_name): 'email': user.email, 'locale': user.locale, 'screenname': user.screen_name, - 'authentication-source': user.auth_source + 'authentication-source': user.auth_source, + 'bdmonth': user.birth_month, + 'bdyear': user.birth_year, + 'parent-email': user.parent_email, + 'parent-email-source': user.parent_email_source }} @@ -133,6 +171,7 @@ class DoInfoChange(Resource): def post(self, id_user): screen_name = request.form.get('screenname') + # Validate required fields validation = Validation() validation.add_required_field('id-user', id_user) @@ -143,7 +182,7 @@ def post(self, id_user): # Parse numbers try: id_user = int(id_user) - except: + except ValueError: return Failures.not_a_number('idUser', id_user) # Validate user exists, is validated and is not blocked @@ -166,7 +205,11 @@ def post(self, id_user): 'email': user.email, 'locale': user.locale, 'screenname': user.screen_name, - 'authentication-source': user.auth_source + 'authentication-source': user.auth_source, + 'bdmonth': user.birth_month, + 'bdyear': user.birth_year, + 'parent-email': user.parent_email, + 'parent-email-source': user.parent_email_source }} @@ -174,6 +217,7 @@ class DoLocaleChange(Resource): def post(self, id_user): locale = request.form.get('locale') + # Validate required fields validation = Validation() validation.add_required_field('id-user', id_user) @@ -184,7 +228,7 @@ def post(self, id_user): # Parse numbers try: id_user = int(id_user) - except: + except ValueError: return Failures.not_a_number('idUser', id_user) # Validate user exists, is validated and is not blocked @@ -202,13 +246,28 @@ def post(self, id_user): 'email': user.email, 'locale': user.locale, 'screenname': user.screen_name, - 'authentication-source': user.auth_source + 'authentication-source': user.auth_source, + 'bdmonth': user.birth_month, + 'bdyear': user.birth_year, + 'parent-email': user.parent_email, + 'parent-email-source': user.parent_email_source }} +# Supported endpoints +# Note: The url_prefix is '/user'. All user endpoints are in the form +# of host:port/user/_service_ +# +# Register a new user account api.add_resource(Register, '/register') + +# Retrieve details about an existing user account api.add_resource(GetUserById, '/id/') api.add_resource(GetUserByEmail, '/email/') api.add_resource(GetUserByScreenname, '/screenname/') + +# Update a user screen name api.add_resource(DoInfoChange, '/info/') + +# Update the local defined in the user account api.add_resource(DoLocaleChange, '/locale/') diff --git a/app/User/coppa.py b/app/User/coppa.py new file mode 100644 index 0000000..88fa09a --- /dev/null +++ b/app/User/coppa.py @@ -0,0 +1,38 @@ + +import datetime + +# Enumerate the sponsor types for COPPA eligible user accounts +class SponsorType: + INDIVIDUAL = 0 + PARENT = 1 + GUARDIAN = 2 + TEACHER = 3 + + def __init__(self): + pass + + +class Coppa: + def __init__(self): + pass + + # Return true if the date is less than 13 years + def is_coppa_covered(self, month, year): + # This is the number of months a typical thirteen years old has been on planet Earth. + cap = 156 + + # This is the actual number of months a typical user has been on the same planet. + user_age = (year * 12) + month + + # Current year and month + current_month = datetime.date.today().month + current_year = datetime.date.today().year + + # This represents the number of months since the inception of AD + # Unless you want to count that first year as part of BC. + current_cap = (current_year * 12) + current_month + + if current_cap - user_age > cap: + return False + else: + return True diff --git a/app/User/models.py b/app/User/models.py index 08310b0..9a7da45 100644 --- a/app/User/models.py +++ b/app/User/models.py @@ -2,6 +2,28 @@ class User(db.Model): + """ + User madel provides a direct mapping to the underlying + database. + + Version 1 deploys the following fields: + id (BigInteger) - Unique record identifier + email (String(250)) - User email address + password (String(100)) - User account password + salt (String(50)) - A hash used to encrypt password + auth_source (String(250)) - Identifier for system providing authentication + locale (String(50)) - User language + blocked (Boolean) - Flag to indicate account is disabled + confirmed (Boolean) - Flag to indicate account has been verified + screen_name (String(250)) - Unique user screen name + + Version 2 adds support for US COPPA compliance. The following fields were added: + birth_month (INTEGER) - User birth month + birth_year (INTEGER) - User birth year + parent_email (String(250)) - Sponsor email address + parent_email_source (INTEGER) - Classification of sponsor email address + + """ id = db.Column(db.BigInteger, primary_key=True) email = db.Column(db.String(250), unique=True) password = db.Column(db.String(100)) @@ -12,9 +34,16 @@ class User(db.Model): confirmed = db.Column(db.Boolean) screen_name = db.Column(db.String(250)) + # COPPA support + birth_month = db.Column(db.INTEGER, nullable=False) + birth_year = db.Column(db.INTEGER, nullable=False) + parent_email = db.Column(db.String(250)) + parent_email_source = db.Column(db.INTEGER) + def __init__(self): self.blocked = False self.confirmed = False + self.version = 2 def __repr__(self): return '' % self.email diff --git a/app/User/services.py b/app/User/services.py index 1a71da8..d72d0be 100644 --- a/app/User/services.py +++ b/app/User/services.py @@ -1,6 +1,6 @@ import hashlib import uuid - +import logging import datetime from app import db, app @@ -11,6 +11,10 @@ from models import User, ConfirmToken, ResetToken +def get_user(id_user): + return User.query.get(id_user) + + def get_password_hash(password): salt = str(uuid.uuid1()) password_hash = hashlib.sha256("%s:%s" % (password, salt)).hexdigest() @@ -23,10 +27,6 @@ def check_password(id_user, password): return user.password == password_hash -def get_user(id_user): - return User.query.get(id_user) - - def get_user_by_email(email): return User.query.filter_by(email=email).first() @@ -39,7 +39,10 @@ def check_password_complexity(password): return 8 <= len(password) < 200 -def create_local_user(server, email, password, locale, screen_name): +def create_local_user( + server, email, password, locale, screen_name, + birth_month, birth_year, parent_email, parent_email_source): + salt, password_hash = get_password_hash(password) # Save user @@ -51,6 +54,12 @@ def create_local_user(server, email, password, locale, screen_name): user.password = password_hash user.salt = salt + #COPPA support + user.birth_month = birth_month + user.birth_year = birth_year + user.parent_email = parent_email + user.parent_email_source = parent_email_source + db.session.add(user) db.session.flush() db.session.refresh(user) @@ -58,7 +67,10 @@ def create_local_user(server, email, password, locale, screen_name): return user.id -def create_oauth_user(server, email, source, locale, screen_name): +def create_oauth_user( + server, email, source, locale, screen_name, + birth_month, birth_year, parent_email, parent_email_source): + # Save user user = User() user.email = email @@ -68,6 +80,13 @@ def create_oauth_user(server, email, source, locale, screen_name): user.confirmed = True user.blocked = False + # COPPA support + user.birth_month = birth_month + user.birth_year = birth_year + user.parent_email = parent_email + user.parent_email_source = parent_email_source + + # Add the user record db.session.add(user) db.session.flush() db.session.refresh(user) @@ -76,16 +95,24 @@ def create_oauth_user(server, email, source, locale, screen_name): def send_email_confirm(id_user, server): + logging.info("Preparing new account confirmation email for user %s", id_user) + logging.info("Account request received from server: %s", server) + user = get_user(id_user) + if user is None: + logging.debug("Unknown user id: %s", id_user) return False, 1, 'User id not known' if user.confirmed: + logging.debug("User account %s has already been verified", id_user) return False, 2, 'Account already verified' if user.blocked: + logging.debug("User account %s has been blocked", id_user) return False, 3, 'Account Blocked' # check rate limiting if not rate_limiting_services.consume_tokens(id_user, 'email-confirm', 1): + logging.debug("Too many attempts to confirm account for user %s", id_user) return False, 10, 'Rate limiter exceeded' # Delete token if any exists @@ -103,7 +130,14 @@ def send_email_confirm(id_user, server): confirm_token.validity = datetime.datetime.now() + datetime.timedelta(hours=token_validity_time) db.session.add(confirm_token) - email_services.send_email_template_for_user(id_user, 'confirm', server, token=token) + try: + logging.info("Sending account confirmation email to user: %s ", id_user) + # Send an email to the user or user's responsible party to confirm the account request + email_services.send_email_template_for_user(id_user, 'confirm', server, token=token) + logging.info("Completed email send process.") + except Exception as ex: + logging.error("Error while sending email: %s", ex.message) + return False, 99, 'Unable to contact SMTP server' return True, 0, 'Success' @@ -137,3 +171,4 @@ def send_password_reset(id_user, server): email_services.send_email_template_for_user(id_user, 'reset', server, token=token) return True, 0, 'Success' + diff --git a/app/__init__.py b/app/__init__.py index 0ca2971..2608dea 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,17 +1,19 @@ -# Import flask and template operators +""" +Cloud Session server application initialization + +""" # Import properties files utils import logging from ConfigParser import ConfigParser - -import os from FakeSecHead import FakeSecHead from os.path import expanduser, isfile # Import Flask -from flask import Flask, render_template +# from flask import Flask, render_template +from flask import Flask -# Import SQLAlchemy +# Import SQLAlchemy database mapper from flask.ext.sqlalchemy import SQLAlchemy # Import Mail @@ -22,6 +24,10 @@ app = Flask(__name__) +# Application version (major,minor,patch-level) +version = "1.1.2" +db = None + # Load basic configurations app.config.from_object('config') @@ -29,6 +35,9 @@ defaults = { 'database.url': 'mysql+mysqldb://cloudsession:cloudsession@localhost:3306/cloudsession', + 'request.host': 'http://localhost:8080/blockly', + 'response.host': 'localhost', + 'sentry-dsn': None, 'mail.host': 'localhost', @@ -51,15 +60,18 @@ 'bucket.password-reset.size': '2', 'bucket.password-reset.input': '1', - 'bucket.password-reset.freq': '1800000', + 'bucket.password-reset.freq': '600000', 'bucket.email-confirm.size': '2', 'bucket.email-confirm.input': '1', 'bucket.email-confirm.freq': '1800000' } +logging.basicConfig(level=logging.DEBUG) + configfile = expanduser("~/cloudsession.properties") -print('Looking for config file: %s' % configfile) +logging.info('Looking for config file: %s', configfile) + if isfile(configfile): configs = ConfigParser(defaults) configs.readfp(FakeSecHead(open(configfile))) @@ -67,14 +79,15 @@ app_configs = {} for (key, value) in configs.items('section'): app_configs[key] = value + logging.info("Key:%s, Value:%s", key, value) + app.config['CLOUD_SESSION_PROPERTIES'] = app_configs + else: app.config['CLOUD_SESSION_PROPERTIES'] = defaults # -------------------------------------- Module initialization ------------------------------------------------- -logging.basicConfig(level=logging.DEBUG) - if app.config['CLOUD_SESSION_PROPERTIES']['sentry-dsn'] is not None: logging.info("Initializing Sentry") sentry = Sentry(app, @@ -85,14 +98,14 @@ else: logging.info("No Sentry configuration") - -app.config['SQLALCHEMY_DATABASE_URI'] = app.config['CLOUD_SESSION_PROPERTIES']['database.url'] - # Define the database object which is imported # by modules and controllers -logging.info("Initializing database connection") +# logging.info("Initializing database connection") +app.config['SQLALCHEMY_DATABASE_URI'] = app.config['CLOUD_SESSION_PROPERTIES']['database.url'] db = SQLAlchemy(app) + +# logging.info("Configuring SMTP properties") app.config['MAIL_SERVER'] = app.config['CLOUD_SESSION_PROPERTIES']['mail.host'] if app.config['CLOUD_SESSION_PROPERTIES']['mail.port'] is None: if app.config['CLOUD_SESSION_PROPERTIES']['mail.tls']: @@ -101,6 +114,7 @@ app.config['MAIL_PORT'] = 25 else: app.config['MAIL_PORT'] = app.config['CLOUD_SESSION_PROPERTIES']['mail.port'] + app.config['MAIL_USE_TLS'] = app.config['CLOUD_SESSION_PROPERTIES']['mail.tls'] app.config['MAIL_USE_SSL'] = app.config['CLOUD_SESSION_PROPERTIES']['mail.ssl'] app.config['MAIL_DEBUG'] = app.config['CLOUD_SESSION_PROPERTIES']['mail.debug'] @@ -108,18 +122,27 @@ app.config['MAIL_PASSWORD'] = app.config['CLOUD_SESSION_PROPERTIES']['mail.password'] app.config['DEFAULT_MAIL_SENDER'] = app.config['CLOUD_SESSION_PROPERTIES']['mail.from'] -#app.debug = True + logging.info("Initializing mail") +logging.info("SMTP port: %s", app.config['MAIL_PORT']) +logging.info("TLS: %s",app.config['MAIL_USE_TLS']) +logging.info("SSL: %s",app.config['MAIL_USE_SSL']) +logging.info("Sender: %s",app.config['DEFAULT_MAIL_SENDER']) + mail = Mail(app) # -------------------------------------------- Services -------------------------------------------------------- logging.info("Initializing services") -from app.AuthToken.controllers import auth_token_app -from app.Authenticate.controllers import authenticate_app -from app.User.controllers import user_app -from app.LocalUser.controllers import local_user_app -from app.RateLimiting.controllers import rate_limiting_app -from app.OAuth.controllers import oauth_app + +# All of these imports need the database +if db is not None: + from app.Authenticate.controllers import authenticate_app + from app.AuthToken.controllers import auth_token_app + from app.User.controllers import user_app + from app.LocalUser.controllers import local_user_app + from app.RateLimiting.controllers import rate_limiting_app + from app.OAuth.controllers import oauth_app + app.register_blueprint(auth_token_app) app.register_blueprint(authenticate_app) diff --git a/cloudsession.properties.example b/cloudsession.properties.example index f2898f3..0ff000d 100644 --- a/cloudsession.properties.example +++ b/cloudsession.properties.example @@ -9,17 +9,56 @@ database.password = database_user_password # SMTP server configuration mail.host = smtp.example.com +mail.port = 25 mail.from = no_reply@example.com mail.authenticated = true mail.user = authenticated_username mail.password = authenticated_user_password +mail.ssl = false mail.tls = true # The email notification system relies on a number of templates # to produce the email messages sent to users. email.template.path = /usr/share/tomcat7/templates +# When a new account confirmation email is created, a security +# token is created and attached to the confirmation request. +# The token has a default lifespan of 12 hours. The default +# can be adjusted here. +# ---------------------------------------------------------- +confirm-token-validity-hours = 48 + + +# When a password reset request has been received, a security +# token is created and attached to an email sent to the user. +# The token has a default lifespan of 12 hours. The default +# value can be adjusted with this setting. +# ----------------------------------------------------------- +reset-token-validity-hours = 4 + +# Bucket types are objects that can be applied to the rate-limiting service built +# into the server. Bucket types are arbitrary units. There can be more than one +# bucket type defined. +# --------------------------------------------------------------------------------- +bucket.types = compile + # Rate limiting +# Various buckets can be defined to limit the use of specific +# system resources and access to specific system features. +# Each bucket has three characteristics; size, input and +# frequency. These are defined as: +# +# size = Sets the number of time the access or feature can +# be used before the system stops listening to that +# user's requests for access or service. +# +# input = Sets the number of additional tokens are available +# at each interval as defined in 'freq'. +# +# freq = Set the interval, in milliseconds, that system will +# wait until adding tokens as set in 'input'. +# --------------------------------------------------------------- + # Starting number of compiles bucket.compile.size = 100 @@ -32,7 +71,3 @@ bucket.compile.freq = 500000 # Enable detailed statistics on applicaiton operation metrics.console.enable = false -# Bucket types are objects that can be applied to the rate-limiting service built -# into the server. Bucket types are arbitrary units. There can be more than one -# bucket type defined. -bucket.types = compile diff --git a/database/cloudsession-schema.sql b/database/cloudsession-schema.sql new file mode 100644 index 0000000..0addcfb --- /dev/null +++ b/database/cloudsession-schema.sql @@ -0,0 +1,132 @@ +/* + * Base Cloud Session database schema. + */ + +-- MySQL dump 10.13 Distrib 5.6.24, for Win64 (x86_64) +-- +-- Host: localhost Database: cloudsession +-- ------------------------------------------------------ +-- Server version 5.7.7-rc-log + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!40101 SET NAMES utf8 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +-- +-- Table structure for table `authentication_token` +-- +DROP TABLE IF EXISTS `authentication_token`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `authentication_token` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `id_user` bigint(20) DEFAULT NULL, + `browser` varchar(200) DEFAULT NULL, + `server` varchar(1000) DEFAULT NULL, + `ip_address` varchar(200) DEFAULT NULL, + `validity` datetime DEFAULT NULL, + `token` varchar(200) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `token` (`token`), + KEY `id_user` (`id_user`), + CONSTRAINT `authentication_token_ibfk_1` FOREIGN KEY (`id_user`) REFERENCES `user` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `bucket` +-- + +DROP TABLE IF EXISTS `bucket`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `bucket` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `id_user` bigint(20) DEFAULT NULL, + `type` varchar(200) DEFAULT NULL, + `content` int(11) DEFAULT NULL, + `timestamp` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `user_type_unique` (`id_user`,`type`), + CONSTRAINT `bucket_ibfk_1` FOREIGN KEY (`id_user`) REFERENCES `user` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `confirmtoken` +-- + + +DROP TABLE IF EXISTS `confirm_token`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `confirm_token` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `id_user` bigint(20) DEFAULT NULL, + `validity` datetime DEFAULT NULL, + `token` varchar(200) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `id_user` (`id_user`), + UNIQUE KEY `token` (`token`), + CONSTRAINT `confirm_token_ibfk_1` FOREIGN KEY (`id_user`) REFERENCES `user` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `resettoken` +-- + +DROP TABLE IF EXISTS `reset_token`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `reset_token` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `id_user` bigint(20) DEFAULT NULL, + `validity` datetime DEFAULT NULL, + `token` varchar(200) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `id_user` (`id_user`), + UNIQUE KEY `token` (`token`), + CONSTRAINT `reset_token_ibfk_1` FOREIGN KEY (`id_user`) REFERENCES `user` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `user` +-- + +DROP TABLE IF EXISTS `user`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `user` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `email` varchar(250) DEFAULT NULL, + `password` varchar(100) DEFAULT NULL, + `salt` varchar(50) DEFAULT NULL, + `auth_source` varchar(250) DEFAULT NULL, + `locale` varchar(50) DEFAULT NULL, + `blocked` tinyint(1) DEFAULT NULL, + `confirmed` tinyint(1) DEFAULT NULL, + `screen_name` varchar(250) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `email` (`email`) +) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=latin1; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + +-- Dump completed on 2015-08-18 20:24:57 diff --git a/database/patches/0001-add-user-coach.sql b/database/patches/0001-add-user-coach.sql new file mode 100644 index 0000000..7e44c66 --- /dev/null +++ b/database/patches/0001-add-user-coach.sql @@ -0,0 +1,4 @@ +/* + * Add coach email address field to support email cc option. + */ +ALTER TABLE user ADD COLUMN coach_email VARCHAR(250) AFTER screen_name; diff --git a/database/patches/0002-add-coppa-support.sql b/database/patches/0002-add-coppa-support.sql new file mode 100644 index 0000000..5e76365 --- /dev/null +++ b/database/patches/0002-add-coppa-support.sql @@ -0,0 +1,31 @@ +/* + * To change this license header, choose License Headers in Project Properties. + * To change this template file, choose Tools | Templates + * and open the template in the editor. + */ + +/** + * Add fields to user table to support US COPPA compliance + * + * Author: Jim Ewald + * Created: Apr 27, 2017A + * + * birth_month - is a range of [1 - 12] + * birth_year - is a range from [1930 - current year] + * parent_email - is used to register a child under the ae of 13. This is the + * email address of the parent, guardian or instructor that is + * creating the account on behalf of the child + * + * parent_email_source - is a integer designator to characterize the parent + * email adress noted above. Current options are: + * 0 - undefined + * 1 - child's parent + * 2 - child's guardian + * 3 - child's instructor or teacher + */ + +ALTER TABLE cloudsession.user ADD birth_month INT NOT NULL; +ALTER TABLE cloudsession.user ADD birth_year INT NOT NULL; +ALTER TABLE cloudsession.user ADD parent_email VARCHAR(250) NULL; +ALTER TABLE cloudsession.user ADD parent_email_source INT DEFAULT 0 NULL; +ALTER TABLE cloudsession.user DROP coach_email; diff --git a/database/patches/0003-add-user-timestamps.sql b/database/patches/0003-add-user-timestamps.sql new file mode 100644 index 0000000..9b5f23f --- /dev/null +++ b/database/patches/0003-add-user-timestamps.sql @@ -0,0 +1,6 @@ +/* + * Add datestamps to the user record to track when the record was created + * and when it was last modified. + */ +ALTER TABLE cloudsession.user ADD create_date DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL; +ALTER TABLE cloudsession.user ADD last_update DATETIME NULL ON UPDATE CURRENT_TIMESTAMP; diff --git a/requirements.txt b/requirements.txt index 9fd50b6..680ec6b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,3 +19,4 @@ SQLAlchemy==1.0.12 validate-email==1.3 Werkzeug==0.11.5 wheel==0.24.0 +validate_email==1.3 diff --git a/templates/en/confirm-parent/blocklyprop/header.mustache b/templates/en/confirm-parent/blocklyprop/header.mustache new file mode 100644 index 0000000..3425542 --- /dev/null +++ b/templates/en/confirm-parent/blocklyprop/header.mustache @@ -0,0 +1,2 @@ +{{! This is the email Subject line }} +New user registration confirmation request \ No newline at end of file diff --git a/templates/en/confirm-parent/blocklyprop/plain.mustache b/templates/en/confirm-parent/blocklyprop/plain.mustache new file mode 100644 index 0000000..ff72128 --- /dev/null +++ b/templates/en/confirm-parent/blocklyprop/plain.mustache @@ -0,0 +1,33 @@ +{{! + This is the text body of the email new account notification to + a parent or guardian + + Tags used in this template: + blocklyprop-host + locale + registrant-email + screenname + token +}} +Hello, + +A person under age 13 has requested a new account on the Parallax BlocklyProp web site http://{{blocklyprop-host}} under the screen name {{screenname}}. In the request, your email address was provided as the parent or guardian of the requester. + +BlocklyProp is a free, online programming tool designed for education. See Getting Started with BlocklyProp for more information. + +Why are we sending this? In the US, the federal Children's Online Privacy Protection Act (COPPA) requires that we communicate with a parent or guardian of any person under age 13 that requests a BlocklyProp account. A full copy of our Child Privacy Policy is available online at: http://{{blocklyprop-host}}/blockly/privacy-policy + +To complete your child's account registration, please copy and past this link into your browser to confirm your email address: + +http://{{blocklyprop-host}}/blockly/confirm?locale={{locale}}&email={{registrant-email}}&token={{token}} to confirm your email address. + +If the above link is unable to complete the registration, please go to http://{{blocklyprop-host}}/blockly/confirm and enter your email address and the token: {{token}} + +If you do NOT want to complete your child's account registration, you need do nothing more; this confirmation request will automatically expire in 7 days and the account will not be created. + +If you do complete your child's account registration, you may close the account in the future. Email a request for closure and your child's screen name to blocklyadmin@parallax.com. We will confirm your request and then close the account and remove any projects that are associated with the account. + +Regards, + +The Parallax team + diff --git a/templates/en/confirm-teacher/blocklyprop/header.mustache b/templates/en/confirm-teacher/blocklyprop/header.mustache new file mode 100644 index 0000000..7447513 --- /dev/null +++ b/templates/en/confirm-teacher/blocklyprop/header.mustache @@ -0,0 +1,2 @@ +{{! This is the email Subject line }} +Please confirm your student's email address for BlocklyProp diff --git a/templates/en/confirm-teacher/blocklyprop/plain.mustache b/templates/en/confirm-teacher/blocklyprop/plain.mustache new file mode 100644 index 0000000..78a1f1b --- /dev/null +++ b/templates/en/confirm-teacher/blocklyprop/plain.mustache @@ -0,0 +1,22 @@ +{{! + This is the text body of the new account confirmation email directed to instructors +}} +Hello, + +A student has created an account on the Parallax BlocklyProp web site under the +screen name {{screenname}}. When the account was created, your email address was +registered as the classroom instructor. If this is incorrect, please accept our +apologies. There is nothing more you need to do. The request will automatically expire. + +If this is your student, please confirm the registration by copying the link below into +your browser or by navigating to the second link and confirming the student's email address. + +Copy and paste into your browser +http://{{blocklyprop-host}}/blockly/confirm?locale={{locale}}&email={{registrant-email}}&token={{token}} to confirm your email address. + +If the above link is unable to complete your registration, please go to +http://{{blocklyprop-host}}/blockly/confirm and enter your student's email address ({{registrant-email}}) and the token: {{token}} + +Regards, + +The Parallax team diff --git a/templates/en/confirm/blocklyprop/header.mustache b/templates/en/confirm/blocklyprop/header.mustache new file mode 100644 index 0000000..ed52201 --- /dev/null +++ b/templates/en/confirm/blocklyprop/header.mustache @@ -0,0 +1 @@ +{{! This is the email Subject line }}Please confirm your email address for BlocklyProp diff --git a/templates/en/confirm/blocklyprop/plain.mustache b/templates/en/confirm/blocklyprop/plain.mustache new file mode 100644 index 0000000..e39fe63 --- /dev/null +++ b/templates/en/confirm/blocklyprop/plain.mustache @@ -0,0 +1,9 @@ +{{! This is the text body of the email}} +Dear {{screenname}}, + +Please go to http://{{blocklyprop-host}}/blockly/confirm?locale={{locale}}&email={{email}}&token={{token}} to confirm your email address. + +If the url does not work, please go to http://{{blocklyprop-host}}/blockly/confirm and enter your email address and the token: {{token}} + + +The Parallax team diff --git a/templates/en/reset-coppa/blocklyprop/header.mustache b/templates/en/reset-coppa/blocklyprop/header.mustache new file mode 100644 index 0000000..630929b --- /dev/null +++ b/templates/en/reset-coppa/blocklyprop/header.mustache @@ -0,0 +1 @@ +Reset your BlocklyProp password diff --git a/templates/en/reset-coppa/blocklyprop/plain.mustache b/templates/en/reset-coppa/blocklyprop/plain.mustache new file mode 100644 index 0000000..4b05bee --- /dev/null +++ b/templates/en/reset-coppa/blocklyprop/plain.mustache @@ -0,0 +1,15 @@ +{{! + This is the text body of the email that is sent to an account holder + to reset the password associated with the account. +}} +Hello, + +The BlocklyProp web site has received a request to reset the password for user '{{screenname}}'. + +If this request was made by you, please go to http://{{blocklyprop-host}}/blockly/reset?locale={{locale}}&email={{registrant-email}}&token={{token}} to reset your password now. + +If the url does not work, please go to http://{{blocklyprop-host}}/blockly/reset and use the token: {{token}} + +If you did not make this request, you may safely ignore this message. + +The Parallax team diff --git a/templates/en/reset/blocklyprop/header.mustache b/templates/en/reset/blocklyprop/header.mustache new file mode 100644 index 0000000..630929b --- /dev/null +++ b/templates/en/reset/blocklyprop/header.mustache @@ -0,0 +1 @@ +Reset your BlocklyProp password diff --git a/templates/en/reset/blocklyprop/plain.mustache b/templates/en/reset/blocklyprop/plain.mustache new file mode 100644 index 0000000..f9d2518 --- /dev/null +++ b/templates/en/reset/blocklyprop/plain.mustache @@ -0,0 +1,11 @@ +Hello, + +The BlocklyProp web site has received a request to reset the password for user '{{screenname}}'. + +If this request was made by you, please go to http://{{blocklyprop-host}}/blockly/reset?locale={{locale}}&email={{registrant-email}}&token={{token}} to reset your password now. + +If the url does not work, please go to http://{{blocklyprop-host}}/blockly/reset and use the token: {{token}} + +If you did not make this request, you may safely ignore this message. + +The Parallax team diff --git a/templates/en_US/confirm-parent/blocklyprop/header.mustache b/templates/en_US/confirm-parent/blocklyprop/header.mustache new file mode 100644 index 0000000..3425542 --- /dev/null +++ b/templates/en_US/confirm-parent/blocklyprop/header.mustache @@ -0,0 +1,2 @@ +{{! This is the email Subject line }} +New user registration confirmation request \ No newline at end of file diff --git a/templates/en_US/confirm-parent/blocklyprop/plain.mustache b/templates/en_US/confirm-parent/blocklyprop/plain.mustache new file mode 100644 index 0000000..ff72128 --- /dev/null +++ b/templates/en_US/confirm-parent/blocklyprop/plain.mustache @@ -0,0 +1,33 @@ +{{! + This is the text body of the email new account notification to + a parent or guardian + + Tags used in this template: + blocklyprop-host + locale + registrant-email + screenname + token +}} +Hello, + +A person under age 13 has requested a new account on the Parallax BlocklyProp web site http://{{blocklyprop-host}} under the screen name {{screenname}}. In the request, your email address was provided as the parent or guardian of the requester. + +BlocklyProp is a free, online programming tool designed for education. See Getting Started with BlocklyProp for more information. + +Why are we sending this? In the US, the federal Children's Online Privacy Protection Act (COPPA) requires that we communicate with a parent or guardian of any person under age 13 that requests a BlocklyProp account. A full copy of our Child Privacy Policy is available online at: http://{{blocklyprop-host}}/blockly/privacy-policy + +To complete your child's account registration, please copy and past this link into your browser to confirm your email address: + +http://{{blocklyprop-host}}/blockly/confirm?locale={{locale}}&email={{registrant-email}}&token={{token}} to confirm your email address. + +If the above link is unable to complete the registration, please go to http://{{blocklyprop-host}}/blockly/confirm and enter your email address and the token: {{token}} + +If you do NOT want to complete your child's account registration, you need do nothing more; this confirmation request will automatically expire in 7 days and the account will not be created. + +If you do complete your child's account registration, you may close the account in the future. Email a request for closure and your child's screen name to blocklyadmin@parallax.com. We will confirm your request and then close the account and remove any projects that are associated with the account. + +Regards, + +The Parallax team + diff --git a/templates/en_US/confirm-teacher/blocklyprop/header.mustache b/templates/en_US/confirm-teacher/blocklyprop/header.mustache new file mode 100644 index 0000000..7447513 --- /dev/null +++ b/templates/en_US/confirm-teacher/blocklyprop/header.mustache @@ -0,0 +1,2 @@ +{{! This is the email Subject line }} +Please confirm your student's email address for BlocklyProp diff --git a/templates/en_US/confirm-teacher/blocklyprop/plain.mustache b/templates/en_US/confirm-teacher/blocklyprop/plain.mustache new file mode 100644 index 0000000..78a1f1b --- /dev/null +++ b/templates/en_US/confirm-teacher/blocklyprop/plain.mustache @@ -0,0 +1,22 @@ +{{! + This is the text body of the new account confirmation email directed to instructors +}} +Hello, + +A student has created an account on the Parallax BlocklyProp web site under the +screen name {{screenname}}. When the account was created, your email address was +registered as the classroom instructor. If this is incorrect, please accept our +apologies. There is nothing more you need to do. The request will automatically expire. + +If this is your student, please confirm the registration by copying the link below into +your browser or by navigating to the second link and confirming the student's email address. + +Copy and paste into your browser +http://{{blocklyprop-host}}/blockly/confirm?locale={{locale}}&email={{registrant-email}}&token={{token}} to confirm your email address. + +If the above link is unable to complete your registration, please go to +http://{{blocklyprop-host}}/blockly/confirm and enter your student's email address ({{registrant-email}}) and the token: {{token}} + +Regards, + +The Parallax team diff --git a/templates/en_US/confirm/blocklyprop/header.mustache b/templates/en_US/confirm/blocklyprop/header.mustache new file mode 100644 index 0000000..8d1f20a --- /dev/null +++ b/templates/en_US/confirm/blocklyprop/header.mustache @@ -0,0 +1 @@ +Please confirm your email address for BlocklyProp diff --git a/templates/en_US/confirm/blocklyprop/plain.mustache b/templates/en_US/confirm/blocklyprop/plain.mustache new file mode 100644 index 0000000..e39fe63 --- /dev/null +++ b/templates/en_US/confirm/blocklyprop/plain.mustache @@ -0,0 +1,9 @@ +{{! This is the text body of the email}} +Dear {{screenname}}, + +Please go to http://{{blocklyprop-host}}/blockly/confirm?locale={{locale}}&email={{email}}&token={{token}} to confirm your email address. + +If the url does not work, please go to http://{{blocklyprop-host}}/blockly/confirm and enter your email address and the token: {{token}} + + +The Parallax team diff --git a/templates/en_US/reset-coppa/blocklyprop/header.mustache b/templates/en_US/reset-coppa/blocklyprop/header.mustache new file mode 100644 index 0000000..630929b --- /dev/null +++ b/templates/en_US/reset-coppa/blocklyprop/header.mustache @@ -0,0 +1 @@ +Reset your BlocklyProp password diff --git a/templates/en_US/reset-coppa/blocklyprop/plain.mustache b/templates/en_US/reset-coppa/blocklyprop/plain.mustache new file mode 100644 index 0000000..4b05bee --- /dev/null +++ b/templates/en_US/reset-coppa/blocklyprop/plain.mustache @@ -0,0 +1,15 @@ +{{! + This is the text body of the email that is sent to an account holder + to reset the password associated with the account. +}} +Hello, + +The BlocklyProp web site has received a request to reset the password for user '{{screenname}}'. + +If this request was made by you, please go to http://{{blocklyprop-host}}/blockly/reset?locale={{locale}}&email={{registrant-email}}&token={{token}} to reset your password now. + +If the url does not work, please go to http://{{blocklyprop-host}}/blockly/reset and use the token: {{token}} + +If you did not make this request, you may safely ignore this message. + +The Parallax team diff --git a/templates/en_US/reset/blocklyprop/header.mustache b/templates/en_US/reset/blocklyprop/header.mustache new file mode 100644 index 0000000..630929b --- /dev/null +++ b/templates/en_US/reset/blocklyprop/header.mustache @@ -0,0 +1 @@ +Reset your BlocklyProp password diff --git a/templates/en_US/reset/blocklyprop/plain.mustache b/templates/en_US/reset/blocklyprop/plain.mustache new file mode 100644 index 0000000..f9d2518 --- /dev/null +++ b/templates/en_US/reset/blocklyprop/plain.mustache @@ -0,0 +1,11 @@ +Hello, + +The BlocklyProp web site has received a request to reset the password for user '{{screenname}}'. + +If this request was made by you, please go to http://{{blocklyprop-host}}/blockly/reset?locale={{locale}}&email={{registrant-email}}&token={{token}} to reset your password now. + +If the url does not work, please go to http://{{blocklyprop-host}}/blockly/reset and use the token: {{token}} + +If you did not make this request, you may safely ignore this message. + +The Parallax team