Skip to content

Commit b316ba9

Browse files
authored
Merge pull request #1197 from cmu-delphi/release/delphi-epidata-4.1.3
Release Delphi Epidata 4.1.3
2 parents 193c5b2 + 04d6316 commit b316ba9

File tree

14 files changed

+131
-47
lines changed

14 files changed

+131
-47
lines changed

.bumpversion.cfg

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[bumpversion]
2-
current_version = 4.1.2
2+
current_version = 4.1.3
33
commit = False
44
tag = False
55

dev/local/setup.cfg

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[metadata]
22
name = Delphi Development
3-
version = 4.1.2
3+
version = 4.1.3
44

55
[options]
66
packages =

docs/api/api_keys.md

+8
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@ If you choose to
4040
[register for an API key](https://api.delphi.cmu.edu/epidata/admin/registration_form),
4141
there are several ways to use your key to authenticate your requests:
4242

43+
### Using a client
44+
45+
* covidcast
46+
* [R client](https://cmu-delphi.github.io/covidcast/covidcastR/reference/covidcast_signal.html#api-keys-1)
47+
* [Python client](https://cmu-delphi.github.io/covidcast/covidcast-py/html/signals.html#covidcast.use_api_key)
48+
* [epidatr](https://github.com/cmu-delphi/epidatr#api-keys)
49+
* [delphi-epidata](https://cmu-delphi.github.io/delphi-epidata/api/client_libraries.html)
50+
4351
### Via request parameter
4452

4553
The request parameter “api_key” can be used to pass the API key to the server.

docs/api/client_libraries.md

+16-4
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,17 @@ parent: Other Endpoints (COVID-19 and Other Diseases)
44
nav_order: 1
55
---
66

7-
# Epidata API Client Libraries.
7+
# Epidata API Client Libraries
88

9-
Epidata clients are available for
9+
For anyone looking for COVIDCast data, please visit our [COVIDCast Libraries](covidcast_clients.md).
10+
11+
We are currently working on fully-featured Epidata clients for R and Python. They are not ready
12+
for release yet, but you can track our development progress and help us test them out at:
13+
14+
* [epidatr](https://github.com/cmu-delphi/epidatr)
15+
* [epidatpy](https://github.com/cmu-delphi/epidatpy)
16+
17+
In the meantime, minimalist Epidata clients remain available for
1018
[JavaScript](https://github.com/cmu-delphi/delphi-epidata/blob/master/src/client/delphi_epidata.js),
1119
[Python](https://github.com/cmu-delphi/delphi-epidata/blob/master/src/client/delphi_epidata.py),
1220
and
@@ -15,10 +23,10 @@ The following samples show how to import the library and fetch Delphi's COVID-19
1523
Surveillance Streams from Facebook Survey CLI for county 06001 and days
1624
`20200401` and `20200405-20200414` (11 days total).
1725

18-
For anyone looking for COVIDCast data, please visit our [COVIDCast Libraries](covidcast_clients.md).
19-
2026
### JavaScript (in a web browser)
2127

28+
The minimalist JavaScript client does not currently support API keys. If you need API key support in JavaScript, contact [email protected].
29+
2230
````html
2331
<!-- Imports -->
2432
<script src="delphi_epidata.js"></script>
@@ -45,6 +53,8 @@ in the same directory as your Python script.
4553
````python
4654
# Import
4755
from delphi_epidata import Epidata
56+
# [Optional] configure your API key, if desired
57+
#Epidata.auth = ('epidata', <your API key>)
4858
# Fetch data
4959
res = Epidata.covidcast('fb-survey', 'smoothed_cli', 'day', 'county', [20200401, Epidata.range(20200405, 20200414)], '06001')
5060
print(res['result'], res['message'], len(res['epidata']))
@@ -54,6 +64,8 @@ print(res['result'], res['message'], len(res['epidata']))
5464

5565

5666
````R
67+
# [Optional] configure your API key, if desired
68+
#option('epidata.auth', <your API key>)
5769
# Import
5870
source('delphi_epidata.R')
5971
# Fetch data

docs/symptom-survey/publications.md

+7-2
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,20 @@ Pandemic"](https://www.pnas.org/topic/548) in *PNAS*:
2626

2727
Research publications using the survey data include:
2828

29+
- GK Charles, SP Braunstein, JL Barker, et al (2023). [How do psychobehavioural
30+
variables shed light on heterogeneity in COVID-19 vaccine acceptance? Evidence
31+
from United States general population surveys on a probability panel and
32+
social media](https://doi.org/10.1136/bmjopen-2022-066897). *BMJ Open*
33+
13:e066897.
2934
- S. Soorapanth, R. Cheung, X. Zhang, A. H. Mokdad, G. A. Mensah (2023).
3035
[Rural–Urban Differences in Vaccination and Hesitancy Rates and Trust: US
3136
COVID-19 Trends and Impact Survey on a Social Media Platform, May 2021–April
3237
2022](https://doi.org/10.2105/AJPH.2023.307274). *American Journal of Public
33-
Health*.
38+
Health* 113 (6), 680-688.
3439
- See also the associated editorial: T. Callaghan (2023). [Vaccine Uptake and
3540
Hesitancy in Rural America in the Wake of the COVID-19
3641
Pandemic](https://doi.org/10.2105/AJPH.2023.307305). *American Journal of
37-
Public Health*.
42+
Public Health* 113 (6), 615-617.
3843
- M. Rubinstein, A. Haviland, and J. Breslau (2023). [The effect of COVID-19
3944
vaccinations on self-reported depression and anxiety during February
4045
2021](https://doi.org/10.1080/2330443X.2023.2190008). *Statistics and Public

src/client/delphi_epidata.R

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Epidata <- (function() {
1515
# API base url
1616
BASE_URL <- getOption('epidata.url', default = 'https://api.delphi.cmu.edu/epidata/')
1717

18-
client_version <- '4.1.2'
18+
client_version <- '4.1.3'
1919

2020
auth <- getOption("epidata.auth", default = NA)
2121

src/client/delphi_epidata.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
}
2323
})(this, function (exports, fetchImpl, jQuery) {
2424
const BASE_URL = "https://api.delphi.cmu.edu/epidata/";
25-
const client_version = "4.1.2";
25+
const client_version = "4.1.3";
2626

2727
// Helper function to cast values and/or ranges to strings
2828
function _listitem(value) {

src/client/packaging/npm/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "delphi_epidata",
33
"description": "Delphi Epidata API Client",
44
"authors": "Delphi Group",
5-
"version": "4.1.2",
5+
"version": "4.1.3",
66
"license": "MIT",
77
"homepage": "https://github.com/cmu-delphi/delphi-epidata",
88
"bugs": {
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
from .delphi_epidata import Epidata
22

33
name = 'delphi_epidata'
4-
__version__ = '4.1.2'
4+
__version__ = '4.1.3'

src/client/packaging/pypi/setup.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
setuptools.setup(
77
name="delphi_epidata",
8-
version="4.1.2",
8+
version="4.1.3",
99
author="David Farrow",
1010
author_email="[email protected]",
1111
description="A programmatic interface to Delphi's Epidata API.",

src/server/_common.py

+21-6
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from werkzeug.local import LocalProxy
99

1010
from delphi.epidata.common.logger import get_structured_logger
11-
from ._config import SECRET, REVERSE_PROXIED
11+
from ._config import SECRET, REVERSE_PROXY_DEPTH
1212
from ._db import engine
1313
from ._exceptions import DatabaseErrorException, EpiDataException
1414
from ._security import current_user, _is_public_route, resolve_auth_token, show_no_api_key_warning, update_key_last_time_used, ERROR_MSG_INVALID_KEY
@@ -31,14 +31,29 @@ def _get_db() -> Connection:
3131

3232

3333
def get_real_ip_addr(req): # `req` should be a Flask.request object
34-
if REVERSE_PROXIED:
35-
# NOTE: ONLY trust these headers if reverse proxied!!!
34+
if REVERSE_PROXY_DEPTH:
35+
# we only expect/trust (up to) "REVERSE_PROXY_DEPTH" number of proxies between this server and the outside world.
36+
# a REVERSE_PROXY_DEPTH of 0 means not proxied, i.e. server is globally directly reachable.
37+
# a negative proxy depth is a special case to trust the whole chain -- not generally recommended unless the
38+
# most-external proxy is configured to disregard "X-Forwarded-For" from outside.
39+
# really, ONLY trust the following headers if reverse proxied!!!
3640
if "X-Forwarded-For" in req.headers:
37-
return req.headers["X-Forwarded-For"].split(",")[
38-
0
39-
] # take the first (or only) address from the comma-sep list
41+
full_proxy_chain = req.headers["X-Forwarded-For"].split(",")
42+
# eliminate any extra addresses at the front of this list, as they could be spoofed.
43+
if REVERSE_PROXY_DEPTH > 0:
44+
depth = REVERSE_PROXY_DEPTH
45+
else:
46+
# special case for -1/negative: setting `depth` to 0 will not strip any items from the chain
47+
depth = 0
48+
trusted_proxy_chain = full_proxy_chain[-depth:]
49+
# accept the first (or only) address in the remaining trusted part of the chain as the actual remote address
50+
return trusted_proxy_chain[0].strip()
51+
52+
# fall back to "X-Real-Ip" if "X-Forwarded-For" isnt present
4053
if "X-Real-Ip" in req.headers:
4154
return req.headers["X-Real-Ip"]
55+
56+
# if we are not proxied (or we are proxied but the headers werent present and we fell through to here), just use the remote ip addr as the true client address
4257
return req.remote_addr
4358

4459

src/server/_config.py

+36-9
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
load_dotenv()
99

10-
VERSION = "4.1.2"
10+
VERSION = "4.1.3"
1111

1212
MAX_RESULTS = int(10e6)
1313
MAX_COMPATIBILITY_RESULTS = int(3650)
@@ -26,14 +26,41 @@
2626
SECRET = os.environ.get("FLASK_SECRET", "secret")
2727
URL_PREFIX = os.environ.get("FLASK_PREFIX", "") # if not empty, this value should begin but not end in a slash ('/')
2828

29-
# REVERSE_PROXIED is a boolean value that indicates whether or not this server instance
30-
# is running behind a reverse proxy (like nginx).
31-
# in dev and testing, it is fine (or even preferable) for this variable to be set to 'TRUE'
32-
# even if it is not actually the case. in prod, it is very important that this is set accurately --
33-
# it should _only_ be set to 'TRUE' if it really is behind a reverse proxy, as remote addresses can be "spoofed"
34-
# which can carry security/identity implications. conversely, if this is set to 'FALSE' when in fact
35-
# running behind a reverse proxy, it can hinder logging accuracy. it defaults to 'FALSE' for safety.
36-
REVERSE_PROXIED = os.environ.get("REVERSE_PROXIED", "FALSE").upper() == "TRUE"
29+
30+
"""
31+
REVERSE_PROXY_DEPTH is an integer value that indicates how many "chained" and trusted reverse proxies (like nginx) this
32+
server instance is running behind. "chained" refers to proxies forwarding to other proxies, and then ultimately
33+
forwarding to the app server itself. each of these proxies appends the remote address of the request to the
34+
"X-Forwarded-For" header. in many situations, the most externally facing proxy (the first in the chain, the one that
35+
faces the "open internet") can and should be set to write its own "X-Forwarded-For" header, ignoring and replacing
36+
(or creating anew, if it didnt exist) such a header from the client request -- thus preserving the chain of trusted
37+
proxies under our control.
38+
39+
however, in our typical production environment, the most externally facing "proxy" is the AWS application load balancer,
40+
which seemingly cannot be configured to provide this trust boundary without losing the referring client address
41+
(see: https://docs.aws.amazon.com/elasticloadbalancing/latest/application/x-forwarded-headers.html ). accordingly, in
42+
our current typical production environment, REVERSE_PROXY_DEPTH should be set to "2": one for the AWS application load
43+
balancer, and one for our own nginx/haproxy instance. thus "2" is our default value.
44+
45+
it is important that REVERSE_PROXY_DEPTH is set accurately for two reasons...
46+
47+
setting it too high (or to -1) will respect more of the entries in the "X-Forwarded-For" header than are appropriate.
48+
this can allow remote addresses to be "spoofed" when a client fakes this header, carrying security/identity
49+
implications. in dev and testing, it is not particularly dangerous for this variable to be set to -1 (special case
50+
for an "infinite" depth, where any and all proxy hops will be trusted).
51+
52+
setting it too low can hinder logging accuracy -- that can cause an intermediate proxy IP address to be used as the
53+
"real" client IP address, which could cause requests to be rate-limited inappropriately.
54+
55+
setting REVERSE_PROXY_DEPTH to "0" essentially indicates there are no proxies between this server and the outside
56+
world. in this case, the "X-Forwarded-For" header is ignored.
57+
"""
58+
REVERSE_PROXY_DEPTH = int(os.environ.get("PROXY_DEPTH", 4))
59+
# TODO: ^ this value should be "4" for the prod CC API server processes, and is currently unclear
60+
# for prod AWS API server processes (but should be the same or lower)... when thats properly
61+
# determined, set the default to the minimum of the two environments and special case the
62+
# other in conf file(s).
63+
3764

3865
REGION_TO_STATE = {
3966
"hhs1": ["VT", "CT", "ME", "MA", "NH", "RI"],

src/server/admin/models.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,17 @@
1919
Column("role_id", ForeignKey("user_role.id")),
2020
)
2121

22+
def _default_date_now():
23+
return dtime.strftime(dtime.now(), "%Y-%m-%d")
2224

2325
class User(Base):
2426
__tablename__ = "api_user"
2527
id = Column(Integer, primary_key=True, autoincrement=True)
2628
roles = relationship("UserRole", secondary=association_table)
2729
api_key = Column(String(50), unique=True, nullable=False)
2830
email = Column(String(320), unique=True, nullable=False)
29-
created = Column(Date, default=dtime.strftime(dtime.now(), "%Y-%m-%d"))
30-
last_time_used = Column(Date, default=dtime.strftime(dtime.now(), "%Y-%m-%d"))
31+
created = Column(Date, default=_default_date_now)
32+
last_time_used = Column(Date, default=_default_date_now)
3133

3234
def __init__(self, api_key: str, email: str = None) -> None:
3335
self.api_key = api_key

src/server/endpoints/admin.py

+32-17
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from werkzeug.exceptions import NotFound, Unauthorized
66
from werkzeug.utils import redirect
77

8+
from .._common import log_info_with_request
89
from .._config import ADMIN_PASSWORD, API_KEY_REGISTRATION_FORM_LINK, API_KEY_REMOVAL_REQUEST_LINK, REGISTER_WEBHOOK_TOKEN
910
from .._security import resolve_auth_token
1011
from ..admin.models import User, UserRole
@@ -44,6 +45,24 @@ def user_exists(user_email: str = None, api_key: str = None):
4445
return True if user else False
4546

4647

48+
# ~~~~ PUBLIC ROUTES ~~~~
49+
50+
51+
@bp.route("/registration_form", methods=["GET"])
52+
def registration_form_redirect():
53+
# TODO: replace this with our own hosted registration form instead of external
54+
return redirect(API_KEY_REGISTRATION_FORM_LINK, code=302)
55+
56+
57+
@bp.route("/removal_request", methods=["GET"])
58+
def removal_request_redirect():
59+
# TODO: replace this with our own hosted form instead of external
60+
return redirect(API_KEY_REMOVAL_REQUEST_LINK, code=302)
61+
62+
63+
# ~~~~ PRIVLEGED ROUTES ~~~~
64+
65+
4766
@bp.route("/", methods=["GET", "POST"])
4867
def _index():
4968
token = _require_admin()
@@ -88,21 +107,6 @@ def _detail(user_id: int):
88107
return _render("detail", token, flags, user=user.as_dict)
89108

90109

91-
def register_new_key(api_key: str, email: str) -> str:
92-
User.create_user(api_key=api_key, email=email)
93-
return api_key
94-
95-
96-
@bp.route("/registration_form", methods=["GET"])
97-
def registration_form_redirect():
98-
# TODO: replace this with our own hosted registration form instead of external
99-
return redirect(API_KEY_REGISTRATION_FORM_LINK, code=302)
100-
101-
@bp.route("/removal_request", methods=["GET"])
102-
def removal_request_redirect():
103-
# TODO: replace this with our own hosted form instead of external
104-
return redirect(API_KEY_REMOVAL_REQUEST_LINK, code=302)
105-
106110
@bp.route("/register", methods=["POST"])
107111
def _register():
108112
body = request.get_json()
@@ -117,5 +121,16 @@ def _register():
117121
"User with email and/or API Key already exists, use different parameters or contact us for help",
118122
409,
119123
)
120-
api_key = register_new_key(user_api_key, user_email)
121-
return make_response(f"Successfully registered API key '{api_key}'", 200)
124+
User.create_user(api_key=user_api_key, email=user_email)
125+
return make_response(f"Successfully registered API key '{user_api_key}'", 200)
126+
127+
128+
@bp.route("/diagnostics", methods=["GET", "PUT", "POST", "DELETE"])
129+
def diags():
130+
# allows us to get useful diagnostic information written into server logs,
131+
# such as a full current "X-Forwarded-For" path as inserted into headers by intermediate proxies...
132+
# (but only when initiated purposefully by us to keep junk out of the logs)
133+
_require_admin()
134+
log_info_with_request("diagnostics", headers=request.headers)
135+
response_text = f"request path: {request.headers.get('X-Forwarded-For', 'idk')}"
136+
return make_response(response_text, 200, {'content-type': 'text/plain'})

0 commit comments

Comments
 (0)