├── .env-template ├── .flaskenv ├── .gitattributes ├── .gitignore ├── Dockerfile ├── LICENSE ├── Procfile ├── README.md ├── Vagrantfile ├── app ├── __init__.py ├── api │ ├── __init__.py │ ├── auth.py │ ├── errors.py │ ├── tokens.py │ └── users.py ├── auth │ ├── __init__.py │ ├── email.py │ ├── forms.py │ ├── routes.py │ └── twilio_verify.py ├── cli.py ├── email.py ├── errors │ ├── __init__.py │ └── handlers.py ├── main │ ├── __init__.py │ ├── forms.py │ └── routes.py ├── models.py ├── search.py ├── static │ └── loading.gif ├── tasks.py ├── templates │ ├── _post.html │ ├── auth │ │ ├── disable_2fa.html │ │ ├── enable_2fa.html │ │ ├── login.html │ │ ├── register.html │ │ ├── reset_password.html │ │ ├── reset_password_request.html │ │ └── verify_2fa.html │ ├── base.html │ ├── edit_profile.html │ ├── email │ │ ├── export_posts.html │ │ ├── export_posts.txt │ │ ├── reset_password.html │ │ └── reset_password.txt │ ├── errors │ │ ├── 404.html │ │ └── 500.html │ ├── index.html │ ├── messages.html │ ├── search.html │ ├── send_message.html │ ├── user.html │ └── user_popup.html ├── translate.py └── translations │ └── es │ └── LC_MESSAGES │ └── messages.po ├── babel.cfg ├── boot.sh ├── config.py ├── deployment ├── nginx │ └── microblog └── supervisor │ ├── microblog-tasks.conf │ └── microblog.conf ├── microblog.py ├── migrations ├── README ├── alembic.ini ├── env.py ├── script.py.mako └── versions │ ├── 2b017edaa91f_add_language_to_posts.py │ ├── 37f06a334dbf_new_fields_in_user_model.py │ ├── 780739b227a7_posts_table.py │ ├── 834b1a697901_user_tokens.py │ ├── ae346256b650_followers.py │ ├── aeea651280c2_two_factor_authentication.py │ ├── c81bac34faab_tasks.py │ ├── d049de007ccf_private_messages.py │ ├── e517276bb1c2_users_table.py │ └── f7ac3d27bb1d_notifications.py ├── requirements.txt └── tests.py /.env-template: -------------------------------------------------------------------------------- 1 | TWILIO_ACCOUNT_SID= 2 | TWILIO_AUTH_TOKEN= 3 | TWILIO_VERIFY_SERVICE_ID= 4 | -------------------------------------------------------------------------------- /.flaskenv: -------------------------------------------------------------------------------- 1 | FLASK_APP=microblog.py 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.sh text eol=lf 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | __pycache__ 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | 30 | # Translations 31 | *.mo 32 | 33 | # Mr Developer 34 | .mr.developer.cfg 35 | .project 36 | .pydevproject 37 | 38 | venv 39 | app.db 40 | microblog.log* 41 | .env 42 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6-alpine 2 | 3 | RUN adduser -D microblog 4 | 5 | WORKDIR /home/microblog 6 | 7 | COPY requirements.txt requirements.txt 8 | RUN python -m venv venv 9 | RUN venv/bin/pip install -r requirements.txt 10 | RUN venv/bin/pip install gunicorn pymysql 11 | 12 | COPY app app 13 | COPY migrations migrations 14 | COPY microblog.py config.py boot.sh ./ 15 | RUN chmod a+x boot.sh 16 | 17 | ENV FLASK_APP microblog.py 18 | 19 | RUN chown -R microblog:microblog ./ 20 | USER microblog 21 | 22 | EXPOSE 5000 23 | ENTRYPOINT ["./boot.sh"] 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Miguel Grinberg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: flask db upgrade; flask translate compile; gunicorn microblog:app 2 | worker: rq worker microblog-tasks 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to Microblog-Verify! 2 | 3 | This is the example application featured in my [Flask Mega-Tutorial](https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-i-hello-world), to which I've added support for two-factor authentication via the [Twilio Verify API](https://www.twilio.com/docs/verify/api). 4 | 5 | ## How To Run This Application 6 | 7 | Microblog is fairly complex application that is developed over the 23 chapters of the tutorial referenced above. Below you can see how to start the basic application using a local SQLite database, and without including support for emails, full-text search and background tasks. This is enough to demonstrate how to add two-factor authentication. 8 | 9 | 1. Create a Python virtual environment and activate it: 10 | 11 | *For Unix and Mac computers:* 12 | 13 | ``` 14 | $ python3 -m venv venv 15 | $ source venv/bin/activate 16 | (venv) $ _ 17 | ``` 18 | 19 | *For Windows computers:* 20 | 21 | ``` 22 | $ python3 -m venv venv 23 | $ venv\Scripts\activate 24 | (venv) $ _ 25 | ``` 26 | 27 | 2. Import the Python dependencies into the virtual environment: 28 | 29 | ``` 30 | (venv) $ pip install -r requirements 31 | ``` 32 | 33 | 3. Create a local database: 34 | 35 | ``` 36 | (venv) $ flask db upgrade 37 | ``` 38 | 39 | 4. Define your Twilio credentials 40 | 41 | Copy the `.env-template` file to `.env` and then complete the credentials from your Twilio account. You need to include your Twilio account's SID and Auth Token, and the Twilio Verify Service ID. 42 | 43 | 5. Start the development web server: 44 | 45 | ``` 46 | (venv) $ flask run 47 | ``` 48 | 49 | 6. Access the application on your web browser at `http://localhost:5000` 50 | 51 | Interested in learning more about this application besides two-factor authentication? The [actual tutorial](https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-i-hello-world) is the best reference! 52 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | Vagrant.configure("2") do |config| 2 | config.vm.box = "ubuntu/xenial64" 3 | config.vm.network "private_network", ip: "192.168.33.10" 4 | config.vm.provider "virtualbox" do |vb| 5 | vb.memory = "1024" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from logging.handlers import SMTPHandler, RotatingFileHandler 3 | import os 4 | from flask import Flask, request, current_app 5 | from flask_sqlalchemy import SQLAlchemy 6 | from flask_migrate import Migrate 7 | from flask_login import LoginManager 8 | from flask_mail import Mail 9 | from flask_bootstrap import Bootstrap 10 | from flask_moment import Moment 11 | from flask_babel import Babel, lazy_gettext as _l 12 | from elasticsearch import Elasticsearch 13 | from redis import Redis 14 | import rq 15 | from config import Config 16 | 17 | db = SQLAlchemy() 18 | migrate = Migrate() 19 | login = LoginManager() 20 | login.login_view = 'auth.login' 21 | login.login_message = _l('Please log in to access this page.') 22 | mail = Mail() 23 | bootstrap = Bootstrap() 24 | moment = Moment() 25 | babel = Babel() 26 | 27 | 28 | def create_app(config_class=Config): 29 | app = Flask(__name__) 30 | app.config.from_object(config_class) 31 | 32 | db.init_app(app) 33 | migrate.init_app(app, db) 34 | login.init_app(app) 35 | mail.init_app(app) 36 | bootstrap.init_app(app) 37 | moment.init_app(app) 38 | babel.init_app(app) 39 | app.elasticsearch = Elasticsearch([app.config['ELASTICSEARCH_URL']]) \ 40 | if app.config['ELASTICSEARCH_URL'] else None 41 | app.redis = Redis.from_url(app.config['REDIS_URL']) 42 | app.task_queue = rq.Queue('microblog-tasks', connection=app.redis) 43 | 44 | from app.errors import bp as errors_bp 45 | app.register_blueprint(errors_bp) 46 | 47 | from app.auth import bp as auth_bp 48 | app.register_blueprint(auth_bp, url_prefix='/auth') 49 | 50 | from app.main import bp as main_bp 51 | app.register_blueprint(main_bp) 52 | 53 | from app.api import bp as api_bp 54 | app.register_blueprint(api_bp, url_prefix='/api') 55 | 56 | if not app.debug and not app.testing: 57 | if app.config['MAIL_SERVER']: 58 | auth = None 59 | if app.config['MAIL_USERNAME'] or app.config['MAIL_PASSWORD']: 60 | auth = (app.config['MAIL_USERNAME'], 61 | app.config['MAIL_PASSWORD']) 62 | secure = None 63 | if app.config['MAIL_USE_TLS']: 64 | secure = () 65 | mail_handler = SMTPHandler( 66 | mailhost=(app.config['MAIL_SERVER'], app.config['MAIL_PORT']), 67 | fromaddr='no-reply@' + app.config['MAIL_SERVER'], 68 | toaddrs=app.config['ADMINS'], subject='Microblog Failure', 69 | credentials=auth, secure=secure) 70 | mail_handler.setLevel(logging.ERROR) 71 | app.logger.addHandler(mail_handler) 72 | 73 | if app.config['LOG_TO_STDOUT']: 74 | stream_handler = logging.StreamHandler() 75 | stream_handler.setLevel(logging.INFO) 76 | app.logger.addHandler(stream_handler) 77 | else: 78 | if not os.path.exists('logs'): 79 | os.mkdir('logs') 80 | file_handler = RotatingFileHandler('logs/microblog.log', 81 | maxBytes=10240, backupCount=10) 82 | file_handler.setFormatter(logging.Formatter( 83 | '%(asctime)s %(levelname)s: %(message)s ' 84 | '[in %(pathname)s:%(lineno)d]')) 85 | file_handler.setLevel(logging.INFO) 86 | app.logger.addHandler(file_handler) 87 | 88 | app.logger.setLevel(logging.INFO) 89 | app.logger.info('Microblog startup') 90 | 91 | return app 92 | 93 | 94 | @babel.localeselector 95 | def get_locale(): 96 | return request.accept_languages.best_match(current_app.config['LANGUAGES']) 97 | 98 | 99 | from app import models 100 | -------------------------------------------------------------------------------- /app/api/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | bp = Blueprint('api', __name__) 4 | 5 | from app.api import users, errors, tokens 6 | -------------------------------------------------------------------------------- /app/api/auth.py: -------------------------------------------------------------------------------- 1 | from flask import g 2 | from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth 3 | from flask_login import current_user 4 | from app.models import User 5 | from app.api.errors import error_response 6 | 7 | basic_auth = HTTPBasicAuth() 8 | token_auth = HTTPTokenAuth() 9 | 10 | 11 | @basic_auth.verify_password 12 | def verify_password(username, password): 13 | user = User.query.filter_by(username=username).first() 14 | if user is None: 15 | return False 16 | g.current_user = user 17 | return user.check_password(password) 18 | 19 | 20 | @basic_auth.error_handler 21 | def basic_auth_error(): 22 | return error_response(401) 23 | 24 | 25 | @token_auth.verify_token 26 | def verify_token(token): 27 | g.current_user = User.check_token(token) if token else None 28 | return g.current_user is not None 29 | 30 | 31 | @token_auth.error_handler 32 | def token_auth_error(): 33 | return error_response(401) 34 | -------------------------------------------------------------------------------- /app/api/errors.py: -------------------------------------------------------------------------------- 1 | from flask import jsonify 2 | from werkzeug.http import HTTP_STATUS_CODES 3 | 4 | 5 | def error_response(status_code, message=None): 6 | payload = {'error': HTTP_STATUS_CODES.get(status_code, 'Unknown error')} 7 | if message: 8 | payload['message'] = message 9 | response = jsonify(payload) 10 | response.status_code = status_code 11 | return response 12 | 13 | 14 | def bad_request(message): 15 | return error_response(400, message) 16 | -------------------------------------------------------------------------------- /app/api/tokens.py: -------------------------------------------------------------------------------- 1 | from flask import jsonify, g 2 | from app import db 3 | from app.api import bp 4 | from app.api.auth import basic_auth, token_auth 5 | 6 | 7 | @bp.route('/tokens', methods=['POST']) 8 | @basic_auth.login_required 9 | def get_token(): 10 | token = g.current_user.get_token() 11 | db.session.commit() 12 | return jsonify({'token': token}) 13 | 14 | 15 | @bp.route('/tokens', methods=['DELETE']) 16 | @token_auth.login_required 17 | def revoke_token(): 18 | g.current_user.revoke_token() 19 | db.session.commit() 20 | return '', 204 21 | -------------------------------------------------------------------------------- /app/api/users.py: -------------------------------------------------------------------------------- 1 | from flask import jsonify, request, url_for, g, abort 2 | from app import db 3 | from app.models import User 4 | from app.api import bp 5 | from app.api.auth import token_auth 6 | from app.api.errors import bad_request 7 | 8 | 9 | @bp.route('/users/', methods=['GET']) 10 | @token_auth.login_required 11 | def get_user(id): 12 | return jsonify(User.query.get_or_404(id).to_dict()) 13 | 14 | 15 | @bp.route('/users', methods=['GET']) 16 | @token_auth.login_required 17 | def get_users(): 18 | page = request.args.get('page', 1, type=int) 19 | per_page = min(request.args.get('per_page', 10, type=int), 100) 20 | data = User.to_collection_dict(User.query, page, per_page, 'api.get_users') 21 | return jsonify(data) 22 | 23 | 24 | @bp.route('/users//followers', methods=['GET']) 25 | @token_auth.login_required 26 | def get_followers(id): 27 | user = User.query.get_or_404(id) 28 | page = request.args.get('page', 1, type=int) 29 | per_page = min(request.args.get('per_page', 10, type=int), 100) 30 | data = User.to_collection_dict(user.followers, page, per_page, 31 | 'api.get_followers', id=id) 32 | return jsonify(data) 33 | 34 | 35 | @bp.route('/users//followed', methods=['GET']) 36 | @token_auth.login_required 37 | def get_followed(id): 38 | user = User.query.get_or_404(id) 39 | page = request.args.get('page', 1, type=int) 40 | per_page = min(request.args.get('per_page', 10, type=int), 100) 41 | data = User.to_collection_dict(user.followed, page, per_page, 42 | 'api.get_followed', id=id) 43 | return jsonify(data) 44 | 45 | 46 | @bp.route('/users', methods=['POST']) 47 | def create_user(): 48 | data = request.get_json() or {} 49 | if 'username' not in data or 'email' not in data or 'password' not in data: 50 | return bad_request('must include username, email and password fields') 51 | if User.query.filter_by(username=data['username']).first(): 52 | return bad_request('please use a different username') 53 | if User.query.filter_by(email=data['email']).first(): 54 | return bad_request('please use a different email address') 55 | user = User() 56 | user.from_dict(data, new_user=True) 57 | db.session.add(user) 58 | db.session.commit() 59 | response = jsonify(user.to_dict()) 60 | response.status_code = 201 61 | response.headers['Location'] = url_for('api.get_user', id=user.id) 62 | return response 63 | 64 | 65 | @bp.route('/users/', methods=['PUT']) 66 | @token_auth.login_required 67 | def update_user(id): 68 | if g.current_user.id != id: 69 | abort(403) 70 | user = User.query.get_or_404(id) 71 | data = request.get_json() or {} 72 | if 'username' in data and data['username'] != user.username and \ 73 | User.query.filter_by(username=data['username']).first(): 74 | return bad_request('please use a different username') 75 | if 'email' in data and data['email'] != user.email and \ 76 | User.query.filter_by(email=data['email']).first(): 77 | return bad_request('please use a different email address') 78 | user.from_dict(data, new_user=False) 79 | db.session.commit() 80 | return jsonify(user.to_dict()) 81 | -------------------------------------------------------------------------------- /app/auth/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | bp = Blueprint('auth', __name__) 4 | 5 | from app.auth import routes 6 | -------------------------------------------------------------------------------- /app/auth/email.py: -------------------------------------------------------------------------------- 1 | from flask import render_template, current_app 2 | from flask_babel import _ 3 | from app.email import send_email 4 | 5 | 6 | def send_password_reset_email(user): 7 | token = user.get_reset_password_token() 8 | send_email(_('[Microblog] Reset Your Password'), 9 | sender=current_app.config['ADMINS'][0], 10 | recipients=[user.email], 11 | text_body=render_template('email/reset_password.txt', 12 | user=user, token=token), 13 | html_body=render_template('email/reset_password.html', 14 | user=user, token=token)) 15 | -------------------------------------------------------------------------------- /app/auth/forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from wtforms import StringField, PasswordField, BooleanField, SubmitField 3 | from wtforms.validators import ValidationError, DataRequired, Email, EqualTo 4 | from flask_babel import _, lazy_gettext as _l 5 | import phonenumbers 6 | from app.models import User 7 | 8 | 9 | class LoginForm(FlaskForm): 10 | username = StringField(_l('Username'), validators=[DataRequired()]) 11 | password = PasswordField(_l('Password'), validators=[DataRequired()]) 12 | remember_me = BooleanField(_l('Remember Me')) 13 | submit = SubmitField(_l('Sign In')) 14 | 15 | 16 | class RegistrationForm(FlaskForm): 17 | username = StringField(_l('Username'), validators=[DataRequired()]) 18 | email = StringField(_l('Email'), validators=[DataRequired(), Email()]) 19 | password = PasswordField(_l('Password'), validators=[DataRequired()]) 20 | password2 = PasswordField( 21 | _l('Repeat Password'), validators=[DataRequired(), 22 | EqualTo('password')]) 23 | submit = SubmitField(_l('Register')) 24 | 25 | def validate_username(self, username): 26 | user = User.query.filter_by(username=username.data).first() 27 | if user is not None: 28 | raise ValidationError(_('Please use a different username.')) 29 | 30 | def validate_email(self, email): 31 | user = User.query.filter_by(email=email.data).first() 32 | if user is not None: 33 | raise ValidationError(_('Please use a different email address.')) 34 | 35 | 36 | class ResetPasswordRequestForm(FlaskForm): 37 | email = StringField(_l('Email'), validators=[DataRequired(), Email()]) 38 | submit = SubmitField(_l('Request Password Reset')) 39 | 40 | 41 | class ResetPasswordForm(FlaskForm): 42 | password = PasswordField(_l('Password'), validators=[DataRequired()]) 43 | password2 = PasswordField( 44 | _l('Repeat Password'), validators=[DataRequired(), 45 | EqualTo('password')]) 46 | submit = SubmitField(_l('Request Password Reset')) 47 | 48 | 49 | class Enable2faForm(FlaskForm): 50 | verification_phone = StringField(_l('Phone'), validators=[DataRequired()]) 51 | submit = SubmitField(_l('Enable 2FA')) 52 | 53 | def validate_verification_phone(self, verification_phone): 54 | try: 55 | p = phonenumbers.parse(verification_phone.data) 56 | if not phonenumbers.is_valid_number(p): 57 | raise ValueError() 58 | except (phonenumbers.phonenumberutil.NumberParseException, ValueError): 59 | raise ValidationError(_('Invalid phone number')) 60 | 61 | 62 | class Confirm2faForm(FlaskForm): 63 | token = StringField(_l('Token')) 64 | submit = SubmitField(_l('Verify')) 65 | 66 | 67 | class Disable2faForm(FlaskForm): 68 | submit = SubmitField(_l('Disable 2FA')) 69 | -------------------------------------------------------------------------------- /app/auth/routes.py: -------------------------------------------------------------------------------- 1 | from flask import render_template, redirect, url_for, flash, request, session 2 | from werkzeug.urls import url_parse 3 | from flask_login import login_user, logout_user, current_user, login_required 4 | from flask_babel import _ 5 | from app import db 6 | from app.auth import bp 7 | from app.auth.forms import LoginForm, RegistrationForm, \ 8 | ResetPasswordRequestForm, ResetPasswordForm, Enable2faForm, \ 9 | Confirm2faForm, Disable2faForm 10 | from app.models import User 11 | from app.auth.email import send_password_reset_email 12 | from app.auth.twilio_verify import request_verification_token, \ 13 | check_verification_token 14 | 15 | 16 | @bp.route('/login', methods=['GET', 'POST']) 17 | def login(): 18 | if current_user.is_authenticated: 19 | return redirect(url_for('main.index')) 20 | form = LoginForm() 21 | if form.validate_on_submit(): 22 | user = User.query.filter_by(username=form.username.data).first() 23 | if user is None or not user.check_password(form.password.data): 24 | flash(_('Invalid username or password')) 25 | return redirect(url_for('auth.login')) 26 | next_page = request.args.get('next') 27 | if not next_page or url_parse(next_page).netloc != '': 28 | next_page = url_for('main.index') 29 | if user.two_factor_enabled(): 30 | request_verification_token(user.verification_phone) 31 | session['username'] = user.username 32 | session['phone'] = user.verification_phone 33 | return redirect(url_for( 34 | 'auth.verify_2fa', next=next_page, 35 | remember='1' if form.remember_me.data else '0')) 36 | login_user(user, remember=form.remember_me.data) 37 | return redirect(next_page) 38 | return render_template('auth/login.html', title=_('Sign In'), form=form) 39 | 40 | 41 | @bp.route('/logout') 42 | def logout(): 43 | logout_user() 44 | return redirect(url_for('main.index')) 45 | 46 | 47 | @bp.route('/register', methods=['GET', 'POST']) 48 | def register(): 49 | if current_user.is_authenticated: 50 | return redirect(url_for('main.index')) 51 | form = RegistrationForm() 52 | if form.validate_on_submit(): 53 | user = User(username=form.username.data, email='miguelgrinberg50@gmail.com') #form.email.data) 54 | user.set_password(form.password.data) 55 | db.session.add(user) 56 | db.session.commit() 57 | flash(_('Congratulations, you are now a registered user!')) 58 | return redirect(url_for('auth.login')) 59 | return render_template('auth/register.html', title=_('Register'), 60 | form=form) 61 | 62 | 63 | @bp.route('/reset_password_request', methods=['GET', 'POST']) 64 | def reset_password_request(): 65 | if current_user.is_authenticated: 66 | return redirect(url_for('main.index')) 67 | form = ResetPasswordRequestForm() 68 | if form.validate_on_submit(): 69 | user = User.query.filter_by(email=form.email.data).first() 70 | if user: 71 | send_password_reset_email(user) 72 | flash( 73 | _('Check your email for the instructions to reset your password')) 74 | return redirect(url_for('auth.login')) 75 | return render_template('auth/reset_password_request.html', 76 | title=_('Reset Password'), form=form) 77 | 78 | 79 | @bp.route('/reset_password/', methods=['GET', 'POST']) 80 | def reset_password(token): 81 | if current_user.is_authenticated: 82 | return redirect(url_for('main.index')) 83 | user = User.verify_reset_password_token(token) 84 | if not user: 85 | return redirect(url_for('main.index')) 86 | form = ResetPasswordForm() 87 | if form.validate_on_submit(): 88 | user.set_password(form.password.data) 89 | db.session.commit() 90 | flash(_('Your password has been reset.')) 91 | return redirect(url_for('auth.login')) 92 | return render_template('auth/reset_password.html', form=form) 93 | 94 | 95 | @bp.route('/enable_2fa', methods=['GET', 'POST']) 96 | @login_required 97 | def enable_2fa(): 98 | form = Enable2faForm() 99 | if form.validate_on_submit(): 100 | session['phone'] = form.verification_phone.data 101 | request_verification_token(session['phone']) 102 | return redirect(url_for('auth.verify_2fa')) 103 | return render_template('auth/enable_2fa.html', form=form) 104 | 105 | 106 | @bp.route('/verify_2fa', methods=['GET', 'POST']) 107 | def verify_2fa(): 108 | form = Confirm2faForm() 109 | if form.validate_on_submit(): 110 | phone = session['phone'] 111 | if check_verification_token(phone, form.token.data): 112 | del session['phone'] 113 | if current_user.is_authenticated: 114 | current_user.verification_phone = phone 115 | db.session.commit() 116 | flash(_('Two-factor authentication is now enabled')) 117 | return redirect(url_for('main.index')) 118 | else: 119 | username = session['username'] 120 | del session['username'] 121 | user = User.query.filter_by(username=username).first() 122 | next_page = request.args.get('next') 123 | remember = request.args.get('remember', '0') == '1' 124 | login_user(user, remember=remember) 125 | return redirect(next_page) 126 | form.token.errors.append(_('Invalid token')) 127 | return render_template('auth/verify_2fa.html', form=form) 128 | 129 | 130 | @bp.route('/disable_2fa', methods=['GET', 'POST']) 131 | @login_required 132 | def disable_2fa(): 133 | form = Disable2faForm() 134 | if form.validate_on_submit(): 135 | current_user.verification_phone = None 136 | db.session.commit() 137 | flash(_('Two-factor authentication is now disabled.')) 138 | return redirect(url_for('main.index')) 139 | return render_template('auth/disable_2fa.html', form=form) 140 | -------------------------------------------------------------------------------- /app/auth/twilio_verify.py: -------------------------------------------------------------------------------- 1 | from flask import current_app 2 | from twilio.rest import Client, TwilioException 3 | 4 | 5 | def _get_twilio_verify_client(): 6 | return Client( 7 | current_app.config['TWILIO_ACCOUNT_SID'], 8 | current_app.config['TWILIO_AUTH_TOKEN']).verify.services( 9 | current_app.config['TWILIO_VERIFY_SERVICE_ID']) 10 | 11 | 12 | def request_verification_token(phone): 13 | verify = _get_twilio_verify_client() 14 | try: 15 | verify.verifications.create(to=phone, channel='sms') 16 | except TwilioException: 17 | verify.verifications.create(to=phone, channel='call') 18 | 19 | 20 | def check_verification_token(phone, token): 21 | verify = _get_twilio_verify_client() 22 | try: 23 | result = verify.verification_checks.create(to=phone, code=token) 24 | except TwilioException: 25 | return False 26 | return result.status == 'approved' 27 | -------------------------------------------------------------------------------- /app/cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | import click 3 | 4 | 5 | def register(app): 6 | @app.cli.group() 7 | def translate(): 8 | """Translation and localization commands.""" 9 | pass 10 | 11 | @translate.command() 12 | @click.argument('lang') 13 | def init(lang): 14 | """Initialize a new language.""" 15 | if os.system('pybabel extract -F babel.cfg -k _l -o messages.pot .'): 16 | raise RuntimeError('extract command failed') 17 | if os.system( 18 | 'pybabel init -i messages.pot -d app/translations -l ' + lang): 19 | raise RuntimeError('init command failed') 20 | os.remove('messages.pot') 21 | 22 | @translate.command() 23 | def update(): 24 | """Update all languages.""" 25 | if os.system('pybabel extract -F babel.cfg -k _l -o messages.pot .'): 26 | raise RuntimeError('extract command failed') 27 | if os.system('pybabel update -i messages.pot -d app/translations'): 28 | raise RuntimeError('update command failed') 29 | os.remove('messages.pot') 30 | 31 | @translate.command() 32 | def compile(): 33 | """Compile all languages.""" 34 | if os.system('pybabel compile -d app/translations'): 35 | raise RuntimeError('compile command failed') 36 | -------------------------------------------------------------------------------- /app/email.py: -------------------------------------------------------------------------------- 1 | from threading import Thread 2 | from flask import current_app 3 | from flask_mail import Message 4 | from app import mail 5 | 6 | 7 | def send_async_email(app, msg): 8 | with app.app_context(): 9 | mail.send(msg) 10 | 11 | 12 | def send_email(subject, sender, recipients, text_body, html_body, 13 | attachments=None, sync=False): 14 | msg = Message(subject, sender=sender, recipients=recipients) 15 | msg.body = text_body 16 | msg.html = html_body 17 | if attachments: 18 | for attachment in attachments: 19 | msg.attach(*attachment) 20 | if sync: 21 | mail.send(msg) 22 | else: 23 | Thread(target=send_async_email, 24 | args=(current_app._get_current_object(), msg)).start() 25 | -------------------------------------------------------------------------------- /app/errors/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | bp = Blueprint('errors', __name__) 4 | 5 | from app.errors import handlers 6 | -------------------------------------------------------------------------------- /app/errors/handlers.py: -------------------------------------------------------------------------------- 1 | from flask import render_template, request 2 | from app import db 3 | from app.errors import bp 4 | from app.api.errors import error_response as api_error_response 5 | 6 | 7 | def wants_json_response(): 8 | return request.accept_mimetypes['application/json'] >= \ 9 | request.accept_mimetypes['text/html'] 10 | 11 | 12 | @bp.app_errorhandler(404) 13 | def not_found_error(error): 14 | if wants_json_response(): 15 | return api_error_response(404) 16 | return render_template('errors/404.html'), 404 17 | 18 | 19 | @bp.app_errorhandler(500) 20 | def internal_error(error): 21 | db.session.rollback() 22 | if wants_json_response(): 23 | return api_error_response(500) 24 | return render_template('errors/500.html'), 500 25 | -------------------------------------------------------------------------------- /app/main/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | bp = Blueprint('main', __name__) 4 | 5 | from app.main import routes 6 | -------------------------------------------------------------------------------- /app/main/forms.py: -------------------------------------------------------------------------------- 1 | from flask import request 2 | from flask_wtf import FlaskForm 3 | from wtforms import StringField, SubmitField, TextAreaField 4 | from wtforms.validators import ValidationError, DataRequired, Length 5 | from flask_babel import _, lazy_gettext as _l 6 | from app.models import User 7 | 8 | 9 | class EditProfileForm(FlaskForm): 10 | username = StringField(_l('Username'), validators=[DataRequired()]) 11 | about_me = TextAreaField(_l('About me'), 12 | validators=[Length(min=0, max=140)]) 13 | submit = SubmitField(_l('Submit')) 14 | 15 | def __init__(self, original_username, *args, **kwargs): 16 | super(EditProfileForm, self).__init__(*args, **kwargs) 17 | self.original_username = original_username 18 | 19 | def validate_username(self, username): 20 | if username.data != self.original_username: 21 | user = User.query.filter_by(username=self.username.data).first() 22 | if user is not None: 23 | raise ValidationError(_('Please use a different username.')) 24 | 25 | 26 | class PostForm(FlaskForm): 27 | post = TextAreaField(_l('Say something'), validators=[DataRequired()]) 28 | submit = SubmitField(_l('Submit')) 29 | 30 | 31 | class SearchForm(FlaskForm): 32 | q = StringField(_l('Search'), validators=[DataRequired()]) 33 | 34 | def __init__(self, *args, **kwargs): 35 | if 'formdata' not in kwargs: 36 | kwargs['formdata'] = request.args 37 | if 'csrf_enabled' not in kwargs: 38 | kwargs['csrf_enabled'] = False 39 | super(SearchForm, self).__init__(*args, **kwargs) 40 | 41 | 42 | class MessageForm(FlaskForm): 43 | message = TextAreaField(_l('Message'), validators=[ 44 | DataRequired(), Length(min=1, max=140)]) 45 | submit = SubmitField(_l('Submit')) 46 | -------------------------------------------------------------------------------- /app/main/routes.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from flask import render_template, flash, redirect, url_for, request, g, \ 3 | jsonify, current_app 4 | from flask_login import current_user, login_required 5 | from flask_babel import _, get_locale 6 | from guess_language import guess_language 7 | from app import db 8 | from app.main.forms import EditProfileForm, PostForm, SearchForm, MessageForm 9 | from app.models import User, Post, Message, Notification 10 | from app.translate import translate 11 | from app.main import bp 12 | 13 | 14 | @bp.before_app_request 15 | def before_request(): 16 | if current_user.is_authenticated: 17 | current_user.last_seen = datetime.utcnow() 18 | db.session.commit() 19 | g.search_form = SearchForm() 20 | g.locale = str(get_locale()) 21 | 22 | 23 | @bp.route('/', methods=['GET', 'POST']) 24 | @bp.route('/index', methods=['GET', 'POST']) 25 | @login_required 26 | def index(): 27 | form = PostForm() 28 | if form.validate_on_submit(): 29 | language = guess_language(form.post.data) 30 | if language == 'UNKNOWN' or len(language) > 5: 31 | language = '' 32 | post = Post(body=form.post.data, author=current_user, 33 | language=language) 34 | db.session.add(post) 35 | db.session.commit() 36 | flash(_('Your post is now live!')) 37 | return redirect(url_for('main.index')) 38 | page = request.args.get('page', 1, type=int) 39 | posts = current_user.followed_posts().paginate( 40 | page, current_app.config['POSTS_PER_PAGE'], False) 41 | next_url = url_for('main.index', page=posts.next_num) \ 42 | if posts.has_next else None 43 | prev_url = url_for('main.index', page=posts.prev_num) \ 44 | if posts.has_prev else None 45 | return render_template('index.html', title=_('Home'), form=form, 46 | posts=posts.items, next_url=next_url, 47 | prev_url=prev_url) 48 | 49 | 50 | @bp.route('/explore') 51 | @login_required 52 | def explore(): 53 | page = request.args.get('page', 1, type=int) 54 | posts = Post.query.order_by(Post.timestamp.desc()).paginate( 55 | page, current_app.config['POSTS_PER_PAGE'], False) 56 | next_url = url_for('main.explore', page=posts.next_num) \ 57 | if posts.has_next else None 58 | prev_url = url_for('main.explore', page=posts.prev_num) \ 59 | if posts.has_prev else None 60 | return render_template('index.html', title=_('Explore'), 61 | posts=posts.items, next_url=next_url, 62 | prev_url=prev_url) 63 | 64 | 65 | @bp.route('/user/') 66 | @login_required 67 | def user(username): 68 | user = User.query.filter_by(username=username).first_or_404() 69 | page = request.args.get('page', 1, type=int) 70 | posts = user.posts.order_by(Post.timestamp.desc()).paginate( 71 | page, current_app.config['POSTS_PER_PAGE'], False) 72 | next_url = url_for('main.user', username=user.username, 73 | page=posts.next_num) if posts.has_next else None 74 | prev_url = url_for('main.user', username=user.username, 75 | page=posts.prev_num) if posts.has_prev else None 76 | return render_template('user.html', user=user, posts=posts.items, 77 | next_url=next_url, prev_url=prev_url) 78 | 79 | 80 | @bp.route('/user//popup') 81 | @login_required 82 | def user_popup(username): 83 | user = User.query.filter_by(username=username).first_or_404() 84 | return render_template('user_popup.html', user=user) 85 | 86 | 87 | @bp.route('/edit_profile', methods=['GET', 'POST']) 88 | @login_required 89 | def edit_profile(): 90 | form = EditProfileForm(current_user.username) 91 | if form.validate_on_submit(): 92 | current_user.username = form.username.data 93 | current_user.about_me = form.about_me.data 94 | db.session.commit() 95 | flash(_('Your changes have been saved.')) 96 | return redirect(url_for('main.edit_profile')) 97 | elif request.method == 'GET': 98 | form.username.data = current_user.username 99 | form.about_me.data = current_user.about_me 100 | return render_template('edit_profile.html', title=_('Edit Profile'), 101 | form=form) 102 | 103 | 104 | @bp.route('/follow/') 105 | @login_required 106 | def follow(username): 107 | user = User.query.filter_by(username=username).first() 108 | if user is None: 109 | flash(_('User %(username)s not found.', username=username)) 110 | return redirect(url_for('main.index')) 111 | if user == current_user: 112 | flash(_('You cannot follow yourself!')) 113 | return redirect(url_for('main.user', username=username)) 114 | current_user.follow(user) 115 | db.session.commit() 116 | flash(_('You are following %(username)s!', username=username)) 117 | return redirect(url_for('main.user', username=username)) 118 | 119 | 120 | @bp.route('/unfollow/') 121 | @login_required 122 | def unfollow(username): 123 | user = User.query.filter_by(username=username).first() 124 | if user is None: 125 | flash(_('User %(username)s not found.', username=username)) 126 | return redirect(url_for('main.index')) 127 | if user == current_user: 128 | flash(_('You cannot unfollow yourself!')) 129 | return redirect(url_for('main.user', username=username)) 130 | current_user.unfollow(user) 131 | db.session.commit() 132 | flash(_('You are not following %(username)s.', username=username)) 133 | return redirect(url_for('main.user', username=username)) 134 | 135 | 136 | @bp.route('/translate', methods=['POST']) 137 | @login_required 138 | def translate_text(): 139 | return jsonify({'text': translate(request.form['text'], 140 | request.form['source_language'], 141 | request.form['dest_language'])}) 142 | 143 | 144 | @bp.route('/search') 145 | @login_required 146 | def search(): 147 | if not g.search_form.validate(): 148 | return redirect(url_for('main.explore')) 149 | page = request.args.get('page', 1, type=int) 150 | posts, total = Post.search(g.search_form.q.data, page, 151 | current_app.config['POSTS_PER_PAGE']) 152 | next_url = url_for('main.search', q=g.search_form.q.data, page=page + 1) \ 153 | if total > page * current_app.config['POSTS_PER_PAGE'] else None 154 | prev_url = url_for('main.search', q=g.search_form.q.data, page=page - 1) \ 155 | if page > 1 else None 156 | return render_template('search.html', title=_('Search'), posts=posts, 157 | next_url=next_url, prev_url=prev_url) 158 | 159 | 160 | @bp.route('/send_message/', methods=['GET', 'POST']) 161 | @login_required 162 | def send_message(recipient): 163 | user = User.query.filter_by(username=recipient).first_or_404() 164 | form = MessageForm() 165 | if form.validate_on_submit(): 166 | msg = Message(author=current_user, recipient=user, 167 | body=form.message.data) 168 | db.session.add(msg) 169 | user.add_notification('unread_message_count', user.new_messages()) 170 | db.session.commit() 171 | flash(_('Your message has been sent.')) 172 | return redirect(url_for('main.user', username=recipient)) 173 | return render_template('send_message.html', title=_('Send Message'), 174 | form=form, recipient=recipient) 175 | 176 | 177 | @bp.route('/messages') 178 | @login_required 179 | def messages(): 180 | current_user.last_message_read_time = datetime.utcnow() 181 | current_user.add_notification('unread_message_count', 0) 182 | db.session.commit() 183 | page = request.args.get('page', 1, type=int) 184 | messages = current_user.messages_received.order_by( 185 | Message.timestamp.desc()).paginate( 186 | page, current_app.config['POSTS_PER_PAGE'], False) 187 | next_url = url_for('main.messages', page=messages.next_num) \ 188 | if messages.has_next else None 189 | prev_url = url_for('main.messages', page=messages.prev_num) \ 190 | if messages.has_prev else None 191 | return render_template('messages.html', messages=messages.items, 192 | next_url=next_url, prev_url=prev_url) 193 | 194 | 195 | @bp.route('/export_posts') 196 | @login_required 197 | def export_posts(): 198 | if current_user.get_task_in_progress('export_posts'): 199 | flash(_('An export task is currently in progress')) 200 | else: 201 | current_user.launch_task('export_posts', _('Exporting posts...')) 202 | db.session.commit() 203 | return redirect(url_for('main.user', username=current_user.username)) 204 | 205 | 206 | @bp.route('/notifications') 207 | @login_required 208 | def notifications(): 209 | since = request.args.get('since', 0.0, type=float) 210 | notifications = current_user.notifications.filter( 211 | Notification.timestamp > since).order_by(Notification.timestamp.asc()) 212 | return jsonify([{ 213 | 'name': n.name, 214 | 'data': n.get_data(), 215 | 'timestamp': n.timestamp 216 | } for n in notifications]) 217 | -------------------------------------------------------------------------------- /app/models.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from datetime import datetime, timedelta 3 | from hashlib import md5 4 | import json 5 | import os 6 | from time import time 7 | from flask import current_app, url_for 8 | from flask_login import UserMixin 9 | from werkzeug.security import generate_password_hash, check_password_hash 10 | import jwt 11 | import redis 12 | import rq 13 | from app import db, login 14 | from app.search import add_to_index, remove_from_index, query_index 15 | 16 | 17 | class SearchableMixin(object): 18 | @classmethod 19 | def search(cls, expression, page, per_page): 20 | ids, total = query_index(cls.__tablename__, expression, page, per_page) 21 | if total == 0: 22 | return cls.query.filter_by(id=0), 0 23 | when = [] 24 | for i in range(len(ids)): 25 | when.append((ids[i], i)) 26 | return cls.query.filter(cls.id.in_(ids)).order_by( 27 | db.case(when, value=cls.id)), total 28 | 29 | @classmethod 30 | def before_commit(cls, session): 31 | session._changes = { 32 | 'add': list(session.new), 33 | 'update': list(session.dirty), 34 | 'delete': list(session.deleted) 35 | } 36 | 37 | @classmethod 38 | def after_commit(cls, session): 39 | for obj in session._changes['add']: 40 | if isinstance(obj, SearchableMixin): 41 | add_to_index(obj.__tablename__, obj) 42 | for obj in session._changes['update']: 43 | if isinstance(obj, SearchableMixin): 44 | add_to_index(obj.__tablename__, obj) 45 | for obj in session._changes['delete']: 46 | if isinstance(obj, SearchableMixin): 47 | remove_from_index(obj.__tablename__, obj) 48 | session._changes = None 49 | 50 | @classmethod 51 | def reindex(cls): 52 | for obj in cls.query: 53 | add_to_index(cls.__tablename__, obj) 54 | 55 | 56 | db.event.listen(db.session, 'before_commit', SearchableMixin.before_commit) 57 | db.event.listen(db.session, 'after_commit', SearchableMixin.after_commit) 58 | 59 | 60 | class PaginatedAPIMixin(object): 61 | @staticmethod 62 | def to_collection_dict(query, page, per_page, endpoint, **kwargs): 63 | resources = query.paginate(page, per_page, False) 64 | data = { 65 | 'items': [item.to_dict() for item in resources.items], 66 | '_meta': { 67 | 'page': page, 68 | 'per_page': per_page, 69 | 'total_pages': resources.pages, 70 | 'total_items': resources.total 71 | }, 72 | '_links': { 73 | 'self': url_for(endpoint, page=page, per_page=per_page, 74 | **kwargs), 75 | 'next': url_for(endpoint, page=page + 1, per_page=per_page, 76 | **kwargs) if resources.has_next else None, 77 | 'prev': url_for(endpoint, page=page - 1, per_page=per_page, 78 | **kwargs) if resources.has_prev else None 79 | } 80 | } 81 | return data 82 | 83 | 84 | followers = db.Table( 85 | 'followers', 86 | db.Column('follower_id', db.Integer, db.ForeignKey('user.id')), 87 | db.Column('followed_id', db.Integer, db.ForeignKey('user.id')) 88 | ) 89 | 90 | 91 | class User(UserMixin, PaginatedAPIMixin, db.Model): 92 | id = db.Column(db.Integer, primary_key=True) 93 | username = db.Column(db.String(64), index=True, unique=True) 94 | email = db.Column(db.String(120), index=True, unique=True) 95 | password_hash = db.Column(db.String(128)) 96 | posts = db.relationship('Post', backref='author', lazy='dynamic') 97 | about_me = db.Column(db.String(140)) 98 | last_seen = db.Column(db.DateTime, default=datetime.utcnow) 99 | token = db.Column(db.String(32), index=True, unique=True) 100 | token_expiration = db.Column(db.DateTime) 101 | verification_phone = db.Column(db.String(16)) 102 | followed = db.relationship( 103 | 'User', secondary=followers, 104 | primaryjoin=(followers.c.follower_id == id), 105 | secondaryjoin=(followers.c.followed_id == id), 106 | backref=db.backref('followers', lazy='dynamic'), lazy='dynamic') 107 | messages_sent = db.relationship('Message', 108 | foreign_keys='Message.sender_id', 109 | backref='author', lazy='dynamic') 110 | messages_received = db.relationship('Message', 111 | foreign_keys='Message.recipient_id', 112 | backref='recipient', lazy='dynamic') 113 | last_message_read_time = db.Column(db.DateTime) 114 | notifications = db.relationship('Notification', backref='user', 115 | lazy='dynamic') 116 | tasks = db.relationship('Task', backref='user', lazy='dynamic') 117 | 118 | def __repr__(self): 119 | return ''.format(self.username) 120 | 121 | def set_password(self, password): 122 | self.password_hash = generate_password_hash(password) 123 | 124 | def check_password(self, password): 125 | return check_password_hash(self.password_hash, password) 126 | 127 | def avatar(self, size): 128 | digest = md5(self.email.lower().encode('utf-8')).hexdigest() 129 | return 'https://www.gravatar.com/avatar/{}?d=identicon&s={}'.format( 130 | digest, size) 131 | 132 | def follow(self, user): 133 | if not self.is_following(user): 134 | self.followed.append(user) 135 | 136 | def unfollow(self, user): 137 | if self.is_following(user): 138 | self.followed.remove(user) 139 | 140 | def is_following(self, user): 141 | return self.followed.filter( 142 | followers.c.followed_id == user.id).count() > 0 143 | 144 | def followed_posts(self): 145 | followed = Post.query.join( 146 | followers, (followers.c.followed_id == Post.user_id)).filter( 147 | followers.c.follower_id == self.id) 148 | own = Post.query.filter_by(user_id=self.id) 149 | return followed.union(own).order_by(Post.timestamp.desc()) 150 | 151 | def get_reset_password_token(self, expires_in=600): 152 | return jwt.encode( 153 | {'reset_password': self.id, 'exp': time() + expires_in}, 154 | current_app.config['SECRET_KEY'], 155 | algorithm='HS256').decode('utf-8') 156 | 157 | def two_factor_enabled(self): 158 | return self.verification_phone is not None 159 | 160 | @staticmethod 161 | def verify_reset_password_token(token): 162 | try: 163 | id = jwt.decode(token, current_app.config['SECRET_KEY'], 164 | algorithms=['HS256'])['reset_password'] 165 | except: 166 | return 167 | return User.query.get(id) 168 | 169 | def new_messages(self): 170 | last_read_time = self.last_message_read_time or datetime(1900, 1, 1) 171 | return Message.query.filter_by(recipient=self).filter( 172 | Message.timestamp > last_read_time).count() 173 | 174 | def add_notification(self, name, data): 175 | self.notifications.filter_by(name=name).delete() 176 | n = Notification(name=name, payload_json=json.dumps(data), user=self) 177 | db.session.add(n) 178 | return n 179 | 180 | def launch_task(self, name, description, *args, **kwargs): 181 | rq_job = current_app.task_queue.enqueue('app.tasks.' + name, self.id, 182 | *args, **kwargs) 183 | task = Task(id=rq_job.get_id(), name=name, description=description, 184 | user=self) 185 | db.session.add(task) 186 | return task 187 | 188 | def get_tasks_in_progress(self): 189 | return Task.query.filter_by(user=self, complete=False).all() 190 | 191 | def get_task_in_progress(self, name): 192 | return Task.query.filter_by(name=name, user=self, 193 | complete=False).first() 194 | 195 | def to_dict(self, include_email=False): 196 | data = { 197 | 'id': self.id, 198 | 'username': self.username, 199 | 'last_seen': self.last_seen.isoformat() + 'Z', 200 | 'about_me': self.about_me, 201 | 'post_count': self.posts.count(), 202 | 'follower_count': self.followers.count(), 203 | 'followed_count': self.followed.count(), 204 | '_links': { 205 | 'self': url_for('api.get_user', id=self.id), 206 | 'followers': url_for('api.get_followers', id=self.id), 207 | 'followed': url_for('api.get_followed', id=self.id), 208 | 'avatar': self.avatar(128) 209 | } 210 | } 211 | if include_email: 212 | data['email'] = self.email 213 | return data 214 | 215 | def from_dict(self, data, new_user=False): 216 | for field in ['username', 'email', 'about_me']: 217 | if field in data: 218 | setattr(self, field, data[field]) 219 | if new_user and 'password' in data: 220 | self.set_password(data['password']) 221 | 222 | def get_token(self, expires_in=3600): 223 | now = datetime.utcnow() 224 | if self.token and self.token_expiration > now + timedelta(seconds=60): 225 | return self.token 226 | self.token = base64.b64encode(os.urandom(24)).decode('utf-8') 227 | self.token_expiration = now + timedelta(seconds=expires_in) 228 | db.session.add(self) 229 | return self.token 230 | 231 | def revoke_token(self): 232 | self.token_expiration = datetime.utcnow() - timedelta(seconds=1) 233 | 234 | @staticmethod 235 | def check_token(token): 236 | user = User.query.filter_by(token=token).first() 237 | if user is None or user.token_expiration < datetime.utcnow(): 238 | return None 239 | return user 240 | 241 | 242 | @login.user_loader 243 | def load_user(id): 244 | return User.query.get(int(id)) 245 | 246 | 247 | class Post(SearchableMixin, db.Model): 248 | __searchable__ = ['body'] 249 | id = db.Column(db.Integer, primary_key=True) 250 | body = db.Column(db.String(140)) 251 | timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow) 252 | user_id = db.Column(db.Integer, db.ForeignKey('user.id')) 253 | language = db.Column(db.String(5)) 254 | 255 | def __repr__(self): 256 | return ''.format(self.body) 257 | 258 | 259 | class Message(db.Model): 260 | id = db.Column(db.Integer, primary_key=True) 261 | sender_id = db.Column(db.Integer, db.ForeignKey('user.id')) 262 | recipient_id = db.Column(db.Integer, db.ForeignKey('user.id')) 263 | body = db.Column(db.String(140)) 264 | timestamp = db.Column(db.DateTime, index=True, default=datetime.utcnow) 265 | 266 | def __repr__(self): 267 | return ''.format(self.body) 268 | 269 | 270 | class Notification(db.Model): 271 | id = db.Column(db.Integer, primary_key=True) 272 | name = db.Column(db.String(128), index=True) 273 | user_id = db.Column(db.Integer, db.ForeignKey('user.id')) 274 | timestamp = db.Column(db.Float, index=True, default=time) 275 | payload_json = db.Column(db.Text) 276 | 277 | def get_data(self): 278 | return json.loads(str(self.payload_json)) 279 | 280 | 281 | class Task(db.Model): 282 | id = db.Column(db.String(36), primary_key=True) 283 | name = db.Column(db.String(128), index=True) 284 | description = db.Column(db.String(128)) 285 | user_id = db.Column(db.Integer, db.ForeignKey('user.id')) 286 | complete = db.Column(db.Boolean, default=False) 287 | 288 | def get_rq_job(self): 289 | try: 290 | rq_job = rq.job.Job.fetch(self.id, connection=current_app.redis) 291 | except (redis.exceptions.RedisError, rq.exceptions.NoSuchJobError): 292 | return None 293 | return rq_job 294 | 295 | def get_progress(self): 296 | job = self.get_rq_job() 297 | return job.meta.get('progress', 0) if job is not None else 100 298 | -------------------------------------------------------------------------------- /app/search.py: -------------------------------------------------------------------------------- 1 | from flask import current_app 2 | 3 | 4 | def add_to_index(index, model): 5 | if not current_app.elasticsearch: 6 | return 7 | payload = {} 8 | for field in model.__searchable__: 9 | payload[field] = getattr(model, field) 10 | current_app.elasticsearch.index(index=index, id=model.id, body=payload) 11 | 12 | 13 | def remove_from_index(index, model): 14 | if not current_app.elasticsearch: 15 | return 16 | current_app.elasticsearch.delete(index=index, id=model.id) 17 | 18 | 19 | def query_index(index, query, page, per_page): 20 | if not current_app.elasticsearch: 21 | return [], 0 22 | search = current_app.elasticsearch.search( 23 | index=index, 24 | body={'query': {'multi_match': {'query': query, 'fields': ['*']}}, 25 | 'from': (page - 1) * per_page, 'size': per_page}) 26 | ids = [int(hit['_id']) for hit in search['hits']['hits']] 27 | return ids, search['hits']['total']['value'] 28 | -------------------------------------------------------------------------------- /app/static/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miguelgrinberg/microblog-verify/e610eb5d0e3a1798c049a53a5fb597e10a3555bd/app/static/loading.gif -------------------------------------------------------------------------------- /app/tasks.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | import time 4 | from flask import render_template 5 | from rq import get_current_job 6 | from app import create_app, db 7 | from app.models import User, Post, Task 8 | from app.email import send_email 9 | 10 | app = create_app() 11 | app.app_context().push() 12 | 13 | 14 | def _set_task_progress(progress): 15 | job = get_current_job() 16 | if job: 17 | job.meta['progress'] = progress 18 | job.save_meta() 19 | task = Task.query.get(job.get_id()) 20 | task.user.add_notification('task_progress', {'task_id': job.get_id(), 21 | 'progress': progress}) 22 | if progress >= 100: 23 | task.complete = True 24 | db.session.commit() 25 | 26 | 27 | def export_posts(user_id): 28 | try: 29 | user = User.query.get(user_id) 30 | _set_task_progress(0) 31 | data = [] 32 | i = 0 33 | total_posts = user.posts.count() 34 | for post in user.posts.order_by(Post.timestamp.asc()): 35 | data.append({'body': post.body, 36 | 'timestamp': post.timestamp.isoformat() + 'Z'}) 37 | time.sleep(5) 38 | i += 1 39 | _set_task_progress(100 * i // total_posts) 40 | 41 | send_email('[Microblog] Your blog posts', 42 | sender=app.config['ADMINS'][0], recipients=[user.email], 43 | text_body=render_template('email/export_posts.txt', user=user), 44 | html_body=render_template('email/export_posts.html', 45 | user=user), 46 | attachments=[('posts.json', 'application/json', 47 | json.dumps({'posts': data}, indent=4))], 48 | sync=True) 49 | except: 50 | _set_task_progress(100) 51 | app.logger.error('Unhandled exception', exc_info=sys.exc_info()) 52 | -------------------------------------------------------------------------------- /app/templates/_post.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 31 | 32 |
4 | 5 | 6 | 7 | 9 | {% set user_link %} 10 | 11 | 12 | {{ post.author.username }} 13 | 14 | 15 | {% endset %} 16 | {{ _('%(username)s said %(when)s', 17 | username=user_link, when=moment(post.timestamp).fromNow()) }} 18 |
19 | {{ post.body }} 20 | {% if post.language and post.language != g.locale %} 21 |

