class User(DB.Model): __tablename__ = 'users' id = DB.Column(DB.Integer, primary_key=True) email = DB.Column(DB.String(50), unique=True, index=True) password = DB.Column(DB.String(100)) upgraded = DB.Column(DB.Boolean) stripe_id = DB.Column(DB.String(50)) registered_on = DB.Column(DB.DateTime) forms = DB.relationship('Form', backref='owner', lazy='dynamic') def __init__(self, email, password): self.email = email self.password = hash_pwd(password) self.upgraded = False self.registered_on = datetime.utcnow() def is_authenticated(self): return True def is_active(self): return True def is_anonymous(self): return False def get_id(self): return unicode(self.id)
def setUp(self): self.assertNotEqual(settings.SQLALCHEMY_DATABASE_URI, os.getenv('DATABASE_URL')) self.assertIsInstance(redis_store.connection, fakeredis.FakeStrictRedis) DB.create_all() super(FormspreeTestCase, self).setUp()
class Email(DB.Model): __tablename__ = 'emails' """ emails added here are already confirmed and can be trusted. """ address = DB.Column(DB.Text, primary_key=True) owner_id = DB.Column(DB.Integer, DB.ForeignKey('users.id'), primary_key=True) registered_on = DB.Column(DB.DateTime, default=DB.func.now()) @staticmethod def send_confirmation(addr, user_id): g.log = g.log.new(address=addr, user_id=user_id) g.log.info('Sending email confirmation for new address on account.') addr = addr.lower().strip() if not IS_VALID_EMAIL(addr): g.log.info('Failed. Invalid address.') raise ValueError(u'Cannot send confirmation. ' '{} is not a valid email.'.format(addr)) message = u'email={email}&user_id={user_id}'.format(email=addr, user_id=user_id) digest = hmac.new(settings.NONCE_SECRET, message.encode('utf-8'), hashlib.sha256).hexdigest() link = url_for('confirm-account-email', digest=digest, email=addr, _external=True) res = send_email(to=addr, subject='Confirm email for your account at %s' % settings.SERVICE_NAME, text=render_template('email/confirm-account.txt', email=addr, link=link), html=render_template('email/confirm-account.html', email=addr, link=link), sender=settings.ACCOUNT_SENDER) if not res[0]: g.log.info('Failed to send email.', reason=res[1], code=res[2]) return False else: return True @classmethod def create_with_digest(cls, addr, user_id, digest): addr = addr.lower() message = u'email={email}&user_id={user_id}'.format(email=addr, user_id=user_id) what_should_be = hmac.new(settings.NONCE_SECRET, message.encode('utf-8'), hashlib.sha256).hexdigest() if digest == what_should_be: return cls(address=addr, owner_id=user_id) else: return None
def tearDown(self): DB.session.remove() DB.drop_all() redis_store.flushdb() self.redis_patcher.stop() super(FormspreeTestCase, self).tearDown()
class Submission(DB.Model): __tablename__ = 'submissions' id = DB.Column(DB.Integer, primary_key=True) submitted_at = DB.Column(DB.DateTime) form_id = DB.Column(DB.Integer, DB.ForeignKey('forms.id')) data = DB.Column(MutableDict.as_mutable(JSON)) def __init__(self, form_id): self.submitted_at = datetime.datetime.utcnow() self.form_id = form_id
class Submission(DB.Model): __tablename__ = 'submissions' id = DB.Column(DB.Integer, primary_key=True) submitted_at = DB.Column(DB.DateTime) form_id = DB.Column(DB.Integer, DB.ForeignKey('forms.id')) data = DB.Column(MutableDict.as_mutable(JSON)) def __init__(self, form_id): self.submitted_at = datetime.datetime.utcnow() self.form_id = form_id def __repr__(self): return '<Submission %s, form=%s, date=%s, keys=%s>' % \ (self.id or 'with an id to be assigned', self.form_id, self.submitted_at.isoformat(), self.data.keys())
class User(DB.Model): __tablename__ = 'users' id = DB.Column(DB.Integer, primary_key=True) email = DB.Column(DB.Text, unique=True, index=True) password = DB.Column(DB.String(100)) upgraded = DB.Column(DB.Boolean) stripe_id = DB.Column(DB.String(50)) registered_on = DB.Column(DB.DateTime) emails = DB.relationship('Email', backref='owner', lazy='dynamic') @property def forms(self): from formspree.forms.models import Form by_email = DB.session.query(Form) \ .join(Email, Email.address == Form.email) \ .join(User, User.id == Email.owner_id) \ .filter(User.id == self.id) by_creation = DB.session.query(Form) \ .join(User, User.id == Form.owner_id) \ .filter(User.id == self.id) return by_creation.union(by_email) def __init__(self, email, password): email = email.lower().strip() if not IS_VALID_EMAIL(email): raise ValueError('Cannot create User. %s is not a valid email.' % email) self.email = email self.password = hash_pwd(password) self.upgraded = False self.registered_on = datetime.utcnow() @property def is_authenticated(self): return True @property def is_active(self): return True @property def is_anonymous(self): return False def get_id(self): return unicode(self.id)
class Form(DB.Model): __tablename__ = 'forms' id = DB.Column(DB.Integer, primary_key=True) hash = DB.Column(DB.String(32), unique=True) email = DB.Column(DB.String(120)) host = DB.Column(DB.String(300)) sitewide = DB.Column(DB.Boolean) disabled = DB.Column(DB.Boolean) confirm_sent = DB.Column(DB.Boolean) confirmed = DB.Column(DB.Boolean) counter = DB.Column(DB.Integer) owner_id = DB.Column(DB.Integer, DB.ForeignKey('users.id')) owner = DB.relationship('User') # direct owner, defined by 'owner_id' # this property is basically useless. use .controllers submissions = DB.relationship('Submission', backref='form', lazy='dynamic', order_by=lambda: Submission.id.desc()) ''' When the form is created by a spontaneous submission, it is added to the table with a `host`, an `email` and a `hash` made of these two (+ a secret nonce). `hash` is UNIQUE because it is used to query these spontaneous forms when the form is going to be confirmed and whenever a new submission arrives. When a registered user POSTs to /forms, a new form is added to the table with an `email` (provided by the user) and an `owner_id`. Later, when this form receives its first submission and confirmation, `host` is added, so we can ensure that no one will submit to this same form from another host. `hash` is never added to these forms, because they could conflict with other forms, created by the spontaneous process, with the same email and host. So for these forms a different confirmation method is used (see below). ''' STATUS_EMAIL_SENT = 0 STATUS_EMAIL_EMPTY = 1 STATUS_EMAIL_FAILED = 2 STATUS_OVERLIMIT = 3 STATUS_REPLYTO_ERROR = 4 STATUS_CONFIRMATION_SENT = 10 STATUS_CONFIRMATION_DUPLICATED = 11 STATUS_CONFIRMATION_FAILED = 12 def __init__(self, email, host=None, owner=None): if host: self.hash = HASH(email, host) elif owner: self.owner_id = owner.id else: raise Exception('cannot create form without a host and a owner. provide one of these.') self.email = email self.host = host self.confirm_sent = False self.confirmed = False self.counter = 0 self.disabled = False def __repr__(self): return '<Form %s, email=%s, host=%s>' % (self.id, self.email, self.host) @property def controllers(self): from formspree.users.models import User, Email by_email = DB.session.query(User) \ .join(Email, User.id == Email.owner_id) \ .join(Form, Form.email == Email.address) \ .filter(Form.id == self.id) by_creation = DB.session.query(User) \ .join(Form, User.id == Form.owner_id) \ .filter(Form.id == self.id) return by_email.union(by_creation) @classmethod def get_with_hashid(cls, hashid): try: id = HASHIDS_CODEC.decode(hashid)[0] return cls.query.get(id) except IndexError: return None def send(self, submitted_data, referrer): ''' Sends form to user's email. Assumes sender's email has been verified. ''' if type(submitted_data) in (ImmutableMultiDict, ImmutableOrderedMultiDict): data, keys = http_form_to_dict(submitted_data) else: data, keys = submitted_data, submitted_data.keys() subject = data.get('_subject', 'New submission from %s' % referrer_to_path(referrer)) reply_to = data.get('_replyto', data.get('email', data.get('Email', ''))).strip() cc = data.get('_cc', None) next = next_url(referrer, data.get('_next')) spam = data.get('_gotcha', None) format = data.get('_format', None) # turn cc emails into array if cc: cc = [email.strip() for email in cc.split(',')] # prevent submitting empty form if not any(data.values()): return { 'code': Form.STATUS_EMAIL_EMPTY } # return a fake success for spam if spam: return { 'code': Form.STATUS_EMAIL_SENT, 'next': next } # validate reply_to, if it is not a valid email address, reject if reply_to and not IS_VALID_EMAIL(reply_to): return { 'code': Form.STATUS_REPLYTO_ERROR, 'error-message': '"%s" is not a valid email address.' % reply_to } # increase the monthly counter request_date = datetime.datetime.now() self.increase_monthly_counter(basedate=request_date) # increment the forms counter self.counter = Form.counter + 1 DB.session.add(self) # archive the form contents sub = Submission(self.id) sub.data = data DB.session.add(sub) # commit changes DB.session.commit() # delete all archived submissions over the limit records_to_keep = settings.ARCHIVED_SUBMISSIONS_LIMIT newest = self.submissions.with_entities(Submission.id).limit(records_to_keep) DB.engine.execute( delete('submissions'). \ where(Submission.form_id == self.id). \ where(~Submission.id.in_(newest)) ) # check if the forms are over the counter and the user is not upgraded overlimit = False monthly_counter = self.get_monthly_counter() if monthly_counter > settings.MONTHLY_SUBMISSIONS_LIMIT: overlimit = True if self.controllers: for c in self.controllers: if c.upgraded: overlimit = False break now = datetime.datetime.utcnow().strftime('%I:%M %p UTC - %d %B %Y') if not overlimit: text = render_template('email/form.txt', data=data, host=self.host, keys=keys, now=now) # check if the user wants a new or old version of the email if format == 'plain': html = render_template('email/plain_form.html', data=data, host=self.host, keys=keys, now=now) else: html = render_template('email/form.html', data=data, host=self.host, keys=keys, now=now) else: if monthly_counter - settings.MONTHLY_SUBMISSIONS_LIMIT > 25: # only send this overlimit notification for the first 25 overlimit emails # after that, return an error so the user can know the website owner is not # going to read his message. return { 'code': Form.STATUS_OVERLIMIT } text = render_template('email/overlimit-notification.txt', host=self.host) html = render_template('email/overlimit-notification.html', host=self.host) result = send_email(to=self.email, subject=subject, text=text, html=html, sender=settings.DEFAULT_SENDER, reply_to=reply_to, cc=cc) if not result[0]: if result[1].startswith('Invalid replyto email address'): return { 'code': Form.STATUS_REPLYTO_ERROR} return{ 'code': Form.STATUS_EMAIL_FAILED, 'mailer-code': result[2], 'error-message': result[1] } return { 'code': Form.STATUS_EMAIL_SENT, 'next': next } def get_monthly_counter(self, basedate=None): basedate = basedate or datetime.datetime.now() month = basedate.month key = MONTHLY_COUNTER_KEY(form_id=self.id, month=month) counter = redis_store.get(key) or 0 return int(counter) def increase_monthly_counter(self, basedate=None): basedate = basedate or datetime.datetime.now() month = basedate.month key = MONTHLY_COUNTER_KEY(form_id=self.id, month=month) redis_store.incr(key) redis_store.expireat(key, unix_time_for_12_months_from_now(basedate)) def send_confirmation(self, with_data=None): ''' Helper that actually creates confirmation nonce and sends the email to associated email. Renders different templates depending on the result ''' log.debug('Sending confirmation') if self.confirm_sent: return { 'code': Form.STATUS_CONFIRMATION_DUPLICATED } # the nonce for email confirmation will be the hash when it exists # (whenever the form was created from a simple submission) or # a concatenation of HASH(email, id) + ':' + hashid # (whenever the form was created from the dashboard) id = str(self.id) nonce = self.hash or '%s:%s' % (HASH(self.email, id), self.hashid) link = url_for('confirm_email', nonce=nonce, _external=True) def render_content(ext): data, keys = None, None if with_data: if type(with_data) in (ImmutableMultiDict, ImmutableOrderedMultiDict): data, keys = http_form_to_dict(with_data) else: data, keys = with_data, with_data.keys() return render_template('email/confirm.%s' % ext, email=self.email, host=self.host, nonce_link=link, data=data, keys=keys) log.debug('Sending email') result = send_email(to=self.email, subject='Confirm email for %s' % settings.SERVICE_NAME, text=render_content('txt'), html=render_content('html'), sender=settings.DEFAULT_SENDER) log.debug('Sent') if not result[0]: return { 'code': Form.STATUS_CONFIRMATION_FAILED } self.confirm_sent = True DB.session.add(self) DB.session.commit() return { 'code': Form.STATUS_CONFIRMATION_SENT } @classmethod def confirm(cls, nonce): if ':' in nonce: # form created in the dashboard # nonce is another hash and the # hashid comes in the request. nonce, hashid = nonce.split(':') form = cls.get_with_hashid(hashid) if HASH(form.email, str(form.id)) == nonce: pass else: form = None else: # normal form, nonce is HASH(email, host) form = cls.query.filter_by(hash=nonce).first() if form: form.confirmed = True DB.session.add(form) DB.session.commit() return form @property def hashid(self): # A unique identifier for the form that maps to its id, # but doesn't seem like a sequential integer try: return self._hashid except AttributeError: if not self.id: raise Exception("this form doesn't have an id yet, commit it first.") self._hashid = HASHIDS_CODEC.encode(self.id) return self._hashid
class Form(DB.Model): __tablename__ = 'forms' id = DB.Column(DB.Integer, primary_key=True) hash = DB.Column(DB.String(32), unique=True) email = DB.Column(DB.String(120)) host = DB.Column(DB.String(300)) confirm_sent = DB.Column(DB.Boolean) confirmed = DB.Column(DB.Boolean) counter = DB.Column(DB.Integer) owner_id = DB.Column(DB.Integer, DB.ForeignKey('users.id')) submissions = DB.relationship('Submission', backref='form', lazy='dynamic', order_by=lambda: Submission.id.desc()) ''' When the form is created by a spontaneous submission, it is added to the table with a `host`, an `email` and a `hash` made of these two (+ a secret nonce). `hash` is UNIQUE because it is used to query these spontaneous forms when the form is going to be confirmed and whenever a new submission arrives. When a registered user POSTs to /forms, a new form is added to the table with an `email` (provided by the user) and an `owner_id`. Later, when this form receives its first submission and confirmation, `host` is added, so we can ensure that no one will submit to this same form from another host. `hash` is never added to these forms, because they could conflict with other forms, created by the spontaneous process, with the same email and host. So for these forms a different confirmation method is used (see below). ''' STATUS_EMAIL_SENT = 0 STATUS_EMAIL_EMPTY = 1 STATUS_EMAIL_FAILED = 2 STATUS_CONFIRMATION_SENT = 10 STATUS_CONFIRMATION_DUPLICATED = 11 STATUS_CONFIRMATION_FAILED = 12 def __init__(self, email, host=None, owner=None): if host: self.hash = HASH(email, host) elif owner: self.owner_id = owner.id else: raise Exception( 'cannot create form without a host and a owner. provide one of these.' ) self.email = email self.host = host self.confirm_sent = False self.confirmed = False self.counter = 0 def __repr__(self): return '<Form %s, email=%s, host=%s>' % (self.id, self.email, self.host) def get_random_like_string(self): if not self.id: raise Exception( "this form doesn't have an id yet, commit it first.") return HASHIDS_CODEC.encode(self.id) @classmethod def get_form_by_random_like_string(cls, random_like_string): id = HASHIDS_CODEC.decode(random_like_string)[0] return cls.query.get(id) def send(self, http_form, referrer): ''' Sends form to user's email. Assumes sender's email has been verified. ''' data, keys = http_form_to_dict(http_form) subject = data.get( '_subject', 'New submission from %s' % referrer_to_path(referrer)) reply_to = data.get('_replyto', data.get('email', data.get('Email', None))) cc = data.get('_cc', None) next = next_url(referrer, data.get('_next')) spam = data.get('_gotcha', None) # prevent submitting empty form if not any(data.values()): return {'code': Form.STATUS_EMAIL_EMPTY} # return a fake success for spam if spam: return {'code': Form.STATUS_EMAIL_SENT, 'next': next} # increase the monthly counter request_date = datetime.datetime.now() self.increase_monthly_counter(basedate=request_date) # increment the forms counter self.counter = Form.counter + 1 DB.session.add(self) # archive the form contents sub = Submission(self.id) sub.data = data DB.session.add(sub) # commit changes DB.session.commit() # delete all archived submissions over the limit records_to_keep = settings.ARCHIVED_SUBMISSIONS_LIMIT newest = self.submissions.with_entities( Submission.id).limit(records_to_keep) DB.engine.execute( delete('submissions'). \ where(Submission.form_id == self.id). \ where(~Submission.id.in_(newest)) ) # check if the forms are over the counter and the user is not upgraded overlimit = False if self.get_monthly_counter( basedate=request_date) > settings.MONTHLY_SUBMISSIONS_LIMIT: if not self.owner or not self.owner.upgraded: overlimit = True now = datetime.datetime.utcnow().strftime('%I:%M %p UTC - %d %B %Y') if not overlimit: text = render_template('email/form.txt', data=data, host=self.host, keys=keys, now=now) html = render_template('email/form.html', data=data, host=self.host, keys=keys, now=now) else: text = render_template('email/overlimit-notification.txt', host=self.host) html = render_template('email/overlimit-notification.html', host=self.host) result = send_email(to=self.email, subject=subject, text=text, html=html, sender=settings.DEFAULT_SENDER, reply_to=reply_to, cc=cc) if not result[0]: return {'code': Form.STATUS_EMAIL_FAILED} return {'code': Form.STATUS_EMAIL_SENT, 'next': next} def get_monthly_counter(self, basedate=None): basedate = basedate or datetime.datetime.now() month = basedate.month key = MONTHLY_COUNTER_KEY(form_id=self.id, month=month) counter = redis_store.get(key) or 0 return int(counter) def increase_monthly_counter(self, basedate=None): basedate = basedate or datetime.datetime.now() month = basedate.month key = MONTHLY_COUNTER_KEY(form_id=self.id, month=month) redis_store.incr(key) redis_store.expireat(key, unix_time_for_12_months_from_now(basedate)) def send_confirmation(self): ''' Helper that actually creates confirmation nonce and sends the email to associated email. Renders different templates depending on the result ''' log.debug('Sending confirmation') if self.confirm_sent: return {'code': Form.STATUS_CONFIRMATION_DUPLICATED} # the nonce for email confirmation will be the hash when it exists # (whenever the form was created from a simple submission) or # a concatenation of HASH(email, id) + ':' + random_like_string # (whenever the form was created from the dashboard) id = str(self.id) nonce = self.hash or '%s:%s' % (HASH( self.email, id), self.get_random_like_string()) link = url_for('confirm_email', nonce=nonce, _external=True) def render_content(type): return render_template('email/confirm.%s' % type, email=self.email, host=self.host, nonce_link=link) log.debug('Sending email') result = send_email(to=self.email, subject='Confirm email for %s' % settings.SERVICE_NAME, text=render_content('txt'), html=render_content('html'), sender=settings.DEFAULT_SENDER) log.debug('Sent') if not result[0]: return {'code': Form.STATUS_CONFIRMATION_FAILED} self.confirm_sent = True DB.session.add(self) DB.session.commit() return {'code': Form.STATUS_CONFIRMATION_SENT} @classmethod def confirm(cls, nonce): if ':' in nonce: # form created in the dashboard # nonce is another hash and the # random_like_string comes in the # request. nonce, rls = nonce.split(':') form = cls.get_form_by_random_like_string(rls) if HASH(form.email, str(form.id)) == nonce: pass else: form = None else: # normal form, nonce is HASH(email, host) form = cls.query.filter_by(hash=nonce).first() if form: form.confirmed = True DB.session.add(form) DB.session.commit() return form @property def action(self): return url_for('send', email_or_string=self.get_random_like_string(), _external=True) @property def code(self): return CODE_TEMPLATE.format(action=self.action) @property def is_new(self): return not self.host @property def status(self): if self.is_new: return 'new' elif self.confirmed: return 'confirmed' elif self.confirm_sent: return 'awaiting_confirmation'
class User(DB.Model): __tablename__ = 'users' id = DB.Column(DB.Integer, primary_key=True) email = DB.Column(DB.Text, unique=True, index=True) password = DB.Column(DB.String(100)) upgraded = DB.Column(DB.Boolean) stripe_id = DB.Column(DB.String(50)) registered_on = DB.Column(DB.DateTime) emails = DB.relationship('Email', backref='owner', lazy='dynamic') @property def forms(self): from formspree.forms.models import Form by_email = DB.session.query(Form) \ .join(Email, Email.address == Form.email) \ .join(User, User.id == Email.owner_id) \ .filter(User.id == self.id) by_creation = DB.session.query(Form) \ .join(User, User.id == Form.owner_id) \ .filter(User.id == self.id) return by_creation.union(by_email) def __init__(self, email, password): email = email.lower().strip() if not IS_VALID_EMAIL(email): raise ValueError('Cannot create User. %s is not a valid email.' % email) self.email = email self.password = hash_pwd(password) self.upgraded = False self.registered_on = datetime.utcnow() @property def is_authenticated(self): return True @property def is_active(self): return True @property def is_anonymous(self): return False def get_id(self): return unicode(self.id) def reset_password_digest(self): return hmac.new(settings.NONCE_SECRET, 'id={0}&password={1}'.format(self.id, self.password), hashlib.sha256).hexdigest() def send_password_reset(self): g.log.info('Sending password reset.', account=self.email) digest = self.reset_password_digest() link = url_for('reset-password', digest=digest, email=self.email, _external=True) res = send_email(to=self.email, subject='Reset your %s password!' % settings.SERVICE_NAME, text=render_template('email/reset-password.txt', addr=self.email, link=link), html=render_template('email/reset-password.html', add=self.email, link=link), sender=settings.ACCOUNT_SENDER) if not res[0]: g.log.info('Failed to send email.', reason=res[1], code=res[2]) return False else: return True @classmethod def from_password_reset(cls, email, digest): user = User.query.filter_by(email=email).first() if not user: return None what_should_be = user.reset_password_digest() if digest == what_should_be: return user else: return None