diff --git a/app/phone/__init__.py b/app/phone/__init__.py new file mode 100644 index 00000000..4673cd59 --- /dev/null +++ b/app/phone/__init__.py @@ -0,0 +1 @@ +from .views import index, phone_reservation, twilio_callback diff --git a/app/phone/base.py b/app/phone/base.py new file mode 100644 index 00000000..fafce919 --- /dev/null +++ b/app/phone/base.py @@ -0,0 +1,8 @@ +from flask import Blueprint + +phone_bp = Blueprint( + name="phone", + import_name=__name__, + url_prefix="/phone", + template_folder="templates", +) diff --git a/app/phone/views/__init__.py b/app/phone/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/phone/views/index.py b/app/phone/views/index.py new file mode 100644 index 00000000..2387aa11 --- /dev/null +++ b/app/phone/views/index.py @@ -0,0 +1,128 @@ +from typing import Dict + +import arrow +from flask import render_template, request, flash, redirect, url_for +from flask_login import login_required, current_user +from sqlalchemy import func + +from app.db import Session +from app.models import PhoneCountry, PhoneNumber, PhoneReservation +from app.phone.base import phone_bp + + +@phone_bp.route("/", methods=["GET", "POST"]) +@login_required +def index(): + if not current_user.can_use_phone: + flash("You can't use this page", "error") + return redirect(url_for("dashboard.index")) + + countries = available_countries() + + now = arrow.now() + reservations = PhoneReservation.filter( + PhoneReservation.user_id == current_user.id, + PhoneReservation.start < now, + PhoneReservation.end > now, + ).all() + + past_reservations = PhoneReservation.filter( + PhoneReservation.user_id == current_user.id, + PhoneReservation.end <= now, + ).all() + + if request.method == "POST": + nb_minute = int(request.form.get("minute")) + + if current_user.phone_quota < nb_minute: + flash( + f"You don't have enough phone quota. Current quota is {current_user.phone_quota}", + "error", + ) + return redirect(request.url) + + country_id = request.form.get("country") + country = PhoneCountry.get(country_id) + + # get the first phone number available + now = arrow.now() + busy_phone_number_subquery = ( + Session.query(PhoneReservation.number_id) + .filter(PhoneReservation.start < now, PhoneReservation.end > now) + .subquery() + ) + + phone_number = ( + Session.query(PhoneNumber) + .filter( + PhoneNumber.country_id == country.id, + PhoneNumber.id.notin_(busy_phone_number_subquery), + ) + .first() + ) + + if phone_number: + phone_reservation = PhoneReservation.create( + number_id=phone_number.id, + start=arrow.now(), + end=arrow.now().shift(minutes=nb_minute), + user_id=current_user.id, + ) + + current_user.phone_quota -= nb_minute + Session.commit() + + return redirect( + url_for("phone.reservation_route", reservation_id=phone_reservation.id) + ) + else: + flash( + f"No phone number available for {country.name} during {nb_minute} minutes" + ) + + return render_template( + "phone/index.html", + countries=countries, + reservations=reservations, + past_reservations=past_reservations, + ) + + +def available_countries() -> [PhoneCountry]: + now = arrow.now() + + phone_count_by_countries: Dict[PhoneCountry, int] = dict() + for country, count in ( + Session.query(PhoneCountry, func.count(PhoneNumber.id)) + .join(PhoneNumber, PhoneNumber.country_id == PhoneCountry.id) + .filter(PhoneNumber.active.is_(True)) + .group_by(PhoneCountry) + .all() + ): + phone_count_by_countries[country] = count + + busy_phone_count_by_countries: Dict[PhoneCountry, int] = dict() + for country, count in ( + Session.query(PhoneCountry, func.count(PhoneNumber.id)) + .join(PhoneNumber, PhoneNumber.country_id == PhoneCountry.id) + .join(PhoneReservation, PhoneReservation.number_id == PhoneNumber.id) + .filter(PhoneReservation.start < now, PhoneReservation.end > now) + .group_by(PhoneCountry) + .all() + ): + busy_phone_count_by_countries[country] = count + + ret = [] + for country in phone_count_by_countries: + if ( + country not in busy_phone_count_by_countries + or phone_count_by_countries[country] + > busy_phone_count_by_countries[country] + ): + ret.append(country) + + return ret + + +def available_numbers() -> [PhoneNumber]: + Session.query(PhoneReservation).filter(PhoneReservation.start) diff --git a/app/phone/views/phone_reservation.py b/app/phone/views/phone_reservation.py new file mode 100644 index 00000000..21a9a7db --- /dev/null +++ b/app/phone/views/phone_reservation.py @@ -0,0 +1,48 @@ +import arrow +from flask import render_template, flash, redirect, url_for, request +from flask_login import login_required, current_user + +from app.db import Session +from app.models import PhoneReservation, PhoneMessage, User +from app.phone.base import phone_bp + +current_user: User + + +@phone_bp.route("/reservation/", methods=["GET", "POST"]) +@login_required +def reservation_route(reservation_id: int): + reservation: PhoneReservation = PhoneReservation.get(reservation_id) + if not reservation or reservation.user_id != current_user.id: + flash("Unknown error, redirect back to phone page", "warning") + return redirect(url_for("phone.index")) + + phone_number = reservation.number + messages = PhoneMessage.filter( + PhoneMessage.number_id == phone_number.id, + PhoneMessage.created_at > reservation.start, + PhoneMessage.created_at < reservation.end, + ).all() + + if request.method == "POST": + if request.form.get("form-name") == "release": + time_left = reservation.end - arrow.now() + if time_left.seconds > 0: + current_user.phone_quota += time_left.seconds // 60 + flash( + f"Your phone quota is increased by {time_left.seconds // 60} minutes", + "success", + ) + reservation.end = arrow.now() + Session.commit() + + flash(f"{phone_number.number} is released", "success") + return redirect(url_for("phone.index")) + + return render_template( + "phone/phone_reservation.html", + phone_number=phone_number, + reservation=reservation, + messages=messages, + now=arrow.now(), + ) diff --git a/app/phone/views/twilio_callback.py b/app/phone/views/twilio_callback.py new file mode 100644 index 00000000..259cc792 --- /dev/null +++ b/app/phone/views/twilio_callback.py @@ -0,0 +1,59 @@ +from functools import wraps + +from flask import request, abort +from twilio.request_validator import RequestValidator +from twilio.twiml.messaging_response import MessagingResponse + +from app.config import TWILIO_AUTH_TOKEN +from app.log import LOG +from app.models import PhoneNumber, PhoneMessage +from app.phone.base import phone_bp + + +def validate_twilio_request(f): + """Validates that incoming requests genuinely originated from Twilio""" + + @wraps(f) + def decorated_function(*args, **kwargs): + # Create an instance of the RequestValidator class + validator = RequestValidator(TWILIO_AUTH_TOKEN) + + # Validate the request using its URL, POST data, + # and X-TWILIO-SIGNATURE header + request_valid = validator.validate( + request.url, request.form, request.headers.get("X-TWILIO-SIGNATURE", "") + ) + + # Continue processing the request if it's valid, return a 403 error if + # it's not + if request_valid: + return f(*args, **kwargs) + else: + return abort(403) + + return decorated_function + + +@phone_bp.route("/twilio/sms", methods=["GET", "POST"]) +@validate_twilio_request +def twilio_sms(): + LOG.d("%s %s %s", request.args, request.form, request.data) + resp = MessagingResponse() + + to_number = request.form.get("To") + from_number = request.form.get("From") + body = request.form.get("Body") + + LOG.d("%s->%s:%s", from_number, to_number, body) + + phone_number = PhoneNumber.get_by(number=to_number) + if phone_number: + PhoneMessage.create( + number_id=phone_number.id, + from_number=from_number, + body=body, + commit=True, + ) + else: + LOG.e("Unknown phone number %s %s", to_number, request.form) + return str(resp) diff --git a/poetry.lock b/poetry.lock index b3be240f..28eeff68 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1239,6 +1239,20 @@ category = "main" optional = false python-versions = ">=3.5" +[[package]] +name = "pyjwt" +version = "2.3.0" +description = "JSON Web Token implementation in Python" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.extras] +crypto = ["cryptography (>=3.3.1)"] +dev = ["sphinx", "sphinx-rtd-theme", "zope.interface", "cryptography (>=3.3.1)", "pytest (>=6.0.0,<7.0.0)", "coverage[toml] (==5.0.4)", "mypy", "pre-commit"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["pytest (>=6.0.0,<7.0.0)", "coverage[toml] (==5.0.4)"] + [[package]] name = "pyopenssl" version = "19.1.0" @@ -1645,6 +1659,19 @@ ipython-genutils = "*" [package.extras] test = ["pytest"] +[[package]] +name = "twilio" +version = "7.3.2" +description = "Twilio API client and TwiML generator" +category = "main" +optional = false +python-versions = ">=3.6.0" + +[package.dependencies] +PyJWT = ">=2.0.0,<3.0.0" +pytz = "*" +requests = ">=2.0.0" + [[package]] name = "typed-ast" version = "1.4.1" @@ -1853,7 +1880,7 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "d5f89e2b5bb3c7b324e1e87441c64c048ef23c33782637a55e9d2c989c9d73a4" +content-hash = "05c6029909751452b1c44a746f95c6e960a4a14d897742aac2facc8d6a4a5cb6" [metadata.files] aiohttp = [ @@ -2578,6 +2605,10 @@ pygments = [ {file = "Pygments-2.7.1-py3-none-any.whl", hash = "sha256:307543fe65c0947b126e83dd5a61bd8acbd84abec11f43caebaf5534cbc17998"}, {file = "Pygments-2.7.1.tar.gz", hash = "sha256:926c3f319eda178d1bd90851e4317e6d8cdb5e292a3386aac9bd75eca29cf9c7"}, ] +pyjwt = [ + {file = "PyJWT-2.3.0-py3-none-any.whl", hash = "sha256:e0c4bb8d9f0af0c7f5b1ec4c5036309617d03d56932877f2f7a0beeb5318322f"}, + {file = "PyJWT-2.3.0.tar.gz", hash = "sha256:b888b4d56f06f6dcd777210c334e69c737be74755d3e5e9ee3fe67dc18a0ee41"}, +] pyopenssl = [ {file = "pyOpenSSL-19.1.0-py2.py3-none-any.whl", hash = "sha256:621880965a720b8ece2f1b2f54ea2071966ab00e2970ad2ce11d596102063504"}, {file = "pyOpenSSL-19.1.0.tar.gz", hash = "sha256:9a24494b2602aaf402be5c9e30a0b82d4a5c67528fe8fb475e3f3bc00dd69507"}, @@ -2870,6 +2901,10 @@ traitlets = [ {file = "traitlets-5.0.4-py3-none-any.whl", hash = "sha256:9664ec0c526e48e7b47b7d14cd6b252efa03e0129011de0a9c1d70315d4309c3"}, {file = "traitlets-5.0.4.tar.gz", hash = "sha256:86c9351f94f95de9db8a04ad8e892da299a088a64fd283f9f6f18770ae5eae1b"}, ] +twilio = [ + {file = "twilio-7.3.2-py2.py3-none-any.whl", hash = "sha256:6cc6ed114b07a7ce853503a5a27281f56237b411ea415012955cff3a57045f1b"}, + {file = "twilio-7.3.2.tar.gz", hash = "sha256:3170da33c7f4293bbebcd032b183866e044fcf8418e5c5e15bdd5ec7a0a958b6"}, +] typed-ast = [ {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"}, {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"}, diff --git a/pyproject.toml b/pyproject.toml index 87efd22e..06845766 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,6 +81,7 @@ flanker = "^0.9.11" pyre2 = "^0.3.6" tldextract = "^3.1.2" flask-debugtoolbar-sqlalchemy = "^0.2.0" +twilio = "^7.3.2" [tool.poetry.dev-dependencies] pytest = "^6.1.0" diff --git a/server.py b/server.py index 9e6e110a..925b43b9 100644 --- a/server.py +++ b/server.py @@ -98,6 +98,7 @@ from app.models import ( ) from app.monitor.base import monitor_bp from app.oauth.base import oauth_bp +from app.phone.base import phone_bp from app.utils import random_string if SENTRY_DSN: @@ -214,6 +215,7 @@ def register_blueprints(app: Flask): app.register_blueprint(monitor_bp) app.register_blueprint(dashboard_bp) app.register_blueprint(developer_bp) + app.register_blueprint(phone_bp) app.register_blueprint(oauth_bp, url_prefix="/oauth") app.register_blueprint(oauth_bp, url_prefix="/oauth2") diff --git a/templates/menu.html b/templates/menu.html index 70d0da89..a8aeee2a 100644 --- a/templates/menu.html +++ b/templates/menu.html @@ -82,5 +82,16 @@ + {% if current_user.can_use_phone %} + + {% endif %} + \ No newline at end of file diff --git a/templates/phone/index.html b/templates/phone/index.html new file mode 100644 index 00000000..dbf7731c --- /dev/null +++ b/templates/phone/index.html @@ -0,0 +1,77 @@ +{% extends 'default.html' %} + +{% set active_page = "phone" %} + +{% block title %} + Phone numbers +{% endblock %} + +{% block default_content %} +
+
+