22 | 23 | {{ _('Translate') }} 28 | 29 | {% endif %} 30 |
33 | -------------------------------------------------------------------------------- /app/templates/auth/disable_2fa.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% import 'bootstrap/wtf.html' as wtf %} 3 | 4 | {% block app_content %} 5 |

{{ _('Disable Two-Factor Authentication') }}

6 |

{{ _('Please click the button below to disable two-factor authentication on your account.') }}

7 |
8 |
9 | {{ wtf.quick_form(form) }} 10 |
11 |
12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /app/templates/auth/enable_2fa.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% import 'bootstrap/wtf.html' as wtf %} 3 | 4 | {% block app_content %} 5 |

{{ _('Enable Two-Factor Authentication') }}

6 |

{{ _('Please enter your mobile number to activate two-factor authentication on your account.') }}

7 |
8 |
9 | {{ wtf.quick_form(form) }} 10 |
11 |
12 | {% endblock %} 13 | 14 | {% block styles %} 15 | {{ super() }} 16 | 17 | {% endblock %} 18 | 19 | {% block scripts %} 20 | {{ super() }} 21 | 22 | 34 | {% endblock %} 35 | -------------------------------------------------------------------------------- /app/templates/auth/login.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% import 'bootstrap/wtf.html' as wtf %} 3 | 4 | {% block app_content %} 5 |

