-
-
\ No newline at end of file
diff --git a/src/server/endpoints/__init__.py b/src/server/endpoints/__init__.py
index 6dad05bf3..9e2592d99 100644
--- a/src/server/endpoints/__init__.py
+++ b/src/server/endpoints/__init__.py
@@ -30,6 +30,8 @@
wiki,
signal_dashboard_status,
signal_dashboard_coverage,
+ registration,
+ api_key_removal_request
)
endpoints = [
@@ -64,6 +66,8 @@
wiki,
signal_dashboard_status,
signal_dashboard_coverage,
+ registration,
+ api_key_removal_request
]
__all__ = ["endpoints"]
diff --git a/src/server/endpoints/admin.py b/src/server/endpoints/admin.py
index 2931e6a29..a4f69428c 100644
--- a/src/server/endpoints/admin.py
+++ b/src/server/endpoints/admin.py
@@ -1,28 +1,25 @@
-import json
-from pathlib import Path
-import socket
-from typing import Dict, List, Set
+from datetime import timedelta
-from flask import Blueprint, make_response, render_template_string, request
-from werkzeug.exceptions import NotFound, Unauthorized
-from werkzeug.utils import redirect
+from flask import session, request, redirect, url_for, render_template
+import flask_login
+from flask_admin import Admin, AdminIndexView, expose, BaseView
+from flask_admin.contrib.sqla import ModelView
+from werkzeug.exceptions import Unauthorized
-from .._common import db, log_info_with_request
-from .._config import ADMIN_PASSWORD, API_KEY_REGISTRATION_FORM_LINK, API_KEY_REMOVAL_REQUEST_LINK, REGISTER_WEBHOOK_TOKEN
+from .._common import app, db
+from .._config import ADMIN_PASSWORD
from .._db import WriteSession
from .._security import resolve_auth_token
from ..admin.models import User, UserRole
-self_dir = Path(__file__).parent
-# first argument is the endpoint name
-bp = Blueprint("admin", __name__)
+# set optional bootswatch theme
+app.config["FLASK_ADMIN_SWATCH"] = "cerulean"
+# set app secret key to enable session
+app.secret_key = "SOME_RANDOM_SECRET_KEY"
-templates_dir = Path(__file__).parent.parent / "admin" / "templates"
-
-
-def enable_admin() -> bool:
- # only enable admin endpoint if we have a password for it, so it is not exposed to the world
- return bool(ADMIN_PASSWORD)
+login_manager = flask_login.LoginManager()
+login_manager.login_view = "login"
+login_manager.init_app(app)
def _require_admin():
@@ -32,122 +29,125 @@ def _require_admin():
return token
-def _render(mode: str, token: str, flags: Dict, session, **kwargs):
- template = (templates_dir / "index.html").read_text("utf8")
- return render_template_string(
- template, mode=mode, token=token, flags=flags, roles=UserRole.list_all_roles(session), **kwargs
- )
-
-
-# ~~~~ PUBLIC ROUTES ~~~~
-
-
-@bp.route("/registration_form", methods=["GET"])
-def registration_form_redirect():
- # TODO: replace this with our own hosted registration form instead of external
- return redirect(API_KEY_REGISTRATION_FORM_LINK, code=302)
-
-
-@bp.route("/removal_request", methods=["GET"])
-def removal_request_redirect():
- # TODO: replace this with our own hosted form instead of external
- return redirect(API_KEY_REMOVAL_REQUEST_LINK, code=302)
-
-
-# ~~~~ PRIVLEGED ROUTES ~~~~
-
-
-@bp.route("/", methods=["GET", "POST"])
-def _index():
- token = _require_admin()
- flags = dict()
- with WriteSession() as session:
- if request.method == "POST":
- # register a new user
- if not User.find_user(
- user_email=request.values["email"], api_key=request.values["api_key"],
- session=session):
- User.create_user(
- api_key=request.values["api_key"],
- email=request.values["email"],
- user_roles=set(request.values.getlist("roles")),
- session=session
- )
- flags["banner"] = "Successfully Added"
- else:
- flags["banner"] = "User with such email and/or api key already exists."
- users = [user.as_dict for user in session.query(User).all()]
- return _render("overview", token, flags, session=session, users=users, user=dict())
-
-
-@bp.route("/", methods=["GET", "PUT", "POST", "DELETE"])
-def _detail(user_id: int):
- token = _require_admin()
- with WriteSession() as session:
- user = User.find_user(user_id=user_id, session=session)
- if not user:
- raise NotFound()
- if request.method == "DELETE" or "delete" in request.values:
- User.delete_user(user.id, session=session)
- return redirect(f"./?auth={token}")
- flags = dict()
- if request.method in ["PUT", "POST"]:
- user_check = User.find_user(api_key=request.values["api_key"], user_email=request.values["email"], session=session)
- if user_check and user_check.id != user.id:
- flags["banner"] = "Could not update user; same api_key and/or email already exists."
- else:
- user = User.update_user(
- user=user,
- api_key=request.values["api_key"],
- email=request.values["email"],
- roles=set(request.values.getlist("roles")),
- session=session
- )
- flags["banner"] = "Successfully Saved"
- return _render("detail", token, flags, session=session, user=user.as_dict)
-
-
-@bp.route("/register", methods=["POST"])
-def _register():
- body = request.get_json()
- token = body.get("token")
- if token is None or token != REGISTER_WEBHOOK_TOKEN:
- raise Unauthorized()
+class AdminUser(flask_login.UserMixin):
+ pass
+
+
+@login_manager.user_loader
+def user_loader(admin_token):
+ if admin_token != ADMIN_PASSWORD:
+ return
+
+ user = AdminUser()
+ user.id = admin_token
+ return user
+
+
+@login_manager.unauthorized_handler
+def unauthorized_handler():
+ return "Unauthorized", 401
+
+
+@app.route("/login", methods=["GET", "POST"])
+def login():
+ if request.method == "POST":
+ admin_token = request.form["admin_token"]
+ if admin_token == ADMIN_PASSWORD:
+ user = AdminUser()
+ user.id = admin_token
+ flask_login.login_user(user)
+ return redirect(url_for("admin.index"))
+ return render_template("login.html")
+
+
+class AuthModelView(ModelView):
+ @flask_login.login_required
+ def is_accessible(self):
+ return True
+
+
+@app.before_first_request # runs before FIRST request (only once)
+def make_session_permanent():
+ session.permanent = True
+ app.permanent_session_lifetime = timedelta(minutes=30)
+
+
+class AuthAdminIndexView(AdminIndexView):
+ """
+ Admin view main page
+ """
+
+ @expose("/")
+ @flask_login.login_required
+ def index(self):
+ users_info_query = """
+ SELECT
+ au.email,
+ au.api_key,
+ rr.organization,
+ rr.purpose,
+ au.created,
+ au.last_time_used
+ FROM api_user au
+ JOIN registration_responses rr
+ ON au.email = rr.email
+ """
+ users_info = db.execute(users_info_query)
+ user_data = users_info.fetchall()
+ return self.render("admin/index.html", user_data=user_data)
+
+
+class UserView(AuthModelView):
+ """
+ User model view:
+ - form_columns: list of columns that will be available in CRUD forms
+ - column_list: list of columns that are displayed on user model view page
+ - column_filters: list of available filters
+ - page_size: number of items on page
+ """
+
+ form_columns = ["api_key", "email", "roles"]
+ column_list = ("api_key", "email", "created", "last_time_used", "roles")
+ column_filters = ("api_key", "email")
+
+ page_size = 10
+
+
+class UserRoleView(AuthModelView):
+ """
+ User role view:
+ - colums_filters: list of available filters
+ - page_size: number of items on page
+ """
+
+ column_filters = ["name"]
+
+ page_size = 10
+
- user_api_key = body["user_api_key"]
- user_email = body["user_email"]
- with WriteSession() as session:
- if User.find_user(user_email=user_email, api_key=user_api_key, session=session):
- return make_response(
- "User with email and/or API Key already exists, use different parameters or contact us for help",
- 409,
- )
- User.create_user(api_key=user_api_key, email=user_email, session=session)
- return make_response(f"Successfully registered API key '{user_api_key}'", 200)
-
-
-@bp.route("/diagnostics", methods=["GET", "PUT", "POST", "DELETE"])
-def diags():
- # allows us to get useful diagnostic information written into server logs,
- # such as a full current "X-Forwarded-For" path as inserted into headers by intermediate proxies...
- # (but only when initiated purposefully by us to keep junk out of the logs)
- _require_admin()
-
- try:
- serving_host = socket.gethostbyname_ex(socket.gethostname())
- except e:
- serving_host = e
-
- try:
- db_host = db.execute('SELECT @@hostname AS hn').fetchone()['hn']
- except e:
- db_host = e
-
- log_info_with_request("diagnostics", headers=request.headers, serving_host=serving_host, database_host=db_host)
-
- response_data = {
- 'request_path': request.headers.get('X-Forwarded-For', 'idfk'),
- 'serving_host': serving_host,
- 'database_host': db_host,
- }
- return make_response(json.dumps(response_data), 200, {'content-type': 'text/plain'})
+class LogoutView(BaseView):
+ @expose("/")
+ @flask_login.login_required
+ def logout(self):
+ flask_login.logout_user()
+ return redirect(url_for("login"))
+
+
+# init admin view, default endpoint is /admin
+admin = Admin(app, name="EpiData admin", template_mode="bootstrap4", index_view=AuthAdminIndexView())
+# database session
+admin_session = WriteSession()
+
+# add views
+admin.add_view(UserView(User, admin_session))
+admin.add_view(UserRoleView(UserRole, admin_session))
+admin.add_view(LogoutView(name="Logout", endpoint="logout"))
+
+
+@app.teardown_request
+def teardown_request(*args, **kwargs):
+ """
+ Remove the session after each request.
+ That is used to protect from dirty read.
+ """
+ admin_session.close()
\ No newline at end of file
diff --git a/src/server/endpoints/api_key_removal_request.py b/src/server/endpoints/api_key_removal_request.py
new file mode 100644
index 000000000..2d991b33b
--- /dev/null
+++ b/src/server/endpoints/api_key_removal_request.py
@@ -0,0 +1,67 @@
+from datetime import datetime
+from flask import Blueprint, render_template, request
+
+from .._common import send_email, send_slack_notification
+from .._config import SLACK_WEBHOOK_URL
+from .._db import WriteSession
+from ..admin.models import RemovalRequest, User
+
+bp = Blueprint("removal_request", __name__)
+alias = None
+
+
+REMOVAL_REQUEST_MESSAGE = """
+Your API Key: {}, removal request will be processed soon.
+To verify, we will send you an email message after processing your request.
+Best,
+Delphi Team
+"""
+
+
+def generate_api_key_removal_notification(email: str, comment: str):
+ payload = {
+ "blocks": [
+ {
+ "type": "header",
+ "text": {"type": "plain_text", "text": "New request", "emoji": True},
+ },
+ {
+ "type": "section",
+ "fields": [
+ {"type": "mrkdwn", "text": "*Type:*\nAPI Key deletion."},
+ {"type": "mrkdwn", "text": "*Created by:*\n{}".format(email)},
+ ],
+ },
+ {
+ "type": "section",
+ "fields": [
+ {"type": "mrkdwn", "text": "*Comment:*\n{}".format(comment)},
+ ],
+ },
+ {
+ "type": "section",
+ "fields": [
+ {"type": "mrkdwn", "text": "*Timestamp:*\n{}".format(datetime.now())},
+ ],
+ },
+ ]
+ }
+ return payload
+
+
+@bp.route("/", methods=["GET", "POST"])
+def handle():
+ flags = dict()
+ with WriteSession() as session:
+ if request.method == "POST":
+ api_key = request.values["api_key"]
+ comment = request.values.get("comment")
+ user = User.find_user(api_key=api_key)
+ # User.delete_user(user.id, session)
+ RemovalRequest.add_request(api_key, comment, session)
+ if SLACK_WEBHOOK_URL:
+ slack_message = generate_api_key_removal_notification(user.email, comment)
+ send_slack_notification(slack_message)
+ flags["banner"] = "Your request has been successfully recorded."
+ send_email(user.email, "API Key removal request", REMOVAL_REQUEST_MESSAGE.format(api_key))
+ return render_template("removal_request.html", flags=flags)
diff --git a/src/server/endpoints/registration.py b/src/server/endpoints/registration.py
new file mode 100644
index 000000000..55dff7434
--- /dev/null
+++ b/src/server/endpoints/registration.py
@@ -0,0 +1,100 @@
+import random
+import string
+from datetime import datetime
+
+from flask import Blueprint, render_template, request
+
+from .._common import send_email, send_slack_notification, verify_recaptcha_response
+from .._config import SLACK_WEBHOOK_URL
+from .._db import WriteSession
+from ..admin.models import RegistrationResponse, User
+from .._config import RECAPTCHA_SITE_KEY
+
+# first argument is the endpoint name
+bp = Blueprint("registration_form", __name__)
+alias = None
+
+NEW_KEY_MESSAGE = """
+Thank you for registering with the Delphi Epidata API.
+Your API key is: {}
+For usage information, see the API Keys section of the documentation: https://cmu-delphi.github.io/delphi-epidata/api/api_keys.html
+Best,
+Delphi Team
+"""
+
+
+def generate_registration_notification(email: str, organization: str, purpose: str):
+ payload = {
+ "blocks": [
+ {
+ "type": "header",
+ "text": {"type": "plain_text", "text": "New request", "emoji": True},
+ },
+ {
+ "type": "section",
+ "fields": [
+ {"type": "mrkdwn", "text": "*Type:*\nNew registration."},
+ {"type": "mrkdwn", "text": "*Created by:*\n{}".format(email)},
+ ],
+ },
+ {
+ "type": "section",
+ "fields": [
+ {"type": "mrkdwn", "text": "*Organization:*\n{}".format(organization)},
+ {"type": "mrkdwn", "text": "*Purpose:*\n{}".format(purpose)},
+ ],
+ },
+ {
+ "type": "section",
+ "fields": [
+ {"type": "mrkdwn", "text": "*Timestamp:*\n{}".format(datetime.now())},
+ ],
+ },
+ ]
+ }
+ return payload
+
+
+@bp.route("/", methods=["GET", "POST"])
+def handle():
+ flags = dict()
+ with WriteSession() as session:
+ if request.method == "POST":
+ email = request.values["email"]
+ organization = request.values["organization"]
+ purpose = request.values["purpose"]
+ verified = verify_recaptcha_response(request.values["g-recaptcha-response"])
+ if verified:
+ if not User.find_user(user_email=email):
+ # Use a separate table for email, purpose, organization
+ api_key = "".join(
+ random.choices(string.ascii_letters + string.digits, k=13)
+ )
+ user = User.create_user(api_key=api_key, email=email, session=session)
+ RegistrationResponse.add_response(
+ email=email,
+ organization=request.values["organization"],
+ purpose=request.values["purpose"],
+ session=session,
+ )
+ send_email(
+ to_addr=email,
+ subject="Delphi API Key Registration",
+ message=NEW_KEY_MESSAGE.format(api_key),
+ )
+ if SLACK_WEBHOOK_URL:
+ slack_message = generate_registration_notification(email, organization, purpose)
+ send_slack_notification(slack_message)
+ flags["banner"] = "Successfully sent"
+
+ else:
+ flags[
+ "error"
+ ] = "User with such email already exists. Please try another email address or contact us."
+ else:
+ flags[
+ "error"
+ ] = "Captcha was not submitted. Please try again."
+ return render_template(
+ "registration.html", flags=flags, recaptcha_key=RECAPTCHA_SITE_KEY
+ )
diff --git a/src/server/main.py b/src/server/main.py
index 2ec07e5a5..9a7859e74 100644
--- a/src/server/main.py
+++ b/src/server/main.py
@@ -2,18 +2,21 @@
import pathlib
import logging
import sentry_sdk
+import socket
+import json
from typing import Dict, Callable
-from flask import request, send_file, Response, send_from_directory, jsonify
+from flask import request, send_file, Response, send_from_directory, jsonify, make_response
from delphi.epidata.common.logger import get_structured_logger
from ._config import URL_PREFIX, VERSION
-from ._common import app, set_compatibility_mode
+from ._common import app, db, set_compatibility_mode, log_info_with_request
+from .endpoints.admin import _require_admin
from ._exceptions import MissingOrWrongSourceException
from .endpoints import endpoints
-from .endpoints.admin import bp as admin_bp, enable_admin
-from ._limiter import limiter, apply_limit
+from .endpoints.admin import *
+from ._limiter import apply_limit
SENTRY_DSN = os.environ.get('SENTRY_DSN')
if SENTRY_DSN:
@@ -44,12 +47,6 @@
logger.info("endpoint has alias", bp_name=endpoint.bp.name, alias=alias)
endpoint_map[alias] = endpoint.handle
-if enable_admin():
- logger.info("admin endpoint enabled")
- limiter.exempt(admin_bp)
- app.register_blueprint(admin_bp, url_prefix=f"{URL_PREFIX}/admin")
-
-
@app.route(f"{URL_PREFIX}/api.php", methods=["GET", "POST"])
@apply_limit
def handle_generic():
@@ -77,6 +74,38 @@ def send_lib_file(path: str):
return send_from_directory(pathlib.Path(__file__).parent / "lib", path)
+@app.route('/static/')
+def send_static(path):
+ return send_from_directory("/app/delphi/epidata/server/static", path)
+
+
+@app.route("/diagnostics", methods=["GET", "PUT", "POST", "DELETE"])
+def diags():
+ # allows us to get useful diagnostic information written into server logs,
+ # such as a full current "X-Forwarded-For" path as inserted into headers by intermediate proxies...
+ # (but only when initiated purposefully by us to keep junk out of the logs)
+ _require_admin()
+
+ try:
+ serving_host = socket.gethostbyname_ex(socket.gethostname())
+ except Exception as e:
+ serving_host = e
+
+ try:
+ db_host = db.execute('SELECT @@hostname AS hn').fetchone()['hn']
+ except Exception as e:
+ db_host = e
+
+ log_info_with_request("diagnostics", headers=request.headers, serving_host=serving_host, database_host=db_host)
+
+ response_data = {
+ 'request_path': request.headers.get('X-Forwarded-For', 'idfk'),
+ 'serving_host': serving_host,
+ 'database_host': db_host,
+ }
+ return make_response(json.dumps(response_data), 200, {'content-type': 'text/plain'})
+
+
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
else:
diff --git a/src/server/static/css/style.css b/src/server/static/css/style.css
new file mode 100644
index 000000000..26ba2862b
--- /dev/null
+++ b/src/server/static/css/style.css
@@ -0,0 +1,41 @@
+.divider-text {
+ position: relative;
+ text-align: center;
+ margin-top: 15px;
+ margin-bottom: 15px;
+}
+
+.divider-text span {
+ padding: 7px;
+ font-size: 12px;
+ position: relative;
+ z-index: 2;
+}
+
+.divider-text:after {
+ content: "";
+ position: absolute;
+ width: 100%;
+ border-bottom: 1px solid #ddd;
+ top: 55%;
+ left: 0;
+ z-index: 1;
+}
+
+.btn-facebook {
+ background-color: #405D9D;
+ color: #fff;
+}
+
+.btn-twitter {
+ background-color: #42AEEC;
+ color: #fff;
+}
+
+.text-description {
+ padding: 3%;
+}
+
+.card-body {
+ width: 100%;
+}
\ No newline at end of file
diff --git a/src/server/templates/admin/index.html b/src/server/templates/admin/index.html
new file mode 100644
index 000000000..45e1f0edf
--- /dev/null
+++ b/src/server/templates/admin/index.html
@@ -0,0 +1,31 @@
+{% extends 'admin/master.html' %}
+
+{% block body %}
+
+
List of users
+
+
+
#
+
Email
+
API Key
+
Organization
+
Purpose
+
Created
+
Last time used
+
+
+
+ {% for i in range(user_data | length ) %}
+
+
{{ i }}
+
{{ user_data[i].email }}
+
{{ user_data[i].api_key }}
+
{{ user_data[i].organization }}
+
{{ user_data[i].purpose }}
+
{{ user_data[i].created }}
+
{{ user_data[i].last_time_used }}
+
+ {% endfor %}
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/src/server/templates/login.html b/src/server/templates/login.html
new file mode 100644
index 000000000..3996ae0aa
--- /dev/null
+++ b/src/server/templates/login.html
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+ API Keys registration page
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/server/templates/registration.html b/src/server/templates/registration.html
new file mode 100644
index 000000000..cd7c36405
--- /dev/null
+++ b/src/server/templates/registration.html
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+ API Keys registration page
+
+
+
+
+
+
+
+
+
+
+
+
Delphi: Register API Key
+ {% if flags.banner %}
+
+ {{ flags.banner }}
+
+ {% endif %}
+ {% if flags.error %}
+
+ {{ flags.error }}
+
+ {% endif %}
+
This form allows you to register your API usage in order to lift the
+ restrictions
+ applied to anonymous access. If you regularly or frequently use our system, please consider using an
+ API key even if your usage falls within the anonymous usage limits. API key usage helps us
+ understand who and how others are using our Delphi Epidata API, which may in turn inform our future
+ research, data partnerships, and funding.
+
+ It is important for us to be able to contact our high-traffic users in case of excessive or abnormal
+ activity that may adversely affect our systems.
+
+ The questions about your organization and use case are optional, but we hope you will answer in
+ detail to help us better understand who and how others are using our API.
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/server/templates/removal_request.html b/src/server/templates/removal_request.html
new file mode 100644
index 000000000..67d1c8aaa
--- /dev/null
+++ b/src/server/templates/removal_request.html
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+ API Key removal request page
+
+
+
+
+
+
+
+
+
+
Delphi: API Key removal request
+ {% if flags.banner %}
+
+ {{ flags.banner }}
+
+ {% endif %}
+
+ Submit this form if you would like Delphi to disable your API key and destroy all information
+ associating that key with your identity. We will confirm the request by emailing the address you used at
+ registration time. Since this is a destructive operation, we handle these requests manually; you should
+ expect a response within 1-2 business days.
+