Your current numbers

+ + {% for reservation in reservations %} +
+ + {{ reservation.number.number }} ➡ + +
+ {% endfor %} + +
+
+ +
+
+

Phone Reservation

+
+ Currently your phone quota is {{ current_user.phone_quota }} minutes. +
+ +
+
+ + + We'll never share your email with anyone else. +
+
+
+ {% for country in countries %} +
+ + +
+ {% endfor %} +
+ + +
+ +
+
+ +
+
+

Past Reservations

+ + {% for reservation in past_reservations %} +
+ + {{ reservation.number.number }} ➡ + + ended {{ reservation.end.humanize() }} +
+ {% endfor %} + +
+
+ + +{% endblock %} + + + diff --git a/templates/phone/phone_reservation.html b/templates/phone/phone_reservation.html new file mode 100644 index 00000000..ebf4c8e3 --- /dev/null +++ b/templates/phone/phone_reservation.html @@ -0,0 +1,81 @@ +{% extends 'default.html' %} + +{% set active_page = "phone" %} + +{% block title %} + Phone reservation {{ phone_number.number }} +{% endblock %} + +{% block default_content %} +
+
+ Your number is + +
+

{{ phone_number.number }}

+
+ +
+
+ + {% if now > reservation.end %} + was ended {{ reservation.end.humanize() }} + {% else %} + will be released {{ reservation.end.humanize() }} + {% endif %} + +
+
+ +
+
+ +

Received Messages

+
Please refresh the page to have the latest messages
+ + + + + + + + + + + {% for message in messages %} + + + + + + {% endfor %} + +
FromTimeMessage
{{ message.from_number }}{{ message.created_at.humanize() }}{{ message.body }}
+ +
+
+ + {% if now < reservation.end %} +
+
+ When the number is released, you can't reclaim it. + +
+ + +
+
+
+ {% endif %} + + + + +{% endblock %} + + +