{{ _('Sign In') }}

6 |
7 |
8 | {{ wtf.quick_form(form) }} 9 |
10 |
11 |
12 |

{{ _('New User?') }} {{ _('Click to Register!') }}

13 |

14 | {{ _('Forgot Your Password?') }} 15 | {{ _('Click to Reset It') }} 16 |

17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /app/templates/auth/register.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% import 'bootstrap/wtf.html' as wtf %} 3 | 4 | {% block app_content %} 5 |

{{ _('Register') }}

6 |
7 |
8 | {{ wtf.quick_form(form) }} 9 |
10 |
11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /app/templates/auth/reset_password.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% import 'bootstrap/wtf.html' as wtf %} 3 | 4 | {% block app_content %} 5 |

{{ _('Reset Your Password') }}

6 |
7 |
8 | {{ wtf.quick_form(form) }} 9 |
10 |
11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /app/templates/auth/reset_password_request.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% import 'bootstrap/wtf.html' as wtf %} 3 | 4 | {% block app_content %} 5 |

{{ _('Reset Password') }}

6 |
7 |
8 | {{ wtf.quick_form(form) }} 9 |
10 |
11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /app/templates/auth/verify_2fa.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% import 'bootstrap/wtf.html' as wtf %} 3 | 4 | {% block app_content %} 5 |

