├── .flaskenv
├── .gitattributes
├── migrations
├── README
├── script.py.mako
├── versions
│ ├── 2b017edaa91f_add_language_to_posts.py
│ ├── 99a83088f91c_authy_support.py
│ ├── 37f06a334dbf_new_fields_in_user_model.py
│ ├── ae346256b650_followers.py
│ ├── 834b1a697901_user_tokens.py
│ ├── 780739b227a7_posts_table.py
│ ├── c81bac34faab_tasks.py
│ ├── e517276bb1c2_users_table.py
│ ├── f7ac3d27bb1d_notifications.py
│ └── d049de007ccf_private_messages.py
├── alembic.ini
└── env.py
├── .env-template
├── app
├── static
│ └── loading.gif
├── auth
│ ├── __init__.py
│ ├── email.py
│ ├── forms.py
│ ├── authy.py
│ └── routes.py
├── main
│ ├── __init__.py
│ ├── forms.py
│ └── routes.py
├── api
│ ├── __init__.py
│ ├── errors.py
│ ├── tokens.py
│ ├── auth.py
│ └── users.py
├── errors
│ ├── __init__.py
│ └── handlers.py
├── templates
│ ├── email
│ │ ├── export_posts.txt
│ │ ├── export_posts.html
│ │ ├── reset_password.txt
│ │ └── reset_password.html
│ ├── errors
│ │ ├── 404.html
│ │ └── 500.html
│ ├── auth
│ │ ├── register.html
│ │ ├── reset_password.html
│ │ ├── reset_password_request.html
│ │ ├── disable_2fa.html
│ │ ├── enable_2fa.html
│ │ ├── login.html
│ │ ├── check_2fa.html
│ │ └── enable_2fa_qr.html
│ ├── edit_profile.html
│ ├── send_message.html
│ ├── messages.html
│ ├── search.html
│ ├── index.html
│ ├── user_popup.html
│ ├── _post.html
│ ├── user.html
│ └── base.html
├── email.py
├── translate.py
├── search.py
├── cli.py
├── tasks.py
├── __init__.py
├── translations
│ └── es
│ │ └── LC_MESSAGES
│ │ └── messages.po
└── models.py
├── Procfile
├── babel.cfg
├── Vagrantfile
├── deployment
├── supervisor
│ ├── microblog.conf
│ └── microblog-tasks.conf
└── nginx
│ └── microblog
├── microblog.py
├── boot.sh
├── .gitignore
├── Dockerfile
├── requirements.txt
├── LICENSE
├── config.py
├── README.md
└── tests.py
/.flaskenv:
--------------------------------------------------------------------------------
1 | FLASK_APP=microblog.py
2 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto
2 | *.sh text eol=lf
3 |
--------------------------------------------------------------------------------
/migrations/README:
--------------------------------------------------------------------------------
1 | Generic single-database configuration.
--------------------------------------------------------------------------------
/.env-template:
--------------------------------------------------------------------------------
1 | AUTHY_APP_NAME=
2 | AUTHY_APP_ID=
3 | AUTHY_PRODUCTION_API_KEY=
--------------------------------------------------------------------------------
/app/static/loading.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/miguelgrinberg/microblog-authy/HEAD/app/static/loading.gif
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | web: flask db upgrade; flask translate compile; gunicorn microblog:app
2 | worker: rq worker microblog-tasks
3 |
--------------------------------------------------------------------------------
/babel.cfg:
--------------------------------------------------------------------------------
1 | [python: app/**.py]
2 | [jinja2: app/templates/**.html]
3 | extensions=jinja2.ext.autoescape,jinja2.ext.with_
4 |
--------------------------------------------------------------------------------
/app/auth/__init__.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint
2 |
3 | bp = Blueprint('auth', __name__)
4 |
5 | from app.auth import routes
6 |
--------------------------------------------------------------------------------
/app/main/__init__.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint
2 |
3 | bp = Blueprint('main', __name__)
4 |
5 | from app.main import routes
6 |
--------------------------------------------------------------------------------
/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/errors/__init__.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint
2 |
3 | bp = Blueprint('errors', __name__)
4 |
5 | from app.errors import handlers
6 |
--------------------------------------------------------------------------------
/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/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/errors/404.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block app_content %}
4 | {{ _('Not Found') }}
5 | {{ _('Back') }}
6 | {% endblock %}
7 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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/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/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 |
7 |
8 | {{ wtf.quick_form(form) }}
9 |
10 |
11 | {% endblock %}
12 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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 | To enable Two-Factor Authentication, you need to have the Authy app installed on your smartphone. Download it now!
7 |
8 |
9 | {{ wtf.quick_form(form) }}
10 |
11 |
12 | {% endblock %}
13 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
9 |
21 |
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 |
9 |
21 |
22 | {% endblock %}
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 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | alembic==0.9.6
2 | authy==2.2.4
3 | Babel==2.5.1
4 | blinker==1.4
5 | certifi==2017.7.27.1
6 | chardet==3.0.4
7 | click==6.7
8 | dominate==2.3.1
9 | elasticsearch==7.1.0
10 | Flask==1.1.1
11 | Flask-Babel==0.12.2
12 | Flask-Bootstrap==3.3.7.1
13 | Flask-HTTPAuth==3.3.0
14 | Flask-Login==0.4.1
15 | Flask-Mail==0.9.1
16 | Flask-Migrate==2.5.2
17 | Flask-Moment==0.9.0
18 | Flask-SQLAlchemy==2.4.1
19 | Flask-WTF==0.14.2
20 | guess-language-spirit==0.5.3
21 | idna==2.6
22 | itsdangerous==0.24
23 | Jinja2==2.10.3
24 | Mako==1.0.7
25 | MarkupSafe==1.0
26 | PyJWT==1.7.1
27 | python-dateutil==2.6.1
28 | python-dotenv==0.10.3
29 | python-editor==1.0.3
30 | pytz==2017.2
31 | qrcode==6.1
32 | redis==3.2.1
33 | requests==2.22.0
34 | rq==1.1.0
35 | simplejson==3.17.0
36 | six==1.11.0
37 | SQLAlchemy==1.1.14
38 | urllib3==1.22
39 | visitor==0.1.3
40 | Werkzeug==0.16.0
41 | WTForms==2.1
42 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/migrations/versions/99a83088f91c_authy_support.py:
--------------------------------------------------------------------------------
1 | """authy support
2 |
3 | Revision ID: 99a83088f91c
4 | Revises: 834b1a697901
5 | Create Date: 2019-12-03 11:57:21.447277
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 |
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = '99a83088f91c'
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('authy_id', sa.Integer(), nullable=True))
22 | op.create_index(op.f('ix_user_authy_id'), 'user', ['authy_id'], unique=True)
23 | # ### end Alembic commands ###
24 |
25 |
26 | def downgrade():
27 | # ### commands auto generated by Alembic - please adjust! ###
28 | op.drop_index(op.f('ix_user_authy_id'), table_name='user')
29 | op.drop_column('user', 'authy_id')
30 | # ### end Alembic commands ###
31 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/app/templates/auth/check_2fa.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block app_content %}
4 | {{ _('Two-Factor Authentication') }}
5 | {{ _('Please confirm this log-in attempt on the Authy app on your phone.') }}
6 |
7 | {% endblock %}
8 |
9 | {% block scripts %}
10 | {{ super() }}
11 |
28 | {% endblock %}
29 |
--------------------------------------------------------------------------------
/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 |
14 |
26 |
27 | {% endblock %}
28 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/app/templates/user_popup.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
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 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | AUTHY_APP_NAME = os.environ.get('AUTHY_APP_NAME')
26 | AUTHY_APP_ID = os.environ.get('AUTHY_APP_ID')
27 | AUTHY_PRODUCTION_API_KEY = os.environ.get('AUTHY_PRODUCTION_API_KEY')
28 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/app/templates/auth/enable_2fa_qr.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block app_content %}
4 | {{ _('Enable Two-Factor Authentication') }}
5 | {{ _('Please scan the QR code below with the Authy app to continue.') }}
6 | {{ _('iOS Instructions') }}
7 |
8 | {{ _('Open the Authy iOS app.') }}
9 | {{ _('Tap the Red + sign at the bottom of the screen for Add Account.') }}
10 | {{ _('Tap Scan QR Code') }}
11 |
12 | {{ _('Android Instructions') }}
13 |
14 | {{ _('Open the Authy Android app.') }}
15 | {{ _('Tap the … (menu) icon in the upper right corner, and then select Add Account.') }}
16 | {{ _('Tap Scan QR Code') }}
17 |
18 |
19 | {% endblock %}
20 |
21 | {% block scripts %}
22 | {{ super() }}
23 |
37 | {% endblock %}
38 |
--------------------------------------------------------------------------------
/app/templates/_post.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | {% set user_link %}
10 |
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 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Welcome to Microblog-Authy!
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 Authy API](https://www.twilio.com/docs/authy/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 the two-factor authentication feature.
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 | $ python -m venv venv
23 | $ venv\bin\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. Start the development web server:
40 |
41 | ```
42 | (venv) $ flask run
43 | ```
44 |
45 | 5. Access the application on your web browser at `http://localhost:5000`. Register a new account, log in, click on "Profile" on the right side of the navigation bar, and then on "Enable two-factor authentication".
46 |
47 | 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!
48 |
--------------------------------------------------------------------------------
/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/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 | from app.models import User
6 |
7 |
8 | class LoginForm(FlaskForm):
9 | username = StringField(_l('Username'), validators=[DataRequired()])
10 | password = PasswordField(_l('Password'), validators=[DataRequired()])
11 | remember_me = BooleanField(_l('Remember Me'))
12 | submit = SubmitField(_l('Sign In'))
13 |
14 |
15 | class RegistrationForm(FlaskForm):
16 | username = StringField(_l('Username'), validators=[DataRequired()])
17 | email = StringField(_l('Email'), validators=[DataRequired(), Email()])
18 | password = PasswordField(_l('Password'), validators=[DataRequired()])
19 | password2 = PasswordField(
20 | _l('Repeat Password'), validators=[DataRequired(),
21 | EqualTo('password')])
22 | submit = SubmitField(_l('Register'))
23 |
24 | def validate_username(self, username):
25 | user = User.query.filter_by(username=username.data).first()
26 | if user is not None:
27 | raise ValidationError(_('Please use a different username.'))
28 |
29 | def validate_email(self, email):
30 | user = User.query.filter_by(email=email.data).first()
31 | if user is not None:
32 | raise ValidationError(_('Please use a different email address.'))
33 |
34 |
35 | class ResetPasswordRequestForm(FlaskForm):
36 | email = StringField(_l('Email'), validators=[DataRequired(), Email()])
37 | submit = SubmitField(_l('Request Password Reset'))
38 |
39 |
40 | class ResetPasswordForm(FlaskForm):
41 | password = PasswordField(_l('Password'), validators=[DataRequired()])
42 | password2 = PasswordField(
43 | _l('Repeat Password'), validators=[DataRequired(),
44 | EqualTo('password')])
45 | submit = SubmitField(_l('Request Password Reset'))
46 |
47 |
48 | class Enable2faForm(FlaskForm):
49 | submit = SubmitField(_l('Enable 2FA'))
50 |
51 |
52 | class Disable2faForm(FlaskForm):
53 | submit = SubmitField(_l('Disable 2FA'))
54 |
--------------------------------------------------------------------------------
/app/templates/user.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block app_content %}
4 |
35 | {% for post in posts %}
36 | {% include '_post.html' %}
37 | {% endfor %}
38 |
39 |
51 |
52 | {% endblock %}
53 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/__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/auth/authy.py:
--------------------------------------------------------------------------------
1 | from io import BytesIO
2 | import time
3 | from authy.api import AuthyApiClient
4 | from flask import current_app, request
5 | from flask_login import current_user
6 | import jwt
7 | import qrcode
8 | import qrcode.image.svg
9 |
10 |
11 | def get_registration_jwt(user_id, expires_in=5 * 60):
12 | """Return a JWT for Authy registration.
13 |
14 | :param user_id: the ID of the user.
15 | :param expires_in: the validaty time for the token in seconds.
16 |
17 | :returns a JWT token that can be used to register the user with Authy.
18 | """
19 | now = time.time()
20 | payload = {
21 | 'iss': current_app.config['AUTHY_APP_NAME'],
22 | 'iat': now,
23 | 'exp': now + expires_in,
24 | 'context': {
25 | 'custom_user_id': str(user_id),
26 | 'authy_app_id': current_app.config['AUTHY_APP_ID'],
27 | },
28 | }
29 | return jwt.encode(payload,
30 | current_app.config['AUTHY_PRODUCTION_API_KEY']).decode()
31 |
32 |
33 | def get_qrcode(jwt):
34 | """Return an Authy registration QR code for the given JWT.
35 |
36 | :param jwt: Authy registration JWT.
37 |
38 | :returns a bytes object with the QR code image in SVG format.
39 | """
40 | qr = qrcode.make('authy://account?token=' + jwt,
41 | image_factory=qrcode.image.svg.SvgImage)
42 | stream = BytesIO()
43 | qr.save(stream)
44 | return stream.getvalue()
45 |
46 |
47 | def get_registration_status(user_id):
48 | """Check if the given user has scanned the QR code to register.
49 |
50 | :param user_id: the ID of the user.
51 |
52 | :returns a dict with 'status' and 'authy_id' keys. The status is
53 | 'completed' if the user already scanned the QR code, or 'pending'
54 | if they didn't yet. Any other status should be considered an
55 | error. If the status is 'completed' then the 'authy_id' key
56 | contains the ID assigned by Authy to this user.
57 | """
58 | authy_api = AuthyApiClient(current_app.config['AUTHY_PRODUCTION_API_KEY'])
59 | resp = authy_api.users.registration_status(user_id)
60 | if not resp.ok():
61 | return {'status': 'pending'}
62 | return resp.content['registration']
63 |
64 |
65 | def delete_user(authy_id):
66 | """Unregister a user from Authy push notifications.
67 |
68 | :param authy_id: the Authy ID for the user.
69 |
70 | :returns True if successful or False otherwise.
71 | """
72 | authy_api = AuthyApiClient(current_app.config['AUTHY_PRODUCTION_API_KEY'])
73 | resp = authy_api.users.delete(authy_id)
74 | return resp.ok()
75 |
76 |
77 | def send_push_authentication(user):
78 | """Send a push authentication notification to a user.
79 |
80 | :param authy_id: the Authy ID for the user
81 |
82 | :returns a unique ID for the push notification or None if there was an
83 | error.
84 | """
85 | authy_api = AuthyApiClient(current_app.config['AUTHY_PRODUCTION_API_KEY'])
86 | resp = authy_api.one_touch.send_request(
87 | user.authy_id,
88 | "Login requested for Microblog.",
89 | details={
90 | 'Username': user.username,
91 | 'IP Address': request.remote_addr,
92 | },
93 | seconds_to_expire=120)
94 | if not resp.ok():
95 | return None
96 | return resp.get_uuid()
97 |
98 |
99 | def check_push_authentication_status(uuid):
100 | """Check if a push notification has been handled.
101 |
102 | :param uuid: the ID of the push notification.
103 |
104 | :returns 'approved', 'pending' or 'error'
105 | """
106 | authy_api = AuthyApiClient(current_app.config['AUTHY_PRODUCTION_API_KEY'])
107 | resp = authy_api.one_touch.get_approval_status(uuid)
108 | if not resp.ok():
109 | return 'error'
110 | return resp.content['approval_request']['status']
111 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/app/auth/routes.py:
--------------------------------------------------------------------------------
1 | from flask import render_template, redirect, url_for, flash, request, \
2 | session, jsonify, abort
3 | from werkzeug.urls import url_parse
4 | from flask_login import login_user, logout_user, current_user, login_required
5 | from flask_babel import _
6 | from app import db
7 | from app.auth import bp
8 | from app.auth.forms import LoginForm, RegistrationForm, \
9 | ResetPasswordRequestForm, ResetPasswordForm, Enable2faForm, Disable2faForm
10 | from app.models import User
11 | from app.auth.email import send_password_reset_email
12 | from app.auth import authy
13 |
14 |
15 | @bp.route('/login', methods=['GET', 'POST'])
16 | def login():
17 | if current_user.is_authenticated:
18 | return redirect(url_for('main.index'))
19 | form = LoginForm()
20 | if form.validate_on_submit():
21 | user = User.query.filter_by(username=form.username.data).first()
22 | if user is None or not user.check_password(form.password.data):
23 | flash(_('Invalid username or password'))
24 | return redirect(url_for('auth.login'))
25 | next_page = request.args.get('next')
26 | if not next_page or url_parse(next_page).netloc != '':
27 | next_page = url_for('main.index')
28 | if user.authy_id is not None:
29 | session['username'] = user.username
30 | return redirect(url_for(
31 | 'auth.check_2fa', next=next_page,
32 | remember='1' if form.remember_me.data else '0'))
33 | login_user(user, remember=form.remember_me.data)
34 | return redirect(next_page)
35 | return render_template('auth/login.html', title=_('Sign In'), form=form)
36 |
37 |
38 | @bp.route('/logout')
39 | def logout():
40 | logout_user()
41 | return redirect(url_for('main.index'))
42 |
43 |
44 | @bp.route('/register', methods=['GET', 'POST'])
45 | def register():
46 | if current_user.is_authenticated:
47 | return redirect(url_for('main.index'))
48 | form = RegistrationForm()
49 | if form.validate_on_submit():
50 | user = User(username=form.username.data, email=form.email.data)
51 | user.set_password(form.password.data)
52 | db.session.add(user)
53 | db.session.commit()
54 | flash(_('Congratulations, you are now a registered user!'))
55 | return redirect(url_for('auth.login'))
56 | return render_template('auth/register.html', title=_('Register'),
57 | form=form)
58 |
59 |
60 | @bp.route('/reset_password_request', methods=['GET', 'POST'])
61 | def reset_password_request():
62 | if current_user.is_authenticated:
63 | return redirect(url_for('main.index'))
64 | form = ResetPasswordRequestForm()
65 | if form.validate_on_submit():
66 | user = User.query.filter_by(email=form.email.data).first()
67 | if user:
68 | send_password_reset_email(user)
69 | flash(
70 | _('Check your email for the instructions to reset your password'))
71 | return redirect(url_for('auth.login'))
72 | return render_template('auth/reset_password_request.html',
73 | title=_('Reset Password'), form=form)
74 |
75 |
76 | @bp.route('/reset_password/', methods=['GET', 'POST'])
77 | def reset_password(token):
78 | if current_user.is_authenticated:
79 | return redirect(url_for('main.index'))
80 | user = User.verify_reset_password_token(token)
81 | if not user:
82 | return redirect(url_for('main.index'))
83 | form = ResetPasswordForm()
84 | if form.validate_on_submit():
85 | user.set_password(form.password.data)
86 | db.session.commit()
87 | flash(_('Your password has been reset.'))
88 | return redirect(url_for('auth.login'))
89 | return render_template('auth/reset_password.html', form=form)
90 |
91 |
92 | @bp.route('/2fa/enable', methods=['GET', 'POST'])
93 | @login_required
94 | def enable_2fa():
95 | form = Enable2faForm()
96 | if form.validate_on_submit():
97 | jwt = authy.get_registration_jwt(current_user.id)
98 | session['registration_jwt'] = jwt
99 | return render_template('auth/enable_2fa_qr.html')
100 | return render_template('auth/enable_2fa.html', form=form)
101 |
102 |
103 | @bp.route('/2fa/enable/qrcode')
104 | @login_required
105 | def enable_2fa_qrcode():
106 | jwt = session.get('registration_jwt')
107 | if not jwt:
108 | abort(400)
109 |
110 | del session['registration_jwt']
111 |
112 | return authy.get_qrcode(jwt), 200, {
113 | 'Content-Type': 'image/svg+xml',
114 | 'Cache-Control': 'no-cache, no-store, must-revalidate',
115 | 'Pragma': 'no-cache',
116 | 'Expires': '0'}
117 |
118 |
119 | @bp.route('/2fa/enable/poll')
120 | @login_required
121 | def enable_2fa_poll():
122 | registration = authy.get_registration_status(current_user.id)
123 | if registration['status'] == 'completed':
124 | current_user.authy_id = registration['authy_id']
125 | db.session.commit()
126 | flash(_('You have successfully enabled two-factor authentication on '
127 | 'your account!'))
128 | elif registration['status'] != 'pending':
129 | flash(_('An error has occurred. Please try again.'))
130 | return jsonify(registration['status'])
131 |
132 |
133 | @bp.route('/2fa/disable', methods=['GET', 'POST'])
134 | @login_required
135 | def disable_2fa():
136 | form = Disable2faForm()
137 | if form.validate_on_submit():
138 | if not authy.delete_user(current_user.authy_id):
139 | flash(_('An error has occurred. Please try again.'))
140 | else:
141 | current_user.authy_id = None
142 | db.session.commit()
143 | flash(_('Two-factor authentication is now disabled.'))
144 | return redirect(url_for('main.user', username=current_user.username))
145 | return render_template('auth/disable_2fa.html', form=form)
146 |
147 |
148 | @bp.route('/2fa/check')
149 | def check_2fa():
150 | username = session['username']
151 | user = User.query.filter_by(username=username).first()
152 | session['authy_push_uuid'] = authy.send_push_authentication(user)
153 | return render_template('auth/check_2fa.html',
154 | next=request.args.get('next'))
155 |
156 |
157 | @bp.route('/2fa/check/poll')
158 | def check_2fa_poll():
159 | push_status = authy.check_push_authentication_status(
160 | session['authy_push_uuid'])
161 | if push_status == 'approved':
162 | username = session['username']
163 | del session['username']
164 | del session['authy_push_uuid']
165 | user = User.query.filter_by(username=username).first()
166 | remember = request.args.get('remember', '0') == '1'
167 | login_user(user, remember=remember)
168 | elif push_status != 'pending':
169 | flash(_('An error has occurred. Please try again.'))
170 | return jsonify(push_status)
171 |
--------------------------------------------------------------------------------
/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 |
9 |
10 |
19 |
20 |
24 | {% if g.search_form %}
25 |
30 | {% endif %}
31 |
49 |
50 |
51 |
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 |
61 | {{ task.description }}
62 | {{ task.get_progress() }} %
63 |
64 | {% endfor %}
65 | {% endif %}
66 | {% endwith %}
67 | {% endif %}
68 | {% with messages = get_flashed_messages() %}
69 | {% if messages %}
70 | {% for message in messages %}
71 |
{{ message }}
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/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/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 |
--------------------------------------------------------------------------------
/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 | authy_id = db.Column(db.Integer, index=True, unique=True)
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 | @staticmethod
158 | def verify_reset_password_token(token):
159 | try:
160 | id = jwt.decode(token, current_app.config['SECRET_KEY'],
161 | algorithms=['HS256'])['reset_password']
162 | except:
163 | return
164 | return User.query.get(id)
165 |
166 | def new_messages(self):
167 | last_read_time = self.last_message_read_time or datetime(1900, 1, 1)
168 | return Message.query.filter_by(recipient=self).filter(
169 | Message.timestamp > last_read_time).count()
170 |
171 | def add_notification(self, name, data):
172 | self.notifications.filter_by(name=name).delete()
173 | n = Notification(name=name, payload_json=json.dumps(data), user=self)
174 | db.session.add(n)
175 | return n
176 |
177 | def launch_task(self, name, description, *args, **kwargs):
178 | rq_job = current_app.task_queue.enqueue('app.tasks.' + name, self.id,
179 | *args, **kwargs)
180 | task = Task(id=rq_job.get_id(), name=name, description=description,
181 | user=self)
182 | db.session.add(task)
183 | return task
184 |
185 | def get_tasks_in_progress(self):
186 | return Task.query.filter_by(user=self, complete=False).all()
187 |
188 | def get_task_in_progress(self, name):
189 | return Task.query.filter_by(name=name, user=self,
190 | complete=False).first()
191 |
192 | def to_dict(self, include_email=False):
193 | data = {
194 | 'id': self.id,
195 | 'username': self.username,
196 | 'last_seen': self.last_seen.isoformat() + 'Z',
197 | 'about_me': self.about_me,
198 | 'post_count': self.posts.count(),
199 | 'follower_count': self.followers.count(),
200 | 'followed_count': self.followed.count(),
201 | '_links': {
202 | 'self': url_for('api.get_user', id=self.id),
203 | 'followers': url_for('api.get_followers', id=self.id),
204 | 'followed': url_for('api.get_followed', id=self.id),
205 | 'avatar': self.avatar(128)
206 | }
207 | }
208 | if include_email:
209 | data['email'] = self.email
210 | return data
211 |
212 | def from_dict(self, data, new_user=False):
213 | for field in ['username', 'email', 'about_me']:
214 | if field in data:
215 | setattr(self, field, data[field])
216 | if new_user and 'password' in data:
217 | self.set_password(data['password'])
218 |
219 | def get_token(self, expires_in=3600):
220 | now = datetime.utcnow()
221 | if self.token and self.token_expiration > now + timedelta(seconds=60):
222 | return self.token
223 | self.token = base64.b64encode(os.urandom(24)).decode('utf-8')
224 | self.token_expiration = now + timedelta(seconds=expires_in)
225 | db.session.add(self)
226 | return self.token
227 |
228 | def revoke_token(self):
229 | self.token_expiration = datetime.utcnow() - timedelta(seconds=1)
230 |
231 | @staticmethod
232 | def check_token(token):
233 | user = User.query.filter_by(token=token).first()
234 | if user is None or user.token_expiration < datetime.utcnow():
235 | return None
236 | return user
237 |
238 | def two_factor_enabled(self):
239 | return self.authy_id is not None
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 |
--------------------------------------------------------------------------------