Registering for an API key provided authenticated access to the OpenAQ API. Authenticated users are allowed an increased rate limit when accessing the API. To learn more visit docs.openaq.org
37 |
38 |
89 |
90 |
91 |
See our privacy policy for more information on the personal data we store.
92 |
93 |
94 |
95 |
96 |
97 |
98 |
--------------------------------------------------------------------------------
/openaq_api/v3/routers/parameters.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from enum import StrEnum, auto
3 | from typing import Annotated
4 |
5 | from fastapi import APIRouter, Depends, HTTPException, Path, Query
6 |
7 | from openaq_api.db import DB
8 | from openaq_api.v3.models.queries import (
9 | BboxQuery,
10 | CountryIdQuery,
11 | CountryIsoQuery,
12 | Paging,
13 | QueryBaseModel,
14 | QueryBuilder,
15 | RadiusQuery,
16 | SortingBase,
17 | )
18 | from openaq_api.v3.models.responses import ParametersResponse
19 |
20 | logger = logging.getLogger("parameters")
21 |
22 | router = APIRouter(
23 | prefix="/v3",
24 | tags=["v3"],
25 | include_in_schema=True,
26 | )
27 |
28 |
29 | class ProvidersSortFields(StrEnum):
30 | ID = auto()
31 |
32 |
33 | class ProvidersSorting(SortingBase):
34 | order_by: ProvidersSortFields | None = Query(
35 | "id",
36 | description="""Order results by ID""",
37 | examples=["order_by=id"],
38 | )
39 |
40 |
41 | class ParameterPathQuery(QueryBaseModel):
42 | """Path query to filter results by parameters ID
43 |
44 | Inherits from QueryBaseModel
45 |
46 | Attributes:
47 | parameters_id: countries ID value
48 | """
49 |
50 | parameters_id: int = Path(
51 | ..., description="Limit the results to a specific parameters id", ge=1
52 | )
53 |
54 | def where(self) -> str:
55 | """Generates SQL condition for filtering to a single parameters_id
56 |
57 | Overrides the base QueryBaseModel `where` method
58 |
59 | Returns:
60 | string of WHERE clause
61 | """
62 | return "id = :parameters_id"
63 |
64 |
65 | class ParameterType(StrEnum):
66 | pollutant = "pollutant"
67 | meteorological = "meteorological"
68 |
69 |
70 | class ParameterTypeQuery(QueryBaseModel):
71 | """Query to filter results by parameter_type
72 |
73 | Inherits from QueryBaseModel
74 |
75 | Attributes:
76 | parameter_type: a string representing the parameter type to filter
77 | """
78 |
79 | parameter_type: ParameterType | None = Query(
80 | None,
81 | description="Limit the results to a specific parameters type",
82 | examples=["pollutant", "meteorological"],
83 | )
84 |
85 | def where(self) -> str | None:
86 | """Generates SQL condition for filtering to a single parameters_id
87 |
88 | Overrides the base QueryBaseModel `where` method
89 |
90 | Returns:
91 | string of WHERE clause if `parameter_type` is set
92 | """
93 | if self.parameter_type == None:
94 | return None
95 | return "m.parameter_type = :parameter_type"
96 |
97 |
98 | class ParametersCountryIsoQuery(CountryIsoQuery):
99 | """Pydantic query model for the `iso` query parameter.
100 |
101 | Specialty query object for parameters_view_cached to handle ISO code IN ARRAY
102 |
103 | Inherits from CountryIsoQuery
104 | """
105 |
106 | def where(self) -> str | None:
107 | """Generates SQL condition for filtering to country ISO code
108 |
109 | Overrides the base QueryBaseModel `where` method
110 |
111 | Returns:
112 | string of WHERE clause
113 | """
114 | if self.iso is not None:
115 | return "country->>'code' IN :iso"
116 |
117 |
118 | class ParametersSortFields(StrEnum):
119 | ID = auto()
120 |
121 |
122 | class ParametersSorting(SortingBase):
123 | order_by: ParametersSortFields | None = Query(
124 | "id",
125 | description="The field by which to order results",
126 | examples=["order_by=id"],
127 | )
128 |
129 |
130 | class ParametersQueries(
131 | Paging,
132 | CountryIdQuery,
133 | CountryIsoQuery, # TODO replace with ParametersCountryIsoQuery when parameters_view_cached is updated with ISO array field
134 | BboxQuery,
135 | RadiusQuery,
136 | ParameterTypeQuery,
137 | ParametersSorting,
138 | ): ...
139 |
140 |
141 | @router.get(
142 | "/parameters/{parameters_id}",
143 | response_model=ParametersResponse,
144 | summary="Get a parameter by ID",
145 | description="Provides a parameter by parameter ID",
146 | )
147 | async def parameter_get(
148 | parameter: Annotated[ParameterPathQuery, Depends(ParameterPathQuery.depends())],
149 | db: DB = Depends(),
150 | ) -> ParametersResponse:
151 | response = await fetch_parameters(parameter, db)
152 | if len(response.results) == 0:
153 | raise HTTPException(status_code=404, detail="Parameter not found")
154 | return response
155 |
156 |
157 | @router.get(
158 | "/parameters",
159 | response_model=ParametersResponse,
160 | summary="Get a parameters",
161 | description="Provides a list of parameters",
162 | )
163 | async def parameters_get(
164 | parameter: Annotated[ParametersQueries, Depends(ParametersQueries.depends())],
165 | db: DB = Depends(),
166 | ):
167 | response = await fetch_parameters(parameter, db)
168 | return response
169 |
170 |
171 | async def fetch_parameters(query, db) -> ParametersResponse:
172 | query_builder = QueryBuilder(query)
173 | ## TODO
174 | sql = f"""
175 | SELECT id
176 | , p.name
177 | , p.display_name
178 | , p.units
179 | , p.description
180 | {query_builder.total()}
181 | FROM
182 | parameters_view_cached p
183 | JOIN
184 | measurands m ON p.id = m.measurands_id
185 | {query_builder.where()}
186 | {query_builder.pagination()}
187 | """
188 | response = await db.fetchPage(sql, query_builder.params())
189 | return response
190 |
--------------------------------------------------------------------------------
/openaq_api/dependencies.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from datetime import datetime
3 | from openaq_api.settings import settings
4 | from fastapi import Security, Response
5 | from starlette.requests import Request
6 |
7 | from fastapi.security import (
8 | APIKeyHeader,
9 | )
10 |
11 | from openaq_api.models.logging import (
12 | TooManyRequestsLog,
13 | UnauthorizedLog,
14 | )
15 |
16 | from openaq_api.exceptions import (
17 | NOT_AUTHENTICATED_EXCEPTION,
18 | TOO_MANY_REQUESTS,
19 | )
20 |
21 | logger = logging.getLogger("dependencies")
22 |
23 |
24 | def in_allowed_list(route: str) -> bool:
25 | logger.debug(f"Checking if '{route}' is allowed")
26 | allow_list = ["/", "/openapi.json", "/docs", "/register"]
27 | if route in allow_list:
28 | return True
29 | if "/v3/locations/tiles" in route:
30 | return True
31 | if "/assets" in route:
32 | return True
33 | if ".css" in route:
34 | return True
35 | if ".js" in route:
36 | return True
37 | return False
38 |
39 |
40 | async def check_api_key(
41 | request: Request,
42 | response: Response,
43 | api_key=Security(APIKeyHeader(name="X-API-Key", auto_error=False)),
44 | ):
45 | """
46 | Check for an api key and then to see if they are rate limited. Throws a
47 | `not authenticated` or `too many reqests` error if appropriate.
48 | Meant to be used as a dependency either at the app, router or function level
49 | """
50 | route = request.url.path
51 | # no checking or limiting for whitelistted routes
52 | logger.debug(f'Explorer api key: {settings.EXPLORER_API_KEY}')
53 | if in_allowed_list(route):
54 | return api_key
55 | elif api_key == settings.EXPLORER_API_KEY:
56 | return api_key
57 | else:
58 | # check to see if we are limiting
59 | redis = request.app.redis
60 |
61 | if redis is None:
62 | logger.warning("No redis client found")
63 | return api_key
64 | elif api_key is None:
65 | logging.info(
66 | UnauthorizedLog(
67 | request=request, detail="api key not provided"
68 | ).model_dump_json()
69 | )
70 | raise NOT_AUTHENTICATED_EXCEPTION
71 | else:
72 |
73 | # check valid key
74 | if await redis.sismember("keys", api_key) == 0:
75 | logging.info(
76 | UnauthorizedLog(
77 | request=request, detail="api key not found"
78 | ).model_dump_json()
79 | )
80 | raise NOT_AUTHENTICATED_EXCEPTION
81 | # check api key
82 | limit = await redis.hget(api_key, "rate")
83 | try:
84 | limit = int(limit)
85 | except TypeError:
86 | limit = 60
87 | limited = False
88 | # check if its limited
89 | now = datetime.now()
90 | # Using a sliding window rate limiting algorithm
91 | # we add the current time to the minute to the api key and use that as our check
92 | key = f"{api_key}:{now.year}{now.month}{now.day}{now.hour}{now.minute}"
93 | # if the that key is in our redis db it will return the number of requests
94 | # that key has made during the current minute
95 | requests_used = await redis.get(key)
96 |
97 | if requests_used is None:
98 | # if the value is none than we need to add that key to the redis db
99 | # and set it, increment it and set it to timeout/delete is 60 seconds
100 | logger.debug("redis no key for current minute so not limited")
101 | async with redis.pipeline() as pipe:
102 | [requests_used, _] = await pipe.incr(key).expire(key, 60).execute()
103 | elif int(requests_used) < limit:
104 | # if that key does exist and the value is below the allowed number of requests
105 | # wea re going to increment it and move on
106 | logger.debug(
107 | f"redis - has key for current minute ({requests_used}) < limit ({limit})"
108 | )
109 | async with redis.pipeline() as pipe:
110 | [requests_used] = await pipe.incr(key).execute()
111 | else:
112 | # otherwise the user is over their limit and so we are going to throw a 429
113 | # after we set the headers
114 | logger.debug(
115 | f"redis - has key for current minute ({requests_used}) >= limit ({limit})"
116 | )
117 | limited = True
118 |
119 | ttl = await redis.ttl(key)
120 | request.state.rate_limiter = (
121 | f"{key}/{limit}/{requests_used}/{limit - int(requests_used)}/{ttl}"
122 | )
123 | rate_limit_headers = {
124 | "x-ratelimit-limit": str(limit),
125 | "x-ratelimit-used": str(requests_used),
126 | "x-ratelimit-remaining": str(limit - int(requests_used)),
127 | "x-ratelimit-reset": str(ttl),
128 | }
129 | response.headers.update(rate_limit_headers)
130 |
131 | if limited:
132 | logging.info(
133 | TooManyRequestsLog(
134 | request=request,
135 | rate_limiter=f"{key}/{limit}/{requests_used}",
136 | ).model_dump_json()
137 | )
138 | raise TOO_MANY_REQUESTS(rate_limit_headers)
139 |
140 | # it would be ideal if we were returing the user information right here
141 | # even it was just an email address it might be useful
142 | return api_key
143 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # OpenAQ API
2 | [](https://join.slack.com/t/openaq/shared_invite/zt-yzqlgsva-v6McumTjy2BZnegIK9XCVw)
3 |
4 | ## Overview
5 | This repository contains the source code for the [OpenAQ API](https://api.openaq.org), a publicly-accessible API that provides endpoints to query the real-time and historical air quality measurements on the OpenAQ platform.
6 |
7 | > [!NOTE]
8 | > This repository is for setting up and deploying the OpenAQ API. If you just wish to access the public API to query data from the OpenAQ platform, visit https://api.openaq.org or https://docs.openaq.org to learn more.
9 |
10 | ## Package management
11 | We are currently using [Poetry](https://python-poetry.org/) to manage our dependencies and run locally.
12 |
13 | ## Local development
14 | In production, the OpenAQ API runs on AWS Lambda with the help of the [mangum](https://mangum.io/) library. This allows the application to run in a serverless environment and take advantage of async Python and FastAPI routing. Despite the serverless deployment, running the API locally as a standard FastAPI application is largely unchanged, making local development much easier.
15 |
16 | ### Settings
17 | Settings can be loaded using `.env` files, and multiple files can be kept and used. The easiest way to manage multiple environment files is to add an extension describing your environment. For example, if I wanted to keep a production, staging and local environment, I would save them as `.env.production`, `.env.staging` and `.env.local` each with their own settings.
18 |
19 | ```
20 | DATABASE_READ_USER=database-read-user
21 | DATABASE_WRITE_USER=database-write-user
22 | DATABASE_READ_PASSWORD=database-read-password
23 | DATABASE_WRITE_PASSWORD=database-write-password
24 | DATABASE_DB=database-name
25 | DATABASE_HOST=localhost
26 | DATABASE_PORT=5432
27 | LOG_LEVEL=info
28 | ```
29 |
30 | ### Running locally
31 | The easiest way to run the API locally is to use uvicorn. Make sure that you have your settings (`.env`) file setup. Once that is done, you can run the following from the `openaq_api` directory. Variables from the `.env` files can be overrode by setting them inline.
32 |
33 | ```bash
34 | # Run using the default .env file
35 | poetry run uvicorn openaq_api.main:app --reload --lifespan on
36 | ```
37 | You can also specify which `.env` file to load by passing the `ENV` variable. This should not include the `.env.` prefix
38 |
39 | ```bash
40 | DOTENV=local poetry run uvicorn openaq_api.main:app --reload --lifespan on
41 | ```
42 | If you are connecting to our production environment you will AWS credentials therefor you may need to provdide the profile name to access the right credentials.
43 |
44 | ```
45 | AWS_PROFILE=optional-profile-name \
46 | DOTENV=production \
47 | poetry run uvicorn openaq_api.main:app --reload --lifespan on
48 | ```
49 | And you can always override variables by setting them inline. This is handy for when you want to change something for the purpose of debugging.
50 | ```
51 | # Run the staging environment and add verbose logging
52 | ENV=staging LOG_LEVEL=debug uvicorn main:app --reload
53 | DOTENV=staging \
54 | LOG_LEVEL=debug \
55 | poetry run uvicorn openaq_api.main:app --reload --lifespan on
56 | ```
57 |
58 | ## Testing
59 | From the root directory
60 | ```bash
61 | DOTENV=local poetry run pytest tests/
62 | ```
63 |
64 | ## Rate limiting
65 |
66 | In the production environment, rate limiting is handled in two places, AWS WAF and at the application level with [Starlette Middleware](https://www.starlette.io/middleware/). The application rate limiting is configurable via environment variables. The rate limiting middleware requires access to an instance of a [redis](https://redis.io/) cluster. For local development, [docker](https://www.docker.com/) can be a convenient method to set up a local redis cluster. With docker, use the following command:
67 |
68 | ```sh
69 | docker run -e "IP=0.0.0.0" -p 7000-7005:7000-7005 grokzen/redis-cluster:7.0.7
70 | ```
71 |
72 | Now a redis instance will be available at ``` http://localhost:7000 ```. Configure the REDIS_HOST to `localhost` and REDIS_PORT to `7000`.
73 |
74 | > [!TIP]
75 | > On some macOS systems port 7000 is used by Airplay which can complicate the mapping of ports from the Docker container. The easiest option is to disable the Airplay reciever in system settings. `System settings -> General -> Airplay receiver (toggle off)`
76 |
77 | ### Rate limiting values
78 |
79 | Rate limiting can be toggled off for local develop via the `RATE_LIMITING` environment variable. Other rate limiting values are:
80 | * `RATE_AMOUNT_KEY` - The number of requests allowed with a valid API key
81 | * `RATE_TIME` - The number of minutes for the rate
82 |
83 | e.g. `RATE_AMOUNT_KEY=5` and `RATE_TIME=1` would allow 5 requests per 1 minute.
84 |
85 | > [!NOTE]
86 | > With AWS WAF, rate limiting also occurs at the cloudfront stage. The application level rate limiting should be less than or equal to the value set at AWS WAF.
87 |
88 |
89 | ### Deployment
90 |
91 | Deployment is managed with Amazon Web Services (AWS) Cloud Development Kit (CDK). Additional environment variables are required for a full deployment to the AWS Cloud.
92 | # Deploying
93 | ```python
94 | AWS_PROFILE=optional-profile-name DOTENV=production cdk deploy openaq-api-production
95 | ```
96 |
97 | ## Platform Overview
98 |
99 | [openaq-fetch](https://github.com/openaq/openaq-fetch) and [openaq-fetch-lcs](https://github.com/openaq/openaq-fetch-lcs) take care of fetching new data and writing to [S3](https://openaq-fetches.s3.amazonaws.com/index.html). Lambda functions defined in [openaq-ingestor](https://github.com/openaq/openaq-ingestor), then load data into the database, defined in [openaq-db](https://github.com/openaq/openaq-db).
100 |
101 |
102 | ## Contributing
103 | There are many ways to contribute to this project; more details can be found in the [contributing guide](CONTRIBUTING.md).
104 |
--------------------------------------------------------------------------------
/openaq_api/models/logging.py:
--------------------------------------------------------------------------------
1 | from enum import StrEnum
2 | from typing import Any
3 |
4 | from fastapi import Request, status
5 | from humps import camelize
6 | from pydantic import BaseModel, ConfigDict, Field, computed_field
7 | import re
8 | from dateutil.parser import parse
9 |
10 |
11 | class LogType(StrEnum):
12 | SUCCESS = "SUCCESS"
13 | VALIDATION_ERROR = "VALIDATION_ERROR"
14 | INFRASTRUCTURE_ERROR = "INFRASTRUCTURE_ERROR"
15 | UNPROCESSABLE_ENTITY = "UNPROCESSABLE_ENTITY"
16 | UNAUTHORIZED = "UNAUTHORIZED"
17 | TOO_MANY_REQUESTS = "TOO_MANY_REQUESTS"
18 | WARNING = "WARNING"
19 | INFO = "INFO"
20 | ERROR = "ERROR"
21 |
22 |
23 | class BaseLog(BaseModel):
24 | """Abstract base class for logging.
25 |
26 | Inherits from Pydantic BaseModel
27 | """
28 |
29 | type: LogType
30 | detail: str | None = None
31 |
32 | def model_dump_json(self, **kwargs):
33 | kwargs["by_alias"] = True
34 | return super().model_dump_json(**kwargs)
35 |
36 | model_config = ConfigDict(
37 | alias_generator=camelize, arbitrary_types_allowed=True, populate_by_name=True
38 | )
39 |
40 |
41 | class InfoLog(BaseLog):
42 | type: LogType = LogType.INFO
43 |
44 |
45 | class WarnLog(BaseLog):
46 | type: LogType = LogType.WARNING
47 |
48 |
49 | class ErrorLog(BaseLog):
50 | type: LogType = LogType.ERROR
51 |
52 |
53 | class InfrastructureErrorLog(BaseLog):
54 | type: LogType = LogType.INFRASTRUCTURE_ERROR
55 |
56 |
57 | class AuthLog(BaseLog):
58 | type: LogType = LogType.INFO
59 |
60 |
61 | class SESEmailLog(BaseLog):
62 | type: LogType = LogType.INFO
63 |
64 |
65 | class HTTPLog(BaseLog):
66 | """A base class for logging HTTP requests
67 |
68 | inherits from BaseLog
69 |
70 | Attributes:
71 | request:
72 | http_code:
73 | timing:
74 | rate_limiter:
75 | counter:
76 | ip:
77 | api_key:
78 | user_agent:
79 | path:
80 | params:
81 | params_obj:
82 | params_keys:
83 |
84 | """
85 |
86 | request: Request = Field(..., exclude=True)
87 | http_code: int
88 | timing: float | None = None
89 | rate_limiter: str | None = None
90 | counter: int | None = None
91 |
92 | @computed_field(return_type=str)
93 | @property
94 | def ip(self) -> str:
95 | """str: returns IP address from request client"""
96 | return self.request.client.host
97 |
98 | @computed_field(return_type=str)
99 | @property
100 | def api_key(self) -> str:
101 | """str: returns API Key from request headers"""
102 | return self.request.headers.get("X-API-Key", None)
103 |
104 | @computed_field(return_type=str)
105 | @property
106 | def user_agent(self) -> str:
107 | """str: returns User-Agent from request headers"""
108 | return self.request.headers.get("User-Agent", None)
109 |
110 | @computed_field(return_type=str)
111 | @property
112 | def path(self) -> str:
113 | """str: returns URL path from request but replaces numbers in the path with :id"""
114 | return re.sub(r"/[0-9]+", "/:id", self.request.url.path)
115 |
116 | @computed_field(return_type=dict)
117 | @property
118 | def path_params(self) -> dict[str, Any] | None:
119 | """str: returns URL path from request but replaces numbers in the path with :id"""
120 | return self.request.path_params
121 |
122 | @computed_field(return_type=str)
123 | @property
124 | def params(self) -> str:
125 | """str: returns URL query params from request"""
126 | return self.request.url.query
127 |
128 | @computed_field(return_type=dict)
129 | @property
130 | def params_obj(self) -> dict:
131 | """returns URL query params and path params as key values from request"""
132 | params = dict(x.split("=", 1) for x in self.params.split("&") if "=" in x)
133 | if self.path_params:
134 | params = params | self.path_params
135 | try:
136 | # if bad strings make it past our validation than this will protect the log
137 | if "date_from" in params.keys():
138 | params["date_from_epoch"] = parse(params["date_from"]).timestamp()
139 | if "date_to" in params.keys():
140 | params["date_to_epoch"] = parse(params["date_to"]).timestamp()
141 | except Exception:
142 | pass
143 |
144 | return params
145 |
146 | @computed_field(return_type=list)
147 | @property
148 | def params_keys(self) -> list:
149 | """list: returns URL query params keys as list/array from request"""
150 | return [] if self.params_obj is None else list(self.params_obj.keys())
151 |
152 |
153 | class HTTPErrorLog(HTTPLog):
154 | """Log for HTTP 500.
155 |
156 | Inherits from HTTPLog
157 | """
158 |
159 | http_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR
160 |
161 |
162 | class UnprocessableEntityLog(HTTPLog):
163 | """Log for HTTP 422.
164 |
165 | Inherits from HTTPLog
166 | """
167 |
168 | http_code: int = status.HTTP_422_UNPROCESSABLE_ENTITY
169 | type: LogType = LogType.UNPROCESSABLE_ENTITY
170 |
171 |
172 | class TooManyRequestsLog(HTTPLog):
173 | """Log for HTTP 429.
174 |
175 | Inherits from HTTPLog
176 | """
177 |
178 | http_code: int = status.HTTP_429_TOO_MANY_REQUESTS
179 | type: LogType = LogType.TOO_MANY_REQUESTS
180 |
181 |
182 | class UnauthorizedLog(HTTPLog):
183 | """Log for HTTP 401.
184 |
185 | Inherits from HTTPLog
186 | """
187 |
188 | http_code: int = status.HTTP_401_UNAUTHORIZED
189 | type: LogType = LogType.UNAUTHORIZED
190 |
191 |
192 | class ModelValidationError(HTTPErrorLog):
193 | """Log for model validations
194 |
195 | Inherits from ErrorLog
196 | """
197 |
198 | type: LogType = LogType.VALIDATION_ERROR
199 |
200 |
201 | class RedisErrorLog(ErrorLog):
202 | detail: str
203 |
--------------------------------------------------------------------------------
/pages/register/index.js:
--------------------------------------------------------------------------------
1 | import './style.scss';
2 |
3 | import { zxcvbn, zxcvbnOptions } from '@zxcvbn-ts/core';
4 | import zxcvbnCommonPackage from '@zxcvbn-ts/language-common';
5 | import zxcvbnEnPackage from '@zxcvbn-ts/language-en';
6 |
7 | const options = {
8 | translations: zxcvbnEnPackage.translations,
9 | graphs: zxcvbnCommonPackage.adjacencyGraphs,
10 | dictionary: {
11 | ...zxcvbnCommonPackage.dictionary,
12 | ...zxcvbnEnPackage.dictionary,
13 | },
14 | };
15 |
16 | let passwordInputTimeout;
17 |
18 | const errorSvg =
19 | '';
20 | const checkSvg =
21 | '';
22 |
23 | zxcvbnOptions.setOptions(options);
24 |
25 | const nameInput = document.querySelector('.js-name-input');
26 | const emailInput = document.querySelector('.js-email-input');
27 | const passwordInput = document.querySelector('.js-password-input');
28 | const passwordConfirmInput = document.querySelector(
29 | '.js-password-confirm-input'
30 | );
31 | const passwordMatchWarning = document.querySelector(
32 | '.js-password-match-warning'
33 | );
34 | const submitBtn = document.querySelector('.js-submit-btn');
35 |
36 | let score;
37 |
38 | nameInput.addEventListener('input', () => {
39 | checkFormFieldsComplete();
40 | });
41 |
42 | emailInput.addEventListener('input', () => {
43 | checkFormFieldsComplete();
44 | });
45 |
46 | passwordInput.addEventListener('input', (e) => {
47 | clearTimeout(passwordInputTimeout);
48 | const result = zxcvbn(e.target.value);
49 | score = result.score;
50 | const strengthMessage = document.querySelector(
51 | '.js-strength-message'
52 | );
53 | strengthMessage.innerText = '';
54 | resetBars();
55 | // only show bars if password field has value
56 | if (e.target.value.length > 0) {
57 | setPasswordStrength(result);
58 | }
59 | if (passwordConfirmInput.value != '') {
60 | passwordMatchOnInput();
61 | }
62 | if (result.score < 3) {
63 | e.target.setCustomValidity(
64 | 'password not strong enough, please choose a stronger password.'
65 | );
66 | } else {
67 | e.target.setCustomValidity('');
68 | }
69 | if (e.target.value.length === 0) {
70 | e.target.setCustomValidity('');
71 | }
72 | checkFormFieldsComplete();
73 | passwordInputTimeout = setTimeout(
74 | () => e.target.reportValidity(),
75 | 1000
76 | );
77 | });
78 |
79 | let passwordMatchTimeout;
80 |
81 | passwordConfirmInput.addEventListener('input', () => {
82 | passwordMatchOnInput();
83 | checkFormFieldsComplete();
84 | });
85 |
86 | function resetBars() {
87 | const bars = document.querySelectorAll('.strength-meter__bar');
88 | for (const bar of bars) {
89 | bar.classList.remove(...bar.classList);
90 | bar.classList.add(`strength-meter__bar`);
91 | }
92 | }
93 |
94 | function passwordMatchOnInput() {
95 | clearTimeout(passwordMatchTimeout);
96 | passwordMatchTimeout = setTimeout(
97 | () =>
98 | checkPasswordsMatch(
99 | passwordInput.value,
100 | passwordConfirmInput.value
101 | ),
102 | 300
103 | );
104 | }
105 |
106 | function checkPasswordsMatch(password, confirmPassword) {
107 | while (passwordMatchWarning.firstChild) {
108 | passwordMatchWarning.removeChild(passwordMatchWarning.firstChild);
109 | }
110 | if (password != confirmPassword) {
111 | passwordMatchWarning.insertAdjacentHTML('afterbegin', errorSvg);
112 | passwordMatchWarning.innerText = 'Passwords do not match.';
113 | } else {
114 | passwordMatchWarning.innerText = '';
115 | }
116 | }
117 |
118 | let formfieldsCheckTimeout;
119 |
120 | function checkFormFieldsComplete() {
121 | clearTimeout(formfieldsCheckTimeout);
122 | formfieldsCheckTimeout = setTimeout(() => {
123 | if (
124 | nameInput.value != '' &&
125 | emailInput.value != '' &&
126 | passwordInput.value != '' &&
127 | passwordConfirmInput.value != '' &&
128 | passwordInput.value == passwordConfirmInput.value &&
129 | score >= 3
130 | ) {
131 | submitBtn.disabled = false;
132 | } else {
133 | submitBtn.disabled = true;
134 | }
135 | }, 300);
136 | }
137 |
138 | function setPasswordStrength(result) {
139 | let color;
140 | let message;
141 | let symbol;
142 |
143 | switch (result.score) {
144 | case 0:
145 | color = 'warning';
146 | symbol = errorSvg;
147 | message = `Very weak password`;
148 | break;
149 | case 1:
150 | color = 'warning';
151 | symbol = errorSvg;
152 | message = `Weak password`;
153 | break;
154 | case 2:
155 | color = 'alert';
156 | symbol = errorSvg;
157 | message = `Somewhat secure password`;
158 | break;
159 | case 3:
160 | color = 'ok';
161 | message = 'Strong password';
162 | symbol = checkSvg;
163 | break;
164 | case 4:
165 | color = 'ok';
166 | message = 'Very strong password';
167 | symbol = checkSvg;
168 | break;
169 | default:
170 | color = 'red';
171 | symbol = errorSvg;
172 | message = 'Weak password';
173 | }
174 | const strengthMessage = document.querySelector(
175 | '.js-strength-message'
176 | );
177 | const strengthWarning = document.querySelector(
178 | '.js-strength-warning'
179 | );
180 | while (strengthMessage.firstChild) {
181 | strengthMessage.removeChild(strengthMessage.firstChild);
182 | }
183 | strengthMessage.innerText = message;
184 | strengthMessage.insertAdjacentHTML('afterbegin', symbol);
185 | strengthWarning.innerText = result.feedback.warning;
186 | const bars = document.querySelectorAll('.strength-meter__bar');
187 | let barsArr = Array.prototype.slice.call(bars);
188 | barsArr = barsArr.splice(0, result.score + 1);
189 | for (const bar of barsArr) {
190 | bar.classList.add(`strength-meter__bar--${color}`);
191 | }
192 | }
193 |
--------------------------------------------------------------------------------
/openaq_api/v3/routers/latest.py:
--------------------------------------------------------------------------------
1 | from datetime import date, datetime
2 | import logging
3 | from typing import Annotated
4 |
5 | from fastapi import APIRouter, Depends, HTTPException, Path, Query
6 |
7 | from openaq_api.db import DB
8 | from openaq_api.v3.routers.locations import LocationPathQuery, fetch_locations
9 | from openaq_api.v3.routers.parameters import fetch_parameters
10 | from openaq_api.v3.models.queries import QueryBaseModel, QueryBuilder, Paging
11 | from openaq_api.v3.models.responses import LatestResponse
12 |
13 | logger = logging.getLogger("latest")
14 |
15 | router = APIRouter(
16 | prefix="/v3",
17 | tags=["v3"],
18 | include_in_schema=True,
19 | )
20 |
21 |
22 | class DatetimeMinQuery(QueryBaseModel):
23 | """Pydantic query model for the `datetime_min` query parameter
24 |
25 | Inherits from QueryBaseModel
26 |
27 | Attributes:
28 | datetime_min: date or datetime in ISO-8601 format to filter results to a mininum data
29 | """
30 |
31 | datetime_min: datetime | date | None = Query(
32 | None,
33 | description="Minimum datetime",
34 | examples=["2022-10-01T11:19:38-06:00", "2022-10-01"],
35 | )
36 |
37 | def where(self) -> str:
38 | """Generates SQL condition for filtering to datetime.
39 |
40 | Overrides the base QueryBaseModel `where` method
41 |
42 | If `datetime_min` is a `date` or `datetime` without a timezone a timezone
43 | is added as local timezone.
44 |
45 | Returns:
46 | string of WHERE clause if `datetime_min` is set
47 | """
48 | tz = self.map("timezone", "tzid")
49 | dt = self.map("datetime", "datetime_last")
50 |
51 | if self.datetime_min is None:
52 | return None
53 | elif isinstance(self.datetime_min, datetime):
54 | if self.datetime_min.tzinfo is None:
55 | return f"{dt} > (:datetime_min::timestamp AT TIME ZONE {tz})"
56 | else:
57 | return f"{dt} > :datetime_min"
58 | elif isinstance(self.datetime_min, date):
59 | return f"{dt} > (:datetime_min::timestamp AT TIME ZONE {tz})"
60 |
61 |
62 | class ParameterLatestPathQuery(QueryBaseModel):
63 | """Path query to filter results by parameters ID
64 |
65 | Inherits from QueryBaseModel
66 |
67 | Attributes:
68 | parameters_id: countries ID value
69 | """
70 |
71 | parameters_id: int = Path(
72 | ..., description="Limit the results to a specific parameters id", ge=1
73 | )
74 |
75 | def where(self) -> str:
76 | """Generates SQL condition for filtering to a single parameters_id
77 |
78 | Overrides the base QueryBaseModel `where` method
79 |
80 | Returns:
81 | string of WHERE clause
82 | """
83 | return "m.measurands_id = :parameters_id"
84 |
85 |
86 | class ParametersLatestQueries(ParameterLatestPathQuery, DatetimeMinQuery, Paging): ...
87 |
88 |
89 | @router.get(
90 | "/parameters/{parameters_id}/latest",
91 | response_model=LatestResponse,
92 | summary="",
93 | description="",
94 | )
95 | async def parameters_latest_get(
96 | parameters_latest: Annotated[
97 | ParametersLatestQueries, Depends(ParametersLatestQueries.depends())
98 | ],
99 | db: DB = Depends(),
100 | ):
101 | response = await fetch_latest(parameters_latest, db)
102 | if len(response.results) == 0:
103 | parameters_response = await fetch_parameters(parameters_latest, db)
104 | if len(parameters_response.results) == 0:
105 | raise HTTPException(status_code=404, detail="Parameter not found")
106 | return response
107 |
108 |
109 | class LocationLatestPathQuery(QueryBaseModel):
110 | """Path query to filter results by locations ID.
111 |
112 | Inherits from QueryBaseModel.
113 |
114 | Attributes:
115 | locations_id: locations ID value.
116 | """
117 |
118 | locations_id: int = Path(
119 | description="Limit the results to a specific location by id", ge=1
120 | )
121 |
122 | def where(self) -> str:
123 | """Generates SQL condition for filtering to a single locations_id
124 |
125 | Overrides the base QueryBaseModel `where` method
126 |
127 | Returns:
128 | string of WHERE clause
129 | """
130 | return "n.sensor_nodes_id = :locations_id"
131 |
132 |
133 | class LocationsLatestQueries(LocationLatestPathQuery, DatetimeMinQuery, Paging): ...
134 |
135 |
136 | @router.get(
137 | "/locations/{locations_id}/latest",
138 | response_model=LatestResponse,
139 | summary="Get a location's latest measurements",
140 | description="Providers a location's latest measurement values",
141 | )
142 | async def location_latest_get(
143 | locations_latest: Annotated[
144 | LocationsLatestQueries, Depends(LocationsLatestQueries.depends())
145 | ],
146 | db: DB = Depends(),
147 | ):
148 | response = await fetch_latest(locations_latest, db)
149 | if len(response.results) == 0:
150 | locations_response = await fetch_locations(
151 | LocationPathQuery(locations_id=locations_latest.locations_id), db
152 | )
153 | if len(locations_response.results) == 0:
154 | raise HTTPException(status_code=404, detail="Location not found")
155 | return response
156 |
157 |
158 | async def fetch_latest(query, db):
159 | query_builder = QueryBuilder(query)
160 | sql = f"""
161 | SELECT
162 | n.sensor_nodes_id AS locations_id
163 | ,s.sensors_id AS sensors_id
164 | ,get_datetime_object(r.datetime_last, t.tzid) as datetime
165 | ,r.value_latest AS value
166 | ,json_build_object(
167 | 'latitude', st_y(COALESCE(r.geom_latest, n.geom))
168 | ,'longitude', st_x(COALESCE(r.geom_latest, n.geom))
169 | ) AS coordinates
170 | {query_builder.total()}
171 | FROM
172 | sensors s
173 | JOIN
174 | sensor_systems sy ON (s.sensor_systems_id = sy.sensor_systems_id)
175 | JOIN
176 | sensor_nodes n ON (sy.sensor_nodes_id = n.sensor_nodes_id)
177 | JOIN
178 | timezones t ON (n.timezones_id = t.timezones_id)
179 | JOIN
180 | measurands m ON (s.measurands_id = m.measurands_id)
181 | INNER JOIN
182 | sensors_rollup r ON (s.sensors_id = r.sensors_id)
183 | {query_builder.where()}
184 | {query_builder.pagination()}
185 | """
186 | response = await db.fetchPage(sql, query_builder.params())
187 | return response
188 |
--------------------------------------------------------------------------------
/openaq_api/main.py:
--------------------------------------------------------------------------------
1 | from contextlib import asynccontextmanager
2 | import datetime
3 | import logging
4 | import time
5 | from os import environ
6 | from pathlib import Path
7 | from typing import Any
8 |
9 | import orjson
10 | from fastapi import FastAPI, Request, Depends
11 | from fastapi.encoders import jsonable_encoder
12 | from fastapi.exceptions import RequestValidationError
13 | from fastapi.middleware.cors import CORSMiddleware
14 | from fastapi.middleware.gzip import GZipMiddleware
15 | from fastapi.staticfiles import StaticFiles
16 | from mangum import Mangum
17 | from pydantic import BaseModel, ValidationError
18 | from starlette.responses import JSONResponse, RedirectResponse
19 |
20 | from openaq_api.db import db_pool
21 | from openaq_api.dependencies import check_api_key
22 | from openaq_api.middleware import (
23 | CacheControlMiddleware,
24 | LoggingMiddleware,
25 | )
26 | from openaq_api.models.logging import InfrastructureErrorLog
27 |
28 | from openaq_api.settings import settings
29 |
30 | # V3 routers
31 | from openaq_api.v3.routers import (
32 | auth,
33 | countries,
34 | instruments,
35 | locations,
36 | manufacturers,
37 | measurements,
38 | owners,
39 | parameters,
40 | providers,
41 | sensors,
42 | tiles,
43 | licenses,
44 | latest,
45 | flags,
46 | )
47 |
48 | logging.basicConfig(
49 | format="[%(asctime)s] %(levelname)s [%(name)s:%(lineno)s] %(message)s",
50 | level=settings.LOG_LEVEL.upper(),
51 | force=True,
52 | )
53 | # When debuging we dont want to debug these libraries
54 | logging.getLogger("boto3").setLevel(logging.WARNING)
55 | logging.getLogger("botocore").setLevel(logging.WARNING)
56 | logging.getLogger("urllib3").setLevel(logging.WARNING)
57 | logging.getLogger("aiocache").setLevel(logging.WARNING)
58 | logging.getLogger("uvicorn").setLevel(logging.WARNING)
59 | logging.getLogger("mangum").setLevel(logging.WARNING)
60 |
61 | logger = logging.getLogger("main")
62 |
63 | # Make sure that we are using UTC timezone
64 | # this is required because the datetime class will automatically
65 | # add the env timezone when passing the value to a sql query parameter
66 | environ["TZ"] = "UTC"
67 |
68 |
69 | # this is instead of importing settings elsewhere
70 | if settings.DOMAIN_NAME is not None:
71 | environ["DOMAIN_NAME"] = settings.DOMAIN_NAME
72 |
73 |
74 | def default(obj):
75 | if isinstance(obj, float):
76 | return round(obj, 5)
77 | if isinstance(obj, datetime.datetime):
78 | return obj.strptime("%Y-%m-%dT%H:%M:%SZ")
79 | if isinstance(obj, datetime.date):
80 | return obj.strptime("%Y-%m-%d")
81 |
82 |
83 | class ORJSONResponse(JSONResponse):
84 | def render(self, content: Any) -> bytes:
85 | # logger.debug(f'rendering content {content}')
86 | return orjson.dumps(content, default=default)
87 |
88 |
89 | @asynccontextmanager
90 | async def lifespan(app: FastAPI):
91 | if not hasattr(app.state, "pool"):
92 | logger.debug("initializing connection pool")
93 | app.state.pool = await db_pool(None)
94 | logger.debug("Connection pool established")
95 |
96 | if hasattr(app.state, "counter"):
97 | app.state.counter += 1
98 | else:
99 | app.state.counter = 0
100 |
101 | yield
102 | if hasattr(app.state, "pool") and not settings.USE_SHARED_POOL:
103 | logger.debug("Closing connection")
104 | await app.state.pool.close()
105 | delattr(app.state, "pool")
106 | logger.debug("Connection closed")
107 |
108 |
109 | app = FastAPI(
110 | title="OpenAQ",
111 | description="OpenAQ API",
112 | version="3.0.0",
113 | default_response_class=ORJSONResponse,
114 | dependencies=[Depends(check_api_key)],
115 | docs_url="/docs",
116 | lifespan=lifespan,
117 | )
118 |
119 |
120 | app.redis = None
121 | if settings.RATE_LIMITING is True:
122 | if settings.RATE_LIMITING:
123 | logger.debug("Connecting to redis")
124 | from redis.asyncio.cluster import RedisCluster
125 |
126 | try:
127 | redis_client = RedisCluster(
128 | host=settings.REDIS_HOST,
129 | port=settings.REDIS_PORT,
130 | decode_responses=True,
131 | socket_timeout=5,
132 | )
133 | # attach to the app so it can be retrieved via the request
134 | app.redis = redis_client
135 | logger.debug("Redis connected")
136 |
137 | except Exception as e:
138 | logging.error(
139 | InfrastructureErrorLog(detail=f"failed to connect to redis: {e}")
140 | )
141 |
142 |
143 | app.add_middleware(
144 | CORSMiddleware,
145 | allow_origins=["*"],
146 | allow_credentials=True,
147 | allow_methods=["*"],
148 | allow_headers=["*"],
149 | )
150 | app.add_middleware(CacheControlMiddleware, cachecontrol="public, max-age=900")
151 | app.add_middleware(LoggingMiddleware)
152 | app.add_middleware(GZipMiddleware, minimum_size=1000)
153 |
154 |
155 | class OpenAQValidationResponseDetail(BaseModel):
156 | loc: list[str] | None = None
157 | msg: str | None = None
158 | type: str | None = None
159 |
160 |
161 | class OpenAQValidationResponse(BaseModel):
162 | detail: list[OpenAQValidationResponseDetail] | None = None
163 |
164 |
165 | @app.exception_handler(RequestValidationError)
166 | async def openaq_request_validation_exception_handler(
167 | request: Request, exc: RequestValidationError
168 | ):
169 | return ORJSONResponse(status_code=422, content=jsonable_encoder(str(exc)))
170 |
171 |
172 | @app.exception_handler(ValidationError)
173 | async def openaq_exception_handler(request: Request, exc: ValidationError):
174 | return ORJSONResponse(status_code=422, content=jsonable_encoder(str(exc)))
175 |
176 |
177 | @app.get("/ping", include_in_schema=False)
178 | def pong():
179 | """
180 | health check.
181 | This will let the user know that the service is operational.
182 | And this path operation will:
183 | * show a lifesign
184 | """
185 | return {"ping": "pong!"}
186 |
187 |
188 | @app.get("/favicon.ico", include_in_schema=False)
189 | def favico():
190 | return RedirectResponse("https://openaq.org/assets/graphics/meta/favicon.png")
191 |
192 |
193 | # v3
194 | app.include_router(auth.router)
195 | app.include_router(instruments.router)
196 | app.include_router(locations.router)
197 | app.include_router(licenses.router)
198 | app.include_router(parameters.router)
199 | app.include_router(tiles.router)
200 | app.include_router(countries.router)
201 | app.include_router(manufacturers.router)
202 | app.include_router(measurements.router)
203 | app.include_router(owners.router)
204 | app.include_router(providers.router)
205 | app.include_router(sensors.router)
206 | app.include_router(latest.router)
207 | app.include_router(flags.router)
208 |
209 |
210 | static_dir = Path.joinpath(Path(__file__).resolve().parent, "static")
211 |
212 |
213 | app.mount("/", StaticFiles(directory=str(static_dir), html=True))
214 |
215 |
216 | def handler(event, context):
217 | asgi_handler = Mangum(app)
218 | return asgi_handler(event, context)
219 |
220 |
221 | def run():
222 | attempts = 0
223 | while attempts < 10:
224 | try:
225 | import uvicorn
226 |
227 | uvicorn.run(
228 | "main:app",
229 | host="0.0.0.0",
230 | port=8888,
231 | reload=True,
232 | )
233 | except Exception:
234 | attempts += 1
235 | logger.debug("waiting for database to start")
236 | time.sleep(3)
237 | pass
238 |
239 |
240 | if __name__ == "__main__":
241 | run()
242 |
--------------------------------------------------------------------------------
/cdk/stacks/waf_rules.py:
--------------------------------------------------------------------------------
1 | from typing import List
2 | from aws_cdk.aws_wafv2 import CfnWebACL, CfnIPSet
3 | from constructs import Construct
4 |
5 |
6 | custom_response_bodies = {
7 | "ForbiddenMessage": CfnWebACL.CustomResponseBodyProperty(
8 | content='{"message": "Forbidden. violation of rate limit policy. Contact dev@openaq.org"}',
9 | content_type="APPLICATION_JSON",
10 | ),
11 | "UnauthorizedMessage": CfnWebACL.CustomResponseBodyProperty(
12 | content='{"message": "Unauthorized. A valid API key must be provided in the X-API-Key header."}',
13 | content_type="APPLICATION_JSON",
14 | ),
15 | "GoneMessage": CfnWebACL.CustomResponseBodyProperty(
16 | content='{"message": "Gone. Version 1 and Version 2 API endpoints are retired and no longer available. Please migrate to Version 3 endpoints."}',
17 | content_type="APPLICATION_JSON",
18 | ),
19 | }
20 |
21 | amazon_ip_reputation_list = CfnWebACL.RuleProperty(
22 | name="AWS-AWSManagedRulesAmazonIpReputationList",
23 | priority=0,
24 | statement=CfnWebACL.StatementProperty(
25 | managed_rule_group_statement=CfnWebACL.ManagedRuleGroupStatementProperty(
26 | vendor_name="AWS", name="AWSManagedRulesAmazonIpReputationList"
27 | )
28 | ),
29 | override_action=CfnWebACL.OverrideActionProperty(none={}),
30 | visibility_config=CfnWebACL.VisibilityConfigProperty(
31 | sampled_requests_enabled=True,
32 | cloud_watch_metrics_enabled=True,
33 | metric_name="AWS-AWSManagedRulesAmazonIpReputationList",
34 | ),
35 | )
36 |
37 | known_bad_inputs_rule_set = CfnWebACL.RuleProperty(
38 | name="AWS-AWSManagedRulesKnownBadInputsRuleSet",
39 | priority=1,
40 | statement=CfnWebACL.StatementProperty(
41 | managed_rule_group_statement=CfnWebACL.ManagedRuleGroupStatementProperty(
42 | vendor_name="AWS", name="AWSManagedRulesKnownBadInputsRuleSet"
43 | )
44 | ),
45 | override_action=CfnWebACL.OverrideActionProperty(none={}),
46 | visibility_config=CfnWebACL.VisibilityConfigProperty(
47 | sampled_requests_enabled=True,
48 | cloud_watch_metrics_enabled=True,
49 | metric_name="AWS-AWSManagedRulesKnownBadInputsRuleSet",
50 | ),
51 | )
52 |
53 | api_key_header_rule = CfnWebACL.RuleProperty(
54 | name="CheckXApiKeyHeader",
55 | priority=2,
56 | action=CfnWebACL.RuleActionProperty(
57 | block=CfnWebACL.BlockActionProperty(
58 | custom_response=CfnWebACL.CustomResponseProperty(
59 | response_code=401, custom_response_body_key="UnauthorizedMessage"
60 | )
61 | )
62 | ),
63 | statement=CfnWebACL.StatementProperty(
64 | and_statement=CfnWebACL.AndStatementProperty(
65 | statements=[
66 | CfnWebACL.StatementProperty(
67 | not_statement=CfnWebACL.NotStatementProperty(
68 | statement=CfnWebACL.StatementProperty(
69 | size_constraint_statement=CfnWebACL.SizeConstraintStatementProperty(
70 | field_to_match=CfnWebACL.FieldToMatchProperty(
71 | single_header={"Name": "x-api-key"}
72 | ),
73 | comparison_operator="GT",
74 | size=0,
75 | text_transformations=[
76 | CfnWebACL.TextTransformationProperty(
77 | priority=0, type="NONE"
78 | )
79 | ],
80 | )
81 | )
82 | )
83 | ),
84 | CfnWebACL.StatementProperty(
85 | byte_match_statement=CfnWebACL.ByteMatchStatementProperty(
86 | search_string="/v3/",
87 | field_to_match=CfnWebACL.FieldToMatchProperty(uri_path={}),
88 | text_transformations=[
89 | CfnWebACL.TextTransformationProperty(
90 | priority=0, type="NONE"
91 | )
92 | ],
93 | positional_constraint="CONTAINS",
94 | )
95 | ),
96 | ]
97 | )
98 | ),
99 | visibility_config=CfnWebACL.VisibilityConfigProperty(
100 | sampled_requests_enabled=True,
101 | cloud_watch_metrics_enabled=True,
102 | metric_name="CheckXApiKeyHeader",
103 | ),
104 | )
105 |
106 | retired_endpoints_rule = CfnWebACL.RuleProperty(
107 | name="retiredVersionsEndpoints",
108 | priority=4,
109 | action=CfnWebACL.RuleActionProperty(
110 | block=CfnWebACL.BlockActionProperty(
111 | custom_response=CfnWebACL.CustomResponseProperty(
112 | response_code=410, custom_response_body_key="GoneMessage"
113 | )
114 | )
115 | ),
116 | statement=CfnWebACL.StatementProperty(
117 | or_statement=CfnWebACL.OrStatementProperty(
118 | statements=[
119 | CfnWebACL.StatementProperty(
120 | byte_match_statement=CfnWebACL.ByteMatchStatementProperty(
121 | search_string="/v1/",
122 | field_to_match=CfnWebACL.FieldToMatchProperty(uri_path={}),
123 | text_transformations=[
124 | CfnWebACL.TextTransformationProperty(
125 | priority=0, type="NONE"
126 | )
127 | ],
128 | positional_constraint="CONTAINS",
129 | )
130 | ),
131 | CfnWebACL.StatementProperty(
132 | byte_match_statement=CfnWebACL.ByteMatchStatementProperty(
133 | search_string="/v2/",
134 | field_to_match=CfnWebACL.FieldToMatchProperty(uri_path={}),
135 | text_transformations=[
136 | CfnWebACL.TextTransformationProperty(
137 | priority=0, type="NONE"
138 | )
139 | ],
140 | positional_constraint="CONTAINS",
141 | )
142 | ),
143 | ]
144 | )
145 | ),
146 | visibility_config=CfnWebACL.VisibilityConfigProperty(
147 | sampled_requests_enabled=True,
148 | cloud_watch_metrics_enabled=True,
149 | metric_name="retiredVersionsEndpoints",
150 | ),
151 | )
152 |
153 |
154 | def ip_rate_limiter(
155 | limit: int, evaluation_window_sec: int = 60
156 | ) -> CfnWebACL.RuleProperty:
157 | return CfnWebACL.RuleProperty(
158 | name="IPRateLimiter",
159 | priority=5,
160 | statement=CfnWebACL.StatementProperty(
161 | rate_based_statement=CfnWebACL.RateBasedStatementProperty(
162 | aggregate_key_type="IP",
163 | evaluation_window_sec=evaluation_window_sec,
164 | limit=limit,
165 | )
166 | ),
167 | action=CfnWebACL.RuleActionProperty(block={}),
168 | visibility_config=CfnWebACL.VisibilityConfigProperty(
169 | sampled_requests_enabled=True,
170 | cloud_watch_metrics_enabled=True,
171 | metric_name="IpRateLimiter",
172 | ),
173 | )
174 |
175 |
176 | def ip_block_rule(stack: Construct, ips: List[str]) -> CfnWebACL.RuleProperty:
177 | ip_set = CfnIPSet(
178 | stack,
179 | "waf_ip_block_set",
180 | addresses=ips,
181 | ip_address_version="IPV4",
182 | scope="CLOUDFRONT",
183 | description="Set of IPs to specifically block to prevent abuse",
184 | name="OpenAQAPIWAFIPBlockList",
185 | )
186 |
187 | return CfnWebACL.RuleProperty(
188 | name="IpBlockRule",
189 | priority=3,
190 | statement=CfnWebACL.StatementProperty(
191 | ip_set_reference_statement=CfnWebACL.IPSetReferenceStatementProperty(
192 | arn=ip_set.attr_arn
193 | )
194 | ),
195 | action=CfnWebACL.RuleActionProperty(
196 | block=CfnWebACL.BlockActionProperty(
197 | custom_response=CfnWebACL.CustomResponseProperty(
198 | response_code=403, custom_response_body_key="ForbiddenMessage"
199 | )
200 | )
201 | ),
202 | visibility_config=CfnWebACL.VisibilityConfigProperty(
203 | sampled_requests_enabled=True,
204 | cloud_watch_metrics_enabled=True,
205 | metric_name="IpBlockRule",
206 | ),
207 | )
208 |
--------------------------------------------------------------------------------
/openaq_api/static/openaq-logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
56 |
--------------------------------------------------------------------------------