{{ _('Two-Factor Authentication') }}

6 |

{{ _('Please enter the token that was sent to your phone.') }}

7 |
8 |
9 | {{ wtf.quick_form(form) }} 10 |
11 |
12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /app/templates/base.html: -------------------------------------------------------------------------------- 1 | {% extends 'bootstrap/base.html' %} 2 | 3 | {% block title %} 4 | {% if title %}{{ title }} - Microblog{% else %}{{ _('Welcome to Microblog') }}{% endif %} 5 | {% endblock %} 6 | 7 | {% block navbar %} 8 | 52 | {% endblock %} 53 | 54 | {% block content %} 55 |
56 | {% if current_user.is_authenticated %} 57 | {% with tasks = current_user.get_tasks_in_progress() %} 58 | {% if tasks %} 59 | {% for task in tasks %} 60 | 64 | {% endfor %} 65 | {% endif %} 66 | {% endwith %} 67 | {% endif %} 68 | {% with messages = get_flashed_messages() %} 69 | {% if messages %} 70 | {% for message in messages %} 71 | 72 | {% endfor %} 73 | {% endif %} 74 | {% endwith %} 75 | 76 | {# application content needs to be provided in the app_content block #} 77 | {% block app_content %}{% endblock %} 78 |
79 | {% endblock %} 80 | 81 | {% block scripts %} 82 | {{ super() }} 83 | {{ moment.include_moment() }} 84 | {{ moment.lang(g.locale) }} 85 | 171 | {% endblock %} 172 | -------------------------------------------------------------------------------- /app/templates/edit_profile.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% import 'bootstrap/wtf.html' as wtf %} 3 | 4 | {% block app_content %} 5 |

{{ _('Edit Profile') }}

6 |
7 |
8 | {{ wtf.quick_form(form) }} 9 |
10 |
11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /app/templates/email/export_posts.html: -------------------------------------------------------------------------------- 1 |

Dear {{ user.username }},

2 |

Please find attached the archive of your posts that you requested.

3 |

Sincerely,

4 |

The Microblog Team

5 | -------------------------------------------------------------------------------- /app/templates/email/export_posts.txt: -------------------------------------------------------------------------------- 1 | Dear {{ user.username }}, 2 | 3 | Please find attached the archive of your posts that you requested. 4 | 5 | Sincerely, 6 | 7 | The Microblog Team 8 | -------------------------------------------------------------------------------- /app/templates/email/reset_password.html: -------------------------------------------------------------------------------- 1 |

Dear {{ user.username }},

2 |

3 | To reset your password 4 | 5 | click here 6 | . 7 |

8 |

Alternatively, you can paste the following link in your browser's address bar:

9 |

{{ url_for('auth.reset_password', token=token, _external=True) }}

10 |

If you have not requested a password reset simply ignore this message.

11 |

Sincerely,

12 |

The Microblog Team

13 | -------------------------------------------------------------------------------- /app/templates/email/reset_password.txt: -------------------------------------------------------------------------------- 1 | Dear {{ user.username }}, 2 | 3 | To reset your password click on the following link: 4 | 5 | {{ url_for('auth.reset_password', token=token, _external=True) }} 6 | 7 | If you have not requested a password reset simply ignore this message. 8 | 9 | Sincerely, 10 | 11 | The Microblog Team 12 | -------------------------------------------------------------------------------- /app/templates/errors/404.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block app_content %} 4 |

{{ _('Not Found') }}

5 |

{{ _('Back') }}

6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /app/templates/errors/500.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block app_content %} 4 |

{{ _('An unexpected error has occurred') }}

5 |

{{ _('The administrator has been notified. Sorry for the inconvenience!') }}

6 |

{{ _('Back') }}

7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /app/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% import 'bootstrap/wtf.html' as wtf %} 3 | 4 | {% block app_content %} 5 |

{{ _('Hi, %(username)s!', username=current_user.username) }}

6 | {% if form %} 7 | {{ wtf.quick_form(form) }} 8 |
9 | {% endif %} 10 | {% for post in posts %} 11 | {% include '_post.html' %} 12 | {% endfor %} 13 | 27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /app/templates/messages.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block app_content %} 4 |

{{ _('Messages') }}

5 | {% for post in messages %} 6 | {% include '_post.html' %} 7 | {% endfor %} 8 | 22 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/search.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block app_content %} 4 |

{{ _('Search Results') }}

5 | {% for post in posts %} 6 | {% include '_post.html' %} 7 | {% endfor %} 8 | 22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /app/templates/send_message.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% import 'bootstrap/wtf.html' as wtf %} 3 | 4 | {% block app_content %} 5 |

{{ _('Send Message to %(recipient)s', recipient=recipient) }}

6 |
7 |
8 | {{ wtf.quick_form(form) }} 9 |
10 |
11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /app/templates/user.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block app_content %} 4 | 5 | 6 | 7 | 33 | 34 |
8 |

{{ _('User') }}: {{ user.username }}

9 | {% if user.about_me %}

{{ user.about_me }}

{% endif %} 10 | {% if user.last_seen %} 11 |

{{ _('Last seen on') }}: {{ moment(user.last_seen).format('LLL') }}

12 | {% endif %} 13 |

{{ _('%(count)d followers', count=user.followers.count()) }}, {{ _('%(count)d following', count=user.followed.count()) }}

14 | {% if user == current_user %} 15 |

{{ _('Edit your profile') }}

16 | {% if not current_user.get_task_in_progress('export_posts') %} 17 |

{{ _('Export your posts') }}

18 | {% endif %} 19 | {% elif not current_user.is_following(user) %} 20 |

{{ _('Follow') }}

21 | {% else %} 22 |

{{ _('Unfollow') }}

23 | {% endif %} 24 | {% if user != current_user %} 25 |

{{ _('Send private message') }}

26 | {% endif %} 27 | {% if not user.two_factor_enabled() %} 28 |

{{ _('Enable two-factor authentication') }}

29 | {% else %} 30 |

{{ _('Disable two-factor authentication') }}

31 | {% endif %} 32 |
35 | {% for post in posts %} 36 | {% include '_post.html' %} 37 | {% endfor %} 38 | 52 | {% endblock %} 53 | -------------------------------------------------------------------------------- /app/templates/user_popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 21 | 22 |
5 |

{{ user.username }}

6 | 7 | {% if user.about_me %}

{{ user.about_me }}

{% endif %} 8 | {% if user.last_seen %} 9 |

{{ _('Last seen on') }}: {{ moment(user.last_seen).format('lll') }}

10 | {% endif %} 11 |

{{ _('%(count)d followers', count=user.followers.count()) }}, {{ _('%(count)d following', count=user.followed.count()) }}

12 | {% if user != current_user %} 13 | {% if not current_user.is_following(user) %} 14 | {{ _('Follow') }} 15 | {% else %} 16 | {{ _('Unfollow') }} 17 | {% endif %} 18 | {% endif %} 19 |
20 |
23 | -------------------------------------------------------------------------------- /app/translate.py: -------------------------------------------------------------------------------- 1 | import json 2 | import requests 3 | from flask import current_app 4 | from flask_babel import _ 5 | 6 | 7 | def translate(text, source_language, dest_language): 8 | if 'MS_TRANSLATOR_KEY' not in current_app.config or \ 9 | not current_app.config['MS_TRANSLATOR_KEY']: 10 | return _('Error: the translation service is not configured.') 11 | auth = { 12 | 'Ocp-Apim-Subscription-Key': current_app.config['MS_TRANSLATOR_KEY']} 13 | r = requests.get('https://api.microsofttranslator.com/v2/Ajax.svc' 14 | '/Translate?text={}&from={}&to={}'.format( 15 | text, source_language, dest_language), 16 | headers=auth) 17 | if r.status_code != 200: 18 | return _('Error: the translation service failed.') 19 | return json.loads(r.content.decode('utf-8-sig')) 20 | -------------------------------------------------------------------------------- /app/translations/es/LC_MESSAGES/messages.po: -------------------------------------------------------------------------------- 1 | # Spanish translations for PROJECT. 2 | # Copyright (C) 2017 ORGANIZATION 3 | # This file is distributed under the same license as the PROJECT project. 4 | # FIRST AUTHOR , 2017. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: PROJECT VERSION\n" 9 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" 10 | "POT-Creation-Date: 2017-11-25 18:27-0800\n" 11 | "PO-Revision-Date: 2017-09-29 23:25-0700\n" 12 | "Last-Translator: FULL NAME \n" 13 | "Language: es\n" 14 | "Language-Team: es \n" 15 | "Plural-Forms: nplurals=2; plural=(n != 1)\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=utf-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Generated-By: Babel 2.5.1\n" 20 | 21 | #: app/__init__.py:20 22 | msgid "Please log in to access this page." 23 | msgstr "Por favor ingrese para acceder a esta página." 24 | 25 | #: app/translate.py:10 26 | msgid "Error: the translation service is not configured." 27 | msgstr "Error: el servicio de traducciones no está configurado." 28 | 29 | #: app/translate.py:18 30 | msgid "Error: the translation service failed." 31 | msgstr "Error el servicio de traducciones ha fallado." 32 | 33 | #: app/auth/email.py:8 34 | msgid "[Microblog] Reset Your Password" 35 | msgstr "[Microblog] Nueva Contraseña" 36 | 37 | #: app/auth/forms.py:10 app/auth/forms.py:17 app/main/forms.py:10 38 | msgid "Username" 39 | msgstr "Nombre de usuario" 40 | 41 | #: app/auth/forms.py:11 app/auth/forms.py:19 app/auth/forms.py:42 42 | msgid "Password" 43 | msgstr "Contraseña" 44 | 45 | #: app/auth/forms.py:12 46 | msgid "Remember Me" 47 | msgstr "Recordarme" 48 | 49 | #: app/auth/forms.py:13 app/templates/auth/login.html:5 50 | msgid "Sign In" 51 | msgstr "Ingresar" 52 | 53 | #: app/auth/forms.py:18 app/auth/forms.py:37 54 | msgid "Email" 55 | msgstr "Email" 56 | 57 | #: app/auth/forms.py:21 app/auth/forms.py:44 58 | msgid "Repeat Password" 59 | msgstr "Repetir Contraseña" 60 | 61 | #: app/auth/forms.py:23 app/templates/auth/register.html:5 62 | msgid "Register" 63 | msgstr "Registrarse" 64 | 65 | #: app/auth/forms.py:28 app/main/forms.py:23 66 | msgid "Please use a different username." 67 | msgstr "Por favor use un nombre de usuario diferente." 68 | 69 | #: app/auth/forms.py:33 70 | msgid "Please use a different email address." 71 | msgstr "Por favor use una dirección de email diferente." 72 | 73 | #: app/auth/forms.py:38 app/auth/forms.py:46 74 | msgid "Request Password Reset" 75 | msgstr "Pedir una nueva contraseña" 76 | 77 | #: app/auth/routes.py:20 78 | msgid "Invalid username or password" 79 | msgstr "Nombre de usuario o contraseña inválidos" 80 | 81 | #: app/auth/routes.py:46 82 | msgid "Congratulations, you are now a registered user!" 83 | msgstr "¡Felicitaciones, ya eres un usuario registrado!" 84 | 85 | #: app/auth/routes.py:61 86 | msgid "Check your email for the instructions to reset your password" 87 | msgstr "Busca en tu email las instrucciones para crear una nueva contraseña" 88 | 89 | #: app/auth/routes.py:78 90 | msgid "Your password has been reset." 91 | msgstr "Tu contraseña ha sido cambiada." 92 | 93 | #: app/main/forms.py:11 94 | msgid "About me" 95 | msgstr "Acerca de mí" 96 | 97 | #: app/main/forms.py:13 app/main/forms.py:28 app/main/forms.py:44 98 | msgid "Submit" 99 | msgstr "Enviar" 100 | 101 | #: app/main/forms.py:27 102 | msgid "Say something" 103 | msgstr "Dí algo" 104 | 105 | #: app/main/forms.py:32 106 | msgid "Search" 107 | msgstr "Buscar" 108 | 109 | #: app/main/forms.py:43 110 | msgid "Message" 111 | msgstr "Mensaje" 112 | 113 | #: app/main/routes.py:36 114 | msgid "Your post is now live!" 115 | msgstr "¡Tu artículo ha sido publicado!" 116 | 117 | #: app/main/routes.py:94 118 | msgid "Your changes have been saved." 119 | msgstr "Tus cambios han sido salvados." 120 | 121 | #: app/main/routes.py:99 app/templates/edit_profile.html:5 122 | msgid "Edit Profile" 123 | msgstr "Editar Perfil" 124 | 125 | #: app/main/routes.py:108 app/main/routes.py:124 126 | #, python-format 127 | msgid "User %(username)s not found." 128 | msgstr "El usuario %(username)s no ha sido encontrado." 129 | 130 | #: app/main/routes.py:111 131 | msgid "You cannot follow yourself!" 132 | msgstr "¡No te puedes seguir a tí mismo!" 133 | 134 | #: app/main/routes.py:115 135 | #, python-format 136 | msgid "You are following %(username)s!" 137 | msgstr "¡Ahora estás siguiendo a %(username)s!" 138 | 139 | #: app/main/routes.py:127 140 | msgid "You cannot unfollow yourself!" 141 | msgstr "¡No te puedes dejar de seguir a tí mismo!" 142 | 143 | #: app/main/routes.py:131 144 | #, python-format 145 | msgid "You are not following %(username)s." 146 | msgstr "No estás siguiendo a %(username)s." 147 | 148 | #: app/main/routes.py:170 149 | msgid "Your message has been sent." 150 | msgstr "Tu mensaje ha sido enviado." 151 | 152 | #: app/main/routes.py:172 153 | msgid "Send Message" 154 | msgstr "Enviar Mensaje" 155 | 156 | #: app/main/routes.py:197 157 | msgid "An export task is currently in progress" 158 | msgstr "Una tarea de exportación esta en progreso" 159 | 160 | #: app/main/routes.py:199 161 | msgid "Exporting posts..." 162 | msgstr "Exportando artículos..." 163 | 164 | #: app/templates/_post.html:16 165 | #, python-format 166 | msgid "%(username)s said %(when)s" 167 | msgstr "%(username)s dijo %(when)s" 168 | 169 | #: app/templates/_post.html:27 170 | msgid "Translate" 171 | msgstr "Traducir" 172 | 173 | #: app/templates/base.html:4 174 | msgid "Welcome to Microblog" 175 | msgstr "Bienvenido a Microblog" 176 | 177 | #: app/templates/base.html:21 178 | msgid "Home" 179 | msgstr "Inicio" 180 | 181 | #: app/templates/base.html:22 182 | msgid "Explore" 183 | msgstr "Explorar" 184 | 185 | #: app/templates/base.html:33 186 | msgid "Login" 187 | msgstr "Ingresar" 188 | 189 | #: app/templates/base.html:36 app/templates/messages.html:4 190 | msgid "Messages" 191 | msgstr "Mensajes" 192 | 193 | #: app/templates/base.html:45 194 | msgid "Profile" 195 | msgstr "Perfil" 196 | 197 | #: app/templates/base.html:46 198 | msgid "Logout" 199 | msgstr "Salir" 200 | 201 | #: app/templates/base.html:95 202 | msgid "Error: Could not contact server." 203 | msgstr "Error: el servidor no pudo ser contactado." 204 | 205 | #: app/templates/index.html:5 206 | #, python-format 207 | msgid "Hi, %(username)s!" 208 | msgstr "¡Hola, %(username)s!" 209 | 210 | #: app/templates/index.html:17 app/templates/user.html:37 211 | msgid "Newer posts" 212 | msgstr "Artículos siguientes" 213 | 214 | #: app/templates/index.html:22 app/templates/user.html:42 215 | msgid "Older posts" 216 | msgstr "Artículos previos" 217 | 218 | #: app/templates/messages.html:12 219 | msgid "Newer messages" 220 | msgstr "Mensajes siguientes" 221 | 222 | #: app/templates/messages.html:17 223 | msgid "Older messages" 224 | msgstr "Mensajes previos" 225 | 226 | #: app/templates/search.html:4 227 | msgid "Search Results" 228 | msgstr "" 229 | 230 | #: app/templates/search.html:12 231 | msgid "Previous results" 232 | msgstr "" 233 | 234 | #: app/templates/search.html:17 235 | msgid "Next results" 236 | msgstr "" 237 | 238 | #: app/templates/send_message.html:5 239 | #, python-format 240 | msgid "Send Message to %(recipient)s" 241 | msgstr "Enviar Mensaje a %(recipient)s" 242 | 243 | #: app/templates/user.html:8 244 | msgid "User" 245 | msgstr "Usuario" 246 | 247 | #: app/templates/user.html:11 app/templates/user_popup.html:9 248 | msgid "Last seen on" 249 | msgstr "Última visita" 250 | 251 | #: app/templates/user.html:13 app/templates/user_popup.html:11 252 | #, python-format 253 | msgid "%(count)d followers" 254 | msgstr "%(count)d seguidores" 255 | 256 | #: app/templates/user.html:13 app/templates/user_popup.html:11 257 | #, python-format 258 | msgid "%(count)d following" 259 | msgstr "siguiendo a %(count)d" 260 | 261 | #: app/templates/user.html:15 262 | msgid "Edit your profile" 263 | msgstr "Editar tu perfil" 264 | 265 | #: app/templates/user.html:17 266 | msgid "Export your posts" 267 | msgstr "Exportar tus artículos" 268 | 269 | #: app/templates/user.html:20 app/templates/user_popup.html:14 270 | msgid "Follow" 271 | msgstr "Seguir" 272 | 273 | #: app/templates/user.html:22 app/templates/user_popup.html:16 274 | msgid "Unfollow" 275 | msgstr "Dejar de seguir" 276 | 277 | #: app/templates/user.html:25 278 | msgid "Send private message" 279 | msgstr "Enviar mensaje privado" 280 | 281 | #: app/templates/auth/login.html:12 282 | msgid "New User?" 283 | msgstr "¿Usuario Nuevo?" 284 | 285 | #: app/templates/auth/login.html:12 286 | msgid "Click to Register!" 287 | msgstr "¡Haz click aquí para registrarte!" 288 | 289 | #: app/templates/auth/login.html:14 290 | msgid "Forgot Your Password?" 291 | msgstr "¿Te olvidaste tu contraseña?" 292 | 293 | #: app/templates/auth/login.html:15 294 | msgid "Click to Reset It" 295 | msgstr "Haz click aquí para pedir una nueva" 296 | 297 | #: app/templates/auth/reset_password.html:5 298 | msgid "Reset Your Password" 299 | msgstr "Nueva Contraseña" 300 | 301 | #: app/templates/auth/reset_password_request.html:5 302 | msgid "Reset Password" 303 | msgstr "Nueva Contraseña" 304 | 305 | #: app/templates/errors/404.html:4 306 | msgid "Not Found" 307 | msgstr "Página No Encontrada" 308 | 309 | #: app/templates/errors/404.html:5 app/templates/errors/500.html:6 310 | msgid "Back" 311 | msgstr "Atrás" 312 | 313 | #: app/templates/errors/500.html:4 314 | msgid "An unexpected error has occurred" 315 | msgstr "Ha ocurrido un error inesperado" 316 | 317 | #: app/templates/errors/500.html:5 318 | msgid "The administrator has been notified. Sorry for the inconvenience!" 319 | msgstr "El administrador ha sido notificado. ¡Lamentamos la inconveniencia!" 320 | 321 | -------------------------------------------------------------------------------- /babel.cfg: -------------------------------------------------------------------------------- 1 | [python: app/**.py] 2 | [jinja2: app/templates/**.html] 3 | extensions=jinja2.ext.autoescape,jinja2.ext.with_ 4 | -------------------------------------------------------------------------------- /boot.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # this script is used to boot a Docker container 3 | source venv/bin/activate 4 | while true; do 5 | flask db upgrade 6 | if [[ "$?" == "0" ]]; then 7 | break 8 | fi 9 | echo Deploy command failed, retrying in 5 secs... 10 | sleep 5 11 | done 12 | flask translate compile 13 | exec gunicorn -b :5000 --access-logfile - --error-logfile - microblog:app 14 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | 4 | basedir = os.path.abspath(os.path.dirname(__file__)) 5 | load_dotenv(os.path.join(basedir, '.env')) 6 | 7 | 8 | class Config(object): 9 | SECRET_KEY = os.environ.get('SECRET_KEY') or 'you-will-never-guess' 10 | SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ 11 | 'sqlite:///' + os.path.join(basedir, 'app.db') 12 | SQLALCHEMY_TRACK_MODIFICATIONS = False 13 | LOG_TO_STDOUT = os.environ.get('LOG_TO_STDOUT') 14 | MAIL_SERVER = os.environ.get('MAIL_SERVER') 15 | MAIL_PORT = int(os.environ.get('MAIL_PORT') or 25) 16 | MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') is not None 17 | MAIL_USERNAME = os.environ.get('MAIL_USERNAME') 18 | MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') 19 | ADMINS = ['your-email@example.com'] 20 | LANGUAGES = ['en', 'es'] 21 | MS_TRANSLATOR_KEY = os.environ.get('MS_TRANSLATOR_KEY') 22 | ELASTICSEARCH_URL = os.environ.get('ELASTICSEARCH_URL') 23 | REDIS_URL = os.environ.get('REDIS_URL') or 'redis://' 24 | POSTS_PER_PAGE = 25 25 | TWILIO_ACCOUNT_SID = os.environ.get('TWILIO_ACCOUNT_SID') 26 | TWILIO_AUTH_TOKEN = os.environ.get('TWILIO_ACCOUNT_TOKEN') 27 | TWILIO_VERIFY_SERVICE_ID = os.environ.get('TWILIO_VERIFY_SERVICE_ID') 28 | -------------------------------------------------------------------------------- /deployment/nginx/microblog: -------------------------------------------------------------------------------- 1 | server { 2 | # listen on port 80 (http) 3 | listen 80; 4 | server_name _; 5 | location / { 6 | # redirect any requests to the same URL but on https 7 | return 301 https://$host$request_uri; 8 | } 9 | } 10 | server { 11 | # listen on port 443 (https) 12 | listen 443 ssl; 13 | server_name _; 14 | 15 | # location of the self-signed SSL certificate 16 | ssl_certificate /home/ubuntu/microblog/certs/cert.pem; 17 | ssl_certificate_key /home/ubuntu/microblog/certs/key.pem; 18 | 19 | # write access and error logs to /var/log 20 | access_log /var/log/microblog_access.log; 21 | error_log /var/log/microblog_error.log; 22 | 23 | location / { 24 | # forward application requests to the gunicorn server 25 | proxy_pass http://localhost:8000; 26 | proxy_redirect off; 27 | proxy_set_header Host $host; 28 | proxy_set_header X-Real-IP $remote_addr; 29 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 30 | } 31 | 32 | location /static { 33 | # handle static files directly, without forwarding to the application 34 | alias /home/ubuntu/microblog/app/static; 35 | expires 30d; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /deployment/supervisor/microblog-tasks.conf: -------------------------------------------------------------------------------- 1 | [program:microblog-tasks] 2 | command=/home/ubuntu/microblog/venv/bin/rq worker microblog-tasks 3 | numprocs=1 4 | directory=/home/ubuntu/microblog 5 | user=ubuntu 6 | autostart=true 7 | autorestart=true 8 | stopasgroup=true 9 | killasgroup=true 10 | -------------------------------------------------------------------------------- /deployment/supervisor/microblog.conf: -------------------------------------------------------------------------------- 1 | [program:microblog] 2 | command=/home/ubuntu/microblog/venv/bin/gunicorn -b localhost:8000 -w 4 microblog:app 3 | directory=/home/ubuntu/microblog 4 | user=ubuntu 5 | autostart=true 6 | autorestart=true 7 | stopasgroup=true 8 | killasgroup=true 9 | -------------------------------------------------------------------------------- /microblog.py: -------------------------------------------------------------------------------- 1 | from app import create_app, db, cli 2 | from app.models import User, Post, Message, Notification, Task 3 | 4 | app = create_app() 5 | cli.register(app) 6 | 7 | 8 | @app.shell_context_processor 9 | def make_shell_context(): 10 | return {'db': db, 'User': User, 'Post': Post, 'Message': Message, 11 | 'Notification': Notification, 'Task': Task} 12 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | from alembic import context 3 | from sqlalchemy import engine_from_config, pool 4 | from logging.config import fileConfig 5 | import logging 6 | 7 | # this is the Alembic Config object, which provides 8 | # access to the values within the .ini file in use. 9 | config = context.config 10 | 11 | # Interpret the config file for Python logging. 12 | # This line sets up loggers basically. 13 | fileConfig(config.config_file_name) 14 | logger = logging.getLogger('alembic.env') 15 | 16 | # add your model's MetaData object here 17 | # for 'autogenerate' support 18 | # from myapp import mymodel 19 | # target_metadata = mymodel.Base.metadata 20 | from flask import current_app 21 | config.set_main_option('sqlalchemy.url', 22 | current_app.config.get('SQLALCHEMY_DATABASE_URI')) 23 | target_metadata = current_app.extensions['migrate'].db.metadata 24 | 25 | # other values from the config, defined by the needs of env.py, 26 | # can be acquired: 27 | # my_important_option = config.get_main_option("my_important_option") 28 | # ... etc. 29 | 30 | 31 | def run_migrations_offline(): 32 | """Run migrations in 'offline' mode. 33 | 34 | This configures the context with just a URL 35 | and not an Engine, though an Engine is acceptable 36 | here as well. By skipping the Engine creation 37 | we don't even need a DBAPI to be available. 38 | 39 | Calls to context.execute() here emit the given string to the 40 | script output. 41 | 42 | """ 43 | url = config.get_main_option("sqlalchemy.url") 44 | context.configure(url=url) 45 | 46 | with context.begin_transaction(): 47 | context.run_migrations() 48 | 49 | 50 | def run_migrations_online(): 51 | """Run migrations in 'online' mode. 52 | 53 | In this scenario we need to create an Engine 54 | and associate a connection with the context. 55 | 56 | """ 57 | 58 | # this callback is used to prevent an auto-migration from being generated 59 | # when there are no changes to the schema 60 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 61 | def process_revision_directives(context, revision, directives): 62 | if getattr(config.cmd_opts, 'autogenerate', False): 63 | script = directives[0] 64 | if script.upgrade_ops.is_empty(): 65 | directives[:] = [] 66 | logger.info('No changes in schema detected.') 67 | 68 | engine = engine_from_config(config.get_section(config.config_ini_section), 69 | prefix='sqlalchemy.', 70 | poolclass=pool.NullPool) 71 | 72 | connection = engine.connect() 73 | context.configure(connection=connection, 74 | target_metadata=target_metadata, 75 | process_revision_directives=process_revision_directives, 76 | **current_app.extensions['migrate'].configure_args) 77 | 78 | try: 79 | with context.begin_transaction(): 80 | context.run_migrations() 81 | finally: 82 | connection.close() 83 | 84 | if context.is_offline_mode(): 85 | run_migrations_offline() 86 | else: 87 | run_migrations_online() 88 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /migrations/versions/2b017edaa91f_add_language_to_posts.py: -------------------------------------------------------------------------------- 1 | """add language to posts 2 | 3 | Revision ID: 2b017edaa91f 4 | Revises: ae346256b650 5 | Create Date: 2017-10-04 22:48:34.494465 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '2b017edaa91f' 14 | down_revision = 'ae346256b650' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('post', sa.Column('language', sa.String(length=5), nullable=True)) 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.drop_column('post', 'language') 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /migrations/versions/37f06a334dbf_new_fields_in_user_model.py: -------------------------------------------------------------------------------- 1 | """new fields in user model 2 | 3 | Revision ID: 37f06a334dbf 4 | Revises: 780739b227a7 5 | Create Date: 2017-09-14 10:54:13.865401 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '37f06a334dbf' 14 | down_revision = '780739b227a7' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('user', sa.Column('about_me', sa.String(length=140), nullable=True)) 22 | op.add_column('user', sa.Column('last_seen', sa.DateTime(), nullable=True)) 23 | # ### end Alembic commands ### 24 | 25 | 26 | def downgrade(): 27 | # ### commands auto generated by Alembic - please adjust! ### 28 | op.drop_column('user', 'last_seen') 29 | op.drop_column('user', 'about_me') 30 | # ### end Alembic commands ### 31 | -------------------------------------------------------------------------------- /migrations/versions/780739b227a7_posts_table.py: -------------------------------------------------------------------------------- 1 | """posts table 2 | 3 | Revision ID: 780739b227a7 4 | Revises: e517276bb1c2 5 | Create Date: 2017-09-11 12:23:25.496587 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '780739b227a7' 14 | down_revision = 'e517276bb1c2' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('post', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('body', sa.String(length=140), nullable=True), 24 | sa.Column('timestamp', sa.DateTime(), nullable=True), 25 | sa.Column('user_id', sa.Integer(), nullable=True), 26 | sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), 27 | sa.PrimaryKeyConstraint('id') 28 | ) 29 | op.create_index(op.f('ix_post_timestamp'), 'post', ['timestamp'], unique=False) 30 | # ### end Alembic commands ### 31 | 32 | 33 | def downgrade(): 34 | # ### commands auto generated by Alembic - please adjust! ### 35 | op.drop_index(op.f('ix_post_timestamp'), table_name='post') 36 | op.drop_table('post') 37 | # ### end Alembic commands ### 38 | -------------------------------------------------------------------------------- /migrations/versions/834b1a697901_user_tokens.py: -------------------------------------------------------------------------------- 1 | """user tokens 2 | 3 | Revision ID: 834b1a697901 4 | Revises: c81bac34faab 5 | Create Date: 2017-11-05 18:41:07.996137 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '834b1a697901' 14 | down_revision = 'c81bac34faab' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('user', sa.Column('token', sa.String(length=32), nullable=True)) 22 | op.add_column('user', sa.Column('token_expiration', sa.DateTime(), nullable=True)) 23 | op.create_index(op.f('ix_user_token'), 'user', ['token'], unique=True) 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | op.drop_index(op.f('ix_user_token'), table_name='user') 30 | op.drop_column('user', 'token_expiration') 31 | op.drop_column('user', 'token') 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /migrations/versions/ae346256b650_followers.py: -------------------------------------------------------------------------------- 1 | """followers 2 | 3 | Revision ID: ae346256b650 4 | Revises: 37f06a334dbf 5 | Create Date: 2017-09-17 15:41:30.211082 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'ae346256b650' 14 | down_revision = '37f06a334dbf' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('followers', 22 | sa.Column('follower_id', sa.Integer(), nullable=True), 23 | sa.Column('followed_id', sa.Integer(), nullable=True), 24 | sa.ForeignKeyConstraint(['followed_id'], ['user.id'], ), 25 | sa.ForeignKeyConstraint(['follower_id'], ['user.id'], ) 26 | ) 27 | # ### end Alembic commands ### 28 | 29 | 30 | def downgrade(): 31 | # ### commands auto generated by Alembic - please adjust! ### 32 | op.drop_table('followers') 33 | # ### end Alembic commands ### 34 | -------------------------------------------------------------------------------- /migrations/versions/aeea651280c2_two_factor_authentication.py: -------------------------------------------------------------------------------- 1 | """two-factor authentication 2 | 3 | Revision ID: aeea651280c2 4 | Revises: 834b1a697901 5 | Create Date: 2019-11-27 13:56:05.996309 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'aeea651280c2' 14 | down_revision = '834b1a697901' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('user', sa.Column('verification_phone', sa.String(length=16), nullable=True)) 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.drop_column('user', 'verification_phone') 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /migrations/versions/c81bac34faab_tasks.py: -------------------------------------------------------------------------------- 1 | """tasks 2 | 3 | Revision ID: c81bac34faab 4 | Revises: f7ac3d27bb1d 5 | Create Date: 2017-11-23 10:56:49.599779 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'c81bac34faab' 14 | down_revision = 'f7ac3d27bb1d' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('task', 22 | sa.Column('id', sa.String(length=36), nullable=False), 23 | sa.Column('name', sa.String(length=128), nullable=True), 24 | sa.Column('description', sa.String(length=128), nullable=True), 25 | sa.Column('user_id', sa.Integer(), nullable=True), 26 | sa.Column('complete', sa.Boolean(), nullable=True), 27 | sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), 28 | sa.PrimaryKeyConstraint('id') 29 | ) 30 | op.create_index(op.f('ix_task_name'), 'task', ['name'], unique=False) 31 | # ### end Alembic commands ### 32 | 33 | 34 | def downgrade(): 35 | # ### commands auto generated by Alembic - please adjust! ### 36 | op.drop_index(op.f('ix_task_name'), table_name='task') 37 | op.drop_table('task') 38 | # ### end Alembic commands ### 39 | -------------------------------------------------------------------------------- /migrations/versions/d049de007ccf_private_messages.py: -------------------------------------------------------------------------------- 1 | """private messages 2 | 3 | Revision ID: d049de007ccf 4 | Revises: 834b1a697901 5 | Create Date: 2017-11-12 23:30:28.571784 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'd049de007ccf' 14 | down_revision = '2b017edaa91f' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('message', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('sender_id', sa.Integer(), nullable=True), 24 | sa.Column('recipient_id', sa.Integer(), nullable=True), 25 | sa.Column('body', sa.String(length=140), nullable=True), 26 | sa.Column('timestamp', sa.DateTime(), nullable=True), 27 | sa.ForeignKeyConstraint(['recipient_id'], ['user.id'], ), 28 | sa.ForeignKeyConstraint(['sender_id'], ['user.id'], ), 29 | sa.PrimaryKeyConstraint('id') 30 | ) 31 | op.create_index(op.f('ix_message_timestamp'), 'message', ['timestamp'], unique=False) 32 | op.add_column('user', sa.Column('last_message_read_time', sa.DateTime(), nullable=True)) 33 | # ### end Alembic commands ### 34 | 35 | 36 | def downgrade(): 37 | # ### commands auto generated by Alembic - please adjust! ### 38 | op.drop_column('user', 'last_message_read_time') 39 | op.drop_index(op.f('ix_message_timestamp'), table_name='message') 40 | op.drop_table('message') 41 | # ### end Alembic commands ### 42 | -------------------------------------------------------------------------------- /migrations/versions/e517276bb1c2_users_table.py: -------------------------------------------------------------------------------- 1 | """users table 2 | 3 | Revision ID: e517276bb1c2 4 | Revises: 5 | Create Date: 2017-09-11 11:23:05.566844 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'e517276bb1c2' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('user', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('username', sa.String(length=64), nullable=True), 24 | sa.Column('email', sa.String(length=120), nullable=True), 25 | sa.Column('password_hash', sa.String(length=128), nullable=True), 26 | sa.PrimaryKeyConstraint('id') 27 | ) 28 | op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True) 29 | op.create_index(op.f('ix_user_username'), 'user', ['username'], unique=True) 30 | # ### end Alembic commands ### 31 | 32 | 33 | def downgrade(): 34 | # ### commands auto generated by Alembic - please adjust! ### 35 | op.drop_index(op.f('ix_user_username'), table_name='user') 36 | op.drop_index(op.f('ix_user_email'), table_name='user') 37 | op.drop_table('user') 38 | # ### end Alembic commands ### 39 | -------------------------------------------------------------------------------- /migrations/versions/f7ac3d27bb1d_notifications.py: -------------------------------------------------------------------------------- 1 | """notifications 2 | 3 | Revision ID: f7ac3d27bb1d 4 | Revises: d049de007ccf 5 | Create Date: 2017-11-22 19:48:39.945858 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'f7ac3d27bb1d' 14 | down_revision = 'd049de007ccf' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('notification', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('name', sa.String(length=128), nullable=True), 24 | sa.Column('user_id', sa.Integer(), nullable=True), 25 | sa.Column('timestamp', sa.Float(), nullable=True), 26 | sa.Column('payload_json', sa.Text(), nullable=True), 27 | sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), 28 | sa.PrimaryKeyConstraint('id') 29 | ) 30 | op.create_index(op.f('ix_notification_name'), 'notification', ['name'], unique=False) 31 | op.create_index(op.f('ix_notification_timestamp'), 'notification', ['timestamp'], unique=False) 32 | # ### end Alembic commands ### 33 | 34 | 35 | def downgrade(): 36 | # ### commands auto generated by Alembic - please adjust! ### 37 | op.drop_index(op.f('ix_notification_timestamp'), table_name='notification') 38 | op.drop_index(op.f('ix_notification_name'), table_name='notification') 39 | op.drop_table('notification') 40 | # ### end Alembic commands ### 41 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alembic==0.9.6 2 | Babel==2.5.1 3 | blinker==1.4 4 | certifi==2017.7.27.1 5 | chardet==3.0.4 6 | click==6.7 7 | dominate==2.3.1 8 | elasticsearch==7.1.0 9 | Flask==1.1.1 10 | Flask-Babel==0.11.2 11 | Flask-Bootstrap==3.3.7.1 12 | Flask-HTTPAuth==3.3.0 13 | Flask-Login==0.4.0 14 | Flask-Mail==0.9.1 15 | Flask-Migrate==2.5.2 16 | Flask-Moment==0.9.0 17 | Flask-SQLAlchemy==2.4.1 18 | Flask-WTF==0.14.2 19 | guess-language-spirit==0.5.3 20 | idna==2.6 21 | itsdangerous==0.24 22 | Jinja2==2.10.3 23 | Mako==1.0.7 24 | MarkupSafe==1.0 25 | phonenumbers==8.10.23 26 | PyJWT==1.5.3 27 | python-dateutil==2.6.1 28 | python-dotenv==0.10.3 29 | python-editor==1.0.3 30 | pytz==2017.2 31 | redis==3.2.1 32 | requests==2.22.0 33 | rq==1.0 34 | six==1.11.0 35 | SQLAlchemy==1.1.14 36 | twilio==6.33.1 37 | urllib3==1.22 38 | visitor==0.1.3 39 | Werkzeug==0.16.0 40 | WTForms==2.1 41 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from datetime import datetime, timedelta 3 | import unittest 4 | from app import create_app, db 5 | from app.models import User, Post 6 | from config import Config 7 | 8 | 9 | class TestConfig(Config): 10 | TESTING = True 11 | SQLALCHEMY_DATABASE_URI = 'sqlite://' 12 | ELASTICSEARCH_URL = None 13 | 14 | 15 | class UserModelCase(unittest.TestCase): 16 | def setUp(self): 17 | self.app = create_app(TestConfig) 18 | self.app_context = self.app.app_context() 19 | self.app_context.push() 20 | db.create_all() 21 | 22 | def tearDown(self): 23 | db.session.remove() 24 | db.drop_all() 25 | self.app_context.pop() 26 | 27 | def test_password_hashing(self): 28 | u = User(username='susan') 29 | u.set_password('cat') 30 | self.assertFalse(u.check_password('dog')) 31 | self.assertTrue(u.check_password('cat')) 32 | 33 | def test_avatar(self): 34 | u = User(username='john', email='john@example.com') 35 | self.assertEqual(u.avatar(128), ('https://www.gravatar.com/avatar/' 36 | 'd4c74594d841139328695756648b6bd6' 37 | '?d=identicon&s=128')) 38 | 39 | def test_follow(self): 40 | u1 = User(username='john', email='john@example.com') 41 | u2 = User(username='susan', email='susan@example.com') 42 | db.session.add(u1) 43 | db.session.add(u2) 44 | db.session.commit() 45 | self.assertEqual(u1.followed.all(), []) 46 | self.assertEqual(u1.followers.all(), []) 47 | 48 | u1.follow(u2) 49 | db.session.commit() 50 | self.assertTrue(u1.is_following(u2)) 51 | self.assertEqual(u1.followed.count(), 1) 52 | self.assertEqual(u1.followed.first().username, 'susan') 53 | self.assertEqual(u2.followers.count(), 1) 54 | self.assertEqual(u2.followers.first().username, 'john') 55 | 56 | u1.unfollow(u2) 57 | db.session.commit() 58 | self.assertFalse(u1.is_following(u2)) 59 | self.assertEqual(u1.followed.count(), 0) 60 | self.assertEqual(u2.followers.count(), 0) 61 | 62 | def test_follow_posts(self): 63 | # create four users 64 | u1 = User(username='john', email='john@example.com') 65 | u2 = User(username='susan', email='susan@example.com') 66 | u3 = User(username='mary', email='mary@example.com') 67 | u4 = User(username='david', email='david@example.com') 68 | db.session.add_all([u1, u2, u3, u4]) 69 | 70 | # create four posts 71 | now = datetime.utcnow() 72 | p1 = Post(body="post from john", author=u1, 73 | timestamp=now + timedelta(seconds=1)) 74 | p2 = Post(body="post from susan", author=u2, 75 | timestamp=now + timedelta(seconds=4)) 76 | p3 = Post(body="post from mary", author=u3, 77 | timestamp=now + timedelta(seconds=3)) 78 | p4 = Post(body="post from david", author=u4, 79 | timestamp=now + timedelta(seconds=2)) 80 | db.session.add_all([p1, p2, p3, p4]) 81 | db.session.commit() 82 | 83 | # setup the followers 84 | u1.follow(u2) # john follows susan 85 | u1.follow(u4) # john follows david 86 | u2.follow(u3) # susan follows mary 87 | u3.follow(u4) # mary follows david 88 | db.session.commit() 89 | 90 | # check the followed posts of each user 91 | f1 = u1.followed_posts().all() 92 | f2 = u2.followed_posts().all() 93 | f3 = u3.followed_posts().all() 94 | f4 = u4.followed_posts().all() 95 | self.assertEqual(f1, [p2, p4, p1]) 96 | self.assertEqual(f2, [p2, p3]) 97 | self.assertEqual(f3, [p3, p4]) 98 | self.assertEqual(f4, [p4]) 99 | 100 | 101 | if __name__ == '__main__': 102 | unittest.main(verbosity=2) 103 | --------------------------------------------------------------------------------