giswqs commited on
Commit
4d108d9
·
unverified ·
1 Parent(s): bef458e

Enhance Dockerfile and main.py for security hardening and input validation (#10)

Browse files

- Added user permissions and health check in Dockerfile
- Implemented input validation for asset IDs, bounding boxes, and visualization parameters in main.py
- Introduced environment variable parsing for allowed origins and hosts
- Improved error handling and request size enforcement in FastAPI application
- Updated README with new deployment settings and validation features

Files changed (4) hide show
  1. .dockerignore +10 -0
  2. Dockerfile +12 -2
  3. README.md +17 -3
  4. main.py +420 -78
.dockerignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .gitignore
3
+ .pre-commit-config.yaml
4
+ .claude
5
+ __pycache__
6
+ *.py[cod]
7
+ *.ipynb
8
+ .env
9
+ *.pem
10
+ *.key
Dockerfile CHANGED
@@ -1,14 +1,24 @@
1
  FROM python:3.12-slim
2
 
3
- RUN apt-get update && apt-get install -y curl git npm && rm -rf /var/lib/apt/lists/*
 
 
4
 
5
  WORKDIR /app
6
 
7
  COPY requirements.txt .
 
 
8
  COPY main.py .
9
 
10
- RUN pip install --no-cache-dir -r requirements.txt
 
 
 
11
 
12
  EXPOSE 7865
13
 
 
 
 
14
  CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7865"]
 
1
  FROM python:3.12-slim
2
 
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ PIP_NO_CACHE_DIR=1
6
 
7
  WORKDIR /app
8
 
9
  COPY requirements.txt .
10
+ RUN pip install --no-cache-dir -r requirements.txt
11
+
12
  COPY main.py .
13
 
14
+ RUN useradd --create-home --shell /usr/sbin/nologin appuser && \
15
+ chown -R appuser:appuser /app
16
+
17
+ USER appuser
18
 
19
  EXPOSE 7865
20
 
21
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
22
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7865/healthz', timeout=3).read()" || exit 1
23
+
24
  CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7865"]
README.md CHANGED
@@ -20,6 +20,7 @@ A FastAPI service that generates tile URLs for Google Earth Engine assets, suita
20
  - Optional date range filtering for ImageCollections
21
  - Optional bounding box filtering for spatial subsetting
22
  - Customizable visualization parameters
 
23
  - REST API and web UI (Gradio)
24
  - FastAPI auto-generated documentation
25
 
@@ -51,6 +52,14 @@ pip install -r requirements.txt
51
  export EARTHENGINE_TOKEN="your_token_here"
52
  ```
53
 
 
 
 
 
 
 
 
 
54
  ## Running the App
55
 
56
  ### Local Development
@@ -63,12 +72,13 @@ uvicorn main:app --host 0.0.0.0 --port 7865 --reload
63
 
64
  ```bash
65
  docker build -t ee-tile-request .
66
- docker run -p 7865:7865 -e EE_SERVICE_ACCOUNT ee-tile-request
67
  ```
68
 
69
  ### Access Points
70
 
71
  - **Web UI**: http://localhost:7865
 
72
  - **API Documentation**: http://localhost:7865/docs
73
  - **Tile Endpoint**: POST http://localhost:7865/tile
74
  - **JRC Water Stats Endpoint**: POST http://localhost:7865/jrc-water-stats
@@ -83,7 +93,7 @@ docker run -p 7865:7865 -e EE_SERVICE_ACCOUNT ee-tile-request
83
 
84
  | Parameter | Type | Required | Description |
85
  | ------------ | ------ | -------- | ----------------------------------------------------------------- |
86
- | `asset_id` | string | Yes | Earth Engine asset ID (e.g., "USGS/SRTMGL1_003") or ee expression |
87
  | `vis_params` | object | No | Visualization parameters (min, max, palette, bands, etc.) |
88
  | `start_date` | string | No | Start date for filtering (format: "YYYY-MM-DD") |
89
  | `end_date` | string | No | End date for filtering (format: "YYYY-MM-DD") |
@@ -183,7 +193,7 @@ Computes JRC monthly water history and water occurrence statistics for a given b
183
 
184
  | Parameter | Type | Required | Default | Description |
185
  | ------------- | ------- | -------- | ------------ | -------------------------------------------------- |
186
- | `bbox` | array | Yes | | Bounding box [west, south, east, north] in degrees |
187
  | `scale` | number | No | 30 | Scale in meters for computation |
188
  | `start_date` | string | No | "1984-03-16" | Start date (format: "YYYY-MM-DD") |
189
  | `end_date` | string | No | today | End date (format: "YYYY-MM-DD") |
@@ -291,6 +301,10 @@ Access the web interface at http://localhost:7865 to:
291
 
292
  - Date filtering only works with ImageCollections
293
  - Bounding box format: `[west, south, east, north]` in WGS84 degrees
 
 
 
 
294
  - All filtering parameters are optional and backward compatible
295
  - Check the FastAPI docs at `/docs` for interactive API testing
296
 
 
20
  - Optional date range filtering for ImageCollections
21
  - Optional bounding box filtering for spatial subsetting
22
  - Customizable visualization parameters
23
+ - Validates public API inputs before Earth Engine requests
24
  - REST API and web UI (Gradio)
25
  - FastAPI auto-generated documentation
26
 
 
52
  export EARTHENGINE_TOKEN="your_token_here"
53
  ```
54
 
55
+ Optional deployment settings:
56
+
57
+ ```bash
58
+ export ALLOWED_ORIGINS="https://ee.opengeos.org"
59
+ export ALLOWED_HOSTS="ee.opengeos.org,localhost,127.0.0.1"
60
+ export MAX_REQUEST_BYTES="1048576"
61
+ ```
62
+
63
  ## Running the App
64
 
65
  ### Local Development
 
72
 
73
  ```bash
74
  docker build -t ee-tile-request .
75
+ docker run -p 7865:7865 -e EE_SERVICE_ACCOUNT="$EE_SERVICE_ACCOUNT" ee-tile-request
76
  ```
77
 
78
  ### Access Points
79
 
80
  - **Web UI**: http://localhost:7865
81
+ - **Cloudflare Tunnel Web UI**: https://ee.opengeos.org
82
  - **API Documentation**: http://localhost:7865/docs
83
  - **Tile Endpoint**: POST http://localhost:7865/tile
84
  - **JRC Water Stats Endpoint**: POST http://localhost:7865/jrc-water-stats
 
93
 
94
  | Parameter | Type | Required | Description |
95
  | ------------ | ------ | -------- | ----------------------------------------------------------------- |
96
+ | `asset_id` | string | Yes | Earth Engine asset ID or supported ee constructor expression |
97
  | `vis_params` | object | No | Visualization parameters (min, max, palette, bands, etc.) |
98
  | `start_date` | string | No | Start date for filtering (format: "YYYY-MM-DD") |
99
  | `end_date` | string | No | End date for filtering (format: "YYYY-MM-DD") |
 
193
 
194
  | Parameter | Type | Required | Default | Description |
195
  | ------------- | ------- | -------- | ------------ | -------------------------------------------------- |
196
+ | `bbox` | array | Yes | N/A | Bounding box [west, south, east, north] in degrees |
197
  | `scale` | number | No | 30 | Scale in meters for computation |
198
  | `start_date` | string | No | "1984-03-16" | Start date (format: "YYYY-MM-DD") |
199
  | `end_date` | string | No | today | End date (format: "YYYY-MM-DD") |
 
301
 
302
  - Date filtering only works with ImageCollections
303
  - Bounding box format: `[west, south, east, north]` in WGS84 degrees
304
+ - `asset_id` can be a literal Earth Engine asset ID or one of `ee.Image("...")`, `ee.ImageCollection("...")`, or `ee.FeatureCollection("...")`.
305
+ - Arbitrary Python expressions are rejected.
306
+ - Browser CORS is limited to `ALLOWED_ORIGINS`; set it to the domains that should call the API.
307
+ - Host headers are limited to `ALLOWED_HOSTS`; include `ee.opengeos.org` when serving through the Cloudflare tunnel.
308
  - All filtering parameters are optional and backward compatible
309
  - Check the FastAPI docs at `/docs` for interactive API testing
310
 
main.py CHANGED
@@ -1,14 +1,39 @@
1
- import os
2
- import json
3
  import datetime
 
 
 
 
 
 
 
4
  import ee
5
- import geemap
6
  import gradio as gr
7
- from fastapi import FastAPI, HTTPException
8
  from pydantic import BaseModel
9
  from geemap.ee_tile_layers import _get_tile_url_format, _validate_palette
10
  from starlette.middleware.cors import CORSMiddleware
11
- from typing import Any, Dict, List, Optional, Tuple, Union
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  # # Earth Engine auth
14
  # if "EARTHENGINE_TOKEN" not in os.environ:
@@ -44,7 +69,7 @@ def ee_initialize(
44
  project: Optional[str] = None,
45
  **kwargs: Any,
46
  ) -> None:
47
- """Authenticates Earth Engine and initialize an Earth Engine session
48
 
49
  Args:
50
  token_name (str, optional): The name of the Earth Engine token.
@@ -100,22 +125,16 @@ def ee_initialize(
100
  ee.Initialize(credentials=credentials, **kwargs)
101
  return
102
 
 
 
 
 
 
 
103
  if auth_args is None:
104
  auth_args = {}
105
 
106
- if project is None:
107
- kwargs["project"] = get_env_var("EE_PROJECT_ID")
108
- else:
109
- kwargs["project"] = project
110
-
111
- if auth_mode is None:
112
- # pylint: disable-next=protected-access
113
- if ee.data._get_state().credentials is None:
114
- ee.Authenticate()
115
- ee.Initialize(**kwargs)
116
- return
117
- else:
118
- auth_mode = "notebook"
119
 
120
  auth_args["auth_mode"] = auth_mode
121
 
@@ -124,21 +143,291 @@ def ee_initialize(
124
 
125
 
126
  # ---- Shared Tile Logic ----
127
- def get_tile(asset_id, vis_params=None, start_date=None, end_date=None, bbox=None):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  try:
129
- if asset_id.startswith("ee."):
130
- ee_object = eval(asset_id)
131
- else:
132
- data_dict = ee.data.getAsset(asset_id)
133
- data_type = data_dict["type"]
134
- if data_type == "IMAGE":
135
- ee_object = ee.Image(asset_id)
136
- elif data_type == "IMAGE_COLLECTION":
137
- ee_object = ee.ImageCollection(asset_id)
138
- elif data_type in ["TABLE", "TABLE_COLLECTION"]:
139
- ee_object = ee.FeatureCollection(asset_id)
140
- else:
141
- raise ValueError(f"Unsupported data type: {data_type}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
 
143
  # Apply date range filtering for ImageCollections
144
  if start_date or end_date:
@@ -156,10 +445,6 @@ def get_tile(asset_id, vis_params=None, start_date=None, end_date=None, bbox=Non
156
 
157
  # Apply bounding box filtering
158
  if bbox:
159
- if len(bbox) != 4:
160
- raise ValueError(
161
- "bbox must be a list of 4 values: [west, south, east, north]"
162
- )
163
  geometry = ee.Geometry.BBox(*bbox)
164
  if isinstance(ee_object, ee.ImageCollection):
165
  ee_object = ee_object.filterBounds(geometry)
@@ -172,41 +457,68 @@ def get_tile(asset_id, vis_params=None, start_date=None, end_date=None, bbox=Non
172
  f"Bounding box filtering not supported for {type(ee_object)}"
173
  )
174
 
175
- if vis_params is None:
176
- vis_params = {}
177
- if isinstance(vis_params, str):
178
- if len(vis_params) == 0:
179
- vis_params = "{}"
180
- if vis_params.startswith("{") and vis_params.endswith("}"):
181
- vis_params = json.loads(vis_params)
182
- else:
183
- raise ValueError(f"Unsupported vis_params type: {type(vis_params)}")
184
- elif isinstance(vis_params, dict):
185
- pass
186
- else:
187
- raise ValueError(f"Unsupported vis_params type: {type(vis_params)}")
188
-
189
- if "palette" in vis_params:
190
- vis_params["palette"] = _validate_palette(vis_params["palette"])
191
-
192
  url = _get_tile_url_format(ee_object, vis_params)
193
  return url
194
  except Exception as e:
195
  return f"Error: {str(e)}"
196
 
197
 
198
- ee_initialize()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
 
200
- # ---- FastAPI ----
201
- app = FastAPI()
202
  app.add_middleware(
203
- CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
 
 
 
204
  )
 
205
 
206
 
207
  class TileRequest(BaseModel):
 
 
208
  asset_id: str
209
- vis_params: dict | None = None
210
  start_date: str | None = None
211
  end_date: str | None = None
212
  bbox: list[float] | None = None # [west, south, east, north]
@@ -216,17 +528,35 @@ class JRCWaterStatsRequest(BaseModel):
216
  """Request model for JRC water statistics endpoint."""
217
 
218
  bbox: list[float] # [west, south, east, north]
219
- scale: float | None = 30
220
- start_date: str | None = "1984-03-16"
221
  end_date: str | None = None # defaults to today
222
- start_month: int | None = 1
223
- end_month: int | None = 12
224
- frequency: str | None = "year" # "month" or "year"
225
- denominator: float | None = 10000.0 # m² to hectares
 
 
 
 
 
 
 
 
 
 
226
 
227
 
228
  @app.post("/tile")
229
- def get_tile_api(req: TileRequest):
 
 
 
 
 
 
 
 
230
  result = get_tile(
231
  req.asset_id, req.vis_params, req.start_date, req.end_date, req.bbox
232
  )
@@ -236,7 +566,7 @@ def get_tile_api(req: TileRequest):
236
 
237
 
238
  @app.post("/jrc-water-stats")
239
- def get_jrc_water_stats(req: JRCWaterStatsRequest):
240
  """Compute JRC monthly water history and water occurrence statistics.
241
 
242
  Args:
@@ -246,15 +576,8 @@ def get_jrc_water_stats(req: JRCWaterStatsRequest):
246
  dict: Monthly history data and water occurrence statistics.
247
  """
248
  try:
249
- if len(req.bbox) != 4:
250
- raise ValueError(
251
- "bbox must be a list of 4 values: [west, south, east, north]"
252
- )
253
- if req.frequency not in ("month", "year"):
254
- raise ValueError("frequency must be 'month' or 'year'")
255
-
256
- region = ee.Geometry.BBox(*req.bbox)
257
- end_date = req.end_date or datetime.date.today().strftime("%Y-%m-%d")
258
 
259
  # Compute monthly water history from JRC MonthlyHistory
260
  collection = ee.ImageCollection("JRC/GSW1_4/MonthlyHistory")
@@ -265,6 +588,14 @@ def get_jrc_water_stats(req: JRCWaterStatsRequest):
265
  )
266
 
267
  def cal_area(img):
 
 
 
 
 
 
 
 
268
  pixel_area = img.multiply(ee.Image.pixelArea()).divide(req.denominator)
269
  img_area = pixel_area.reduceRegion(
270
  geometry=region,
@@ -342,7 +673,7 @@ def get_jrc_water_stats(req: JRCWaterStatsRequest):
342
  "histogram": histogram,
343
  },
344
  "parameters": {
345
- "bbox": req.bbox,
346
  "scale": req.scale,
347
  "start_date": req.start_date,
348
  "end_date": end_date,
@@ -357,7 +688,18 @@ def get_jrc_water_stats(req: JRCWaterStatsRequest):
357
 
358
  # ---- Gradio UI ----
359
  def get_tile_gradio(asset_id, vis_params, start_date, end_date, bbox_str):
360
- """Wrapper for Gradio that converts string inputs to proper types."""
 
 
 
 
 
 
 
 
 
 
 
361
  # Convert empty strings to None
362
  start_date = start_date.strip() if start_date and start_date.strip() else None
363
  end_date = end_date.strip() if end_date and end_date.strip() else None
 
1
+ import ast
 
2
  import datetime
3
+ import json
4
+ import os
5
+ import re
6
+ from collections.abc import AsyncIterator
7
+ from contextlib import asynccontextmanager
8
+ from typing import Any, Dict, Optional
9
+
10
  import ee
 
11
  import gradio as gr
12
+ from fastapi import FastAPI, HTTPException, Request
13
  from pydantic import BaseModel
14
  from geemap.ee_tile_layers import _get_tile_url_format, _validate_palette
15
  from starlette.middleware.cors import CORSMiddleware
16
+ from starlette.middleware.trustedhost import TrustedHostMiddleware
17
+ from starlette.responses import JSONResponse
18
+
19
+ MAX_REQUEST_BYTES = int(os.environ.get("MAX_REQUEST_BYTES", "1048576"))
20
+ MAX_ASSET_ID_LENGTH = 256
21
+ MAX_VIS_PARAMS_BYTES = 4096
22
+ MIN_SCALE_METERS = 1
23
+ MAX_SCALE_METERS = 10000
24
+ MIN_DENOMINATOR = 1
25
+ MAX_DENOMINATOR = 1e12
26
+ MAX_BBOX_AREA_DEGREES = 2500
27
+ ASSET_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_./:-]{0,255}$")
28
+ SAFE_EE_CONSTRUCTORS = {
29
+ "FeatureCollection": ee.FeatureCollection,
30
+ "Image": ee.Image,
31
+ "ImageCollection": ee.ImageCollection,
32
+ }
33
+ DEFAULT_ALLOWED_ORIGINS = (
34
+ "https://ee.opengeos.org,http://localhost:7865,http://127.0.0.1:7865"
35
+ )
36
+ DEFAULT_ALLOWED_HOSTS = "ee.opengeos.org,localhost,127.0.0.1,0.0.0.0"
37
 
38
  # # Earth Engine auth
39
  # if "EARTHENGINE_TOKEN" not in os.environ:
 
69
  project: Optional[str] = None,
70
  **kwargs: Any,
71
  ) -> None:
72
+ """Authenticates Earth Engine and initialize an Earth Engine session.
73
 
74
  Args:
75
  token_name (str, optional): The name of the Earth Engine token.
 
125
  ee.Initialize(credentials=credentials, **kwargs)
126
  return
127
 
128
+ if auth_mode is None:
129
+ raise RuntimeError(
130
+ "Earth Engine credentials are not configured. Set EE_SERVICE_ACCOUNT "
131
+ "or EARTHENGINE_TOKEN."
132
+ )
133
+
134
  if auth_args is None:
135
  auth_args = {}
136
 
137
+ kwargs["project"] = project or get_env_var("EE_PROJECT_ID")
 
 
 
 
 
 
 
 
 
 
 
 
138
 
139
  auth_args["auth_mode"] = auth_mode
140
 
 
143
 
144
 
145
  # ---- Shared Tile Logic ----
146
+ def parse_allowed_origins() -> list[str]:
147
+ """Parses CORS origins from the ALLOWED_ORIGINS environment variable.
148
+
149
+ Returns:
150
+ list[str]: Allowed origins for browser clients.
151
+ """
152
+ origins = os.environ.get("ALLOWED_ORIGINS", DEFAULT_ALLOWED_ORIGINS)
153
+ return [origin.strip() for origin in origins.split(",") if origin.strip()]
154
+
155
+
156
+ def parse_allowed_hosts() -> list[str]:
157
+ """Parses trusted hosts from the ALLOWED_HOSTS environment variable.
158
+
159
+ Returns:
160
+ list[str]: Host names accepted by the application.
161
+ """
162
+ hosts = os.environ.get("ALLOWED_HOSTS", DEFAULT_ALLOWED_HOSTS)
163
+ return [host.strip() for host in hosts.split(",") if host.strip()]
164
+
165
+
166
+ def validate_date_range(
167
+ start_date: Optional[str], end_date: Optional[str]
168
+ ) -> tuple[Optional[str], Optional[str]]:
169
+ """Validates optional date strings and chronological order.
170
+
171
+ Args:
172
+ start_date: Optional start date in YYYY-MM-DD format.
173
+ end_date: Optional end date in YYYY-MM-DD format.
174
+
175
+ Returns:
176
+ tuple[Optional[str], Optional[str]]: The normalized start and end dates.
177
+
178
+ Raises:
179
+ ValueError: If a date is malformed or the range is reversed.
180
+ """
181
+ parsed_start = _parse_date(start_date, "start_date") if start_date else None
182
+ parsed_end = _parse_date(end_date, "end_date") if end_date else None
183
+
184
+ if parsed_start and parsed_end and parsed_start > parsed_end:
185
+ raise ValueError("start_date must be on or before end_date")
186
+
187
+ return (
188
+ parsed_start.isoformat() if parsed_start else None,
189
+ parsed_end.isoformat() if parsed_end else None,
190
+ )
191
+
192
+
193
+ def _parse_date(value: str, field_name: str) -> datetime.date:
194
+ """Parses a strict ISO date.
195
+
196
+ Args:
197
+ value: Date string to parse.
198
+ field_name: Name of the field being parsed.
199
+
200
+ Returns:
201
+ datetime.date: Parsed date.
202
+
203
+ Raises:
204
+ ValueError: If the date is not formatted as YYYY-MM-DD.
205
+ """
206
  try:
207
+ return datetime.date.fromisoformat(value)
208
+ except ValueError as exc:
209
+ raise ValueError(f"{field_name} must use YYYY-MM-DD format") from exc
210
+
211
+
212
+ def validate_bbox(bbox: Optional[list[float]]) -> Optional[list[float]]:
213
+ """Validates a WGS84 bounding box and limits request size.
214
+
215
+ Args:
216
+ bbox: Optional bounding box as [west, south, east, north].
217
+
218
+ Returns:
219
+ Optional[list[float]]: Validated bounding box.
220
+
221
+ Raises:
222
+ ValueError: If the bounding box is malformed or too large.
223
+ """
224
+ if bbox is None:
225
+ return None
226
+
227
+ if len(bbox) != 4:
228
+ raise ValueError("bbox must be a list of 4 values: [west, south, east, north]")
229
+
230
+ west, south, east, north = bbox
231
+ if not (-180 <= west < east <= 180):
232
+ raise ValueError(
233
+ "bbox longitude bounds must satisfy -180 <= west < east <= 180"
234
+ )
235
+ if not (-90 <= south < north <= 90):
236
+ raise ValueError("bbox latitude bounds must satisfy -90 <= south < north <= 90")
237
+
238
+ area_degrees = (east - west) * (north - south)
239
+ if area_degrees > MAX_BBOX_AREA_DEGREES:
240
+ raise ValueError("bbox is too large for this service")
241
+
242
+ return bbox
243
+
244
+
245
+ def validate_asset_id(asset_id: str) -> str:
246
+ """Validates that an Earth Engine asset ID is a safe literal path.
247
+
248
+ Args:
249
+ asset_id: Earth Engine asset ID.
250
+
251
+ Returns:
252
+ str: Validated asset ID.
253
+
254
+ Raises:
255
+ ValueError: If the asset ID is empty, too long, or contains unsafe syntax.
256
+ """
257
+ asset_id = asset_id.strip()
258
+ if not asset_id:
259
+ raise ValueError("asset_id is required")
260
+ if len(asset_id) > MAX_ASSET_ID_LENGTH:
261
+ raise ValueError("asset_id is too long")
262
+ if asset_id.startswith("ee.") or not ASSET_ID_PATTERN.fullmatch(asset_id):
263
+ raise ValueError("asset_id must be a literal Earth Engine asset ID")
264
+ return asset_id
265
+
266
+
267
+ def parse_safe_ee_expression(expression: str) -> ee.ComputedObject:
268
+ """Parses a supported Earth Engine constructor expression.
269
+
270
+ Args:
271
+ expression: Earth Engine expression such as ee.Image("USGS/SRTMGL1_003").
272
+
273
+ Returns:
274
+ ee.ComputedObject: Constructed Earth Engine object.
275
+
276
+ Raises:
277
+ ValueError: If the expression uses unsupported or unsafe syntax.
278
+ """
279
+ try:
280
+ tree = ast.parse(expression, mode="eval")
281
+ except SyntaxError as exc:
282
+ raise ValueError("asset_id contains an unsupported ee expression") from exc
283
+
284
+ call = tree.body
285
+ if not isinstance(call, ast.Call):
286
+ raise ValueError("asset_id contains an unsupported ee expression")
287
+ if call.keywords or len(call.args) != 1:
288
+ raise ValueError("ee expressions must use one literal asset ID argument")
289
+ if not isinstance(call.func, ast.Attribute):
290
+ raise ValueError("asset_id contains an unsupported ee expression")
291
+ if not isinstance(call.func.value, ast.Name) or call.func.value.id != "ee":
292
+ raise ValueError("asset_id contains an unsupported ee expression")
293
+ if call.func.attr not in SAFE_EE_CONSTRUCTORS:
294
+ raise ValueError(
295
+ "Only ee.Image, ee.ImageCollection, and ee.FeatureCollection are supported"
296
+ )
297
+
298
+ arg = call.args[0]
299
+ if not isinstance(arg, ast.Constant) or not isinstance(arg.value, str):
300
+ raise ValueError("ee expressions must use a literal asset ID string")
301
+
302
+ asset_id = validate_asset_id(arg.value)
303
+ return SAFE_EE_CONSTRUCTORS[call.func.attr](asset_id)
304
+
305
+
306
+ def get_ee_object(asset_id: str) -> ee.ComputedObject:
307
+ """Gets an Earth Engine object from a literal asset ID or safe expression.
308
+
309
+ Args:
310
+ asset_id: Literal asset ID or supported ee constructor expression.
311
+
312
+ Returns:
313
+ ee.ComputedObject: Earth Engine object for the request.
314
+
315
+ Raises:
316
+ ValueError: If the asset ID or expression is unsupported.
317
+ """
318
+ asset_id = asset_id.strip()
319
+ if asset_id.startswith("ee."):
320
+ return parse_safe_ee_expression(asset_id)
321
+
322
+ asset_id = validate_asset_id(asset_id)
323
+ data_dict = ee.data.getAsset(asset_id)
324
+ data_type = data_dict["type"]
325
+ if data_type == "IMAGE":
326
+ return ee.Image(asset_id)
327
+ if data_type == "IMAGE_COLLECTION":
328
+ return ee.ImageCollection(asset_id)
329
+ if data_type in ["TABLE", "TABLE_COLLECTION"]:
330
+ return ee.FeatureCollection(asset_id)
331
+
332
+ raise ValueError(f"Unsupported data type: {data_type}")
333
+
334
+
335
+ def validate_vis_params(vis_params: Any) -> dict[str, Any]:
336
+ """Normalizes and validates visualization parameters.
337
+
338
+ Args:
339
+ vis_params: Visualization parameters as a dict or JSON object string.
340
+
341
+ Returns:
342
+ dict[str, Any]: Validated visualization parameters.
343
+
344
+ Raises:
345
+ ValueError: If the parameters are malformed or too large.
346
+ """
347
+ if vis_params is None:
348
+ return {}
349
+
350
+ if isinstance(vis_params, str):
351
+ if len(vis_params.encode("utf-8")) > MAX_VIS_PARAMS_BYTES:
352
+ raise ValueError("vis_params is too large")
353
+ vis_params = vis_params.strip() or "{}"
354
+ try:
355
+ vis_params = json.loads(vis_params)
356
+ except json.JSONDecodeError as exc:
357
+ raise ValueError("vis_params must be valid JSON") from exc
358
+
359
+ if not isinstance(vis_params, dict):
360
+ raise ValueError("vis_params must be a JSON object")
361
+
362
+ if len(json.dumps(vis_params).encode("utf-8")) > MAX_VIS_PARAMS_BYTES:
363
+ raise ValueError("vis_params is too large")
364
+
365
+ if "palette" in vis_params:
366
+ vis_params["palette"] = _validate_palette(vis_params["palette"])
367
+
368
+ return vis_params
369
+
370
+
371
+ def validate_jrc_request(req: "JRCWaterStatsRequest") -> tuple[list[float], str]:
372
+ """Validates JRC water statistics parameters.
373
+
374
+ Args:
375
+ req: Request model for the JRC endpoint.
376
+
377
+ Returns:
378
+ tuple[list[float], str]: Validated bounding box and end date.
379
+
380
+ Raises:
381
+ ValueError: If any parameter is outside the allowed range.
382
+ """
383
+ bbox = validate_bbox(req.bbox)
384
+ start_date, end_date = validate_date_range(
385
+ req.start_date, req.end_date or datetime.date.today().isoformat()
386
+ )
387
+ if start_date is None or end_date is None:
388
+ raise ValueError("start_date and end_date are required")
389
+ if bbox is None:
390
+ raise ValueError("bbox is required")
391
+ if req.frequency not in ("month", "year"):
392
+ raise ValueError("frequency must be 'month' or 'year'")
393
+ if not (MIN_SCALE_METERS <= req.scale <= MAX_SCALE_METERS):
394
+ raise ValueError(
395
+ f"scale must be between {MIN_SCALE_METERS} and {MAX_SCALE_METERS}"
396
+ )
397
+ if not (1 <= req.start_month <= 12 and 1 <= req.end_month <= 12):
398
+ raise ValueError("start_month and end_month must be between 1 and 12")
399
+ if req.start_month > req.end_month:
400
+ raise ValueError("start_month must be less than or equal to end_month")
401
+ if not (MIN_DENOMINATOR <= req.denominator <= MAX_DENOMINATOR):
402
+ raise ValueError("denominator is outside the allowed range")
403
+
404
+ return bbox, end_date
405
+
406
+
407
+ def get_tile(
408
+ asset_id: str,
409
+ vis_params: Any = None,
410
+ start_date: Optional[str] = None,
411
+ end_date: Optional[str] = None,
412
+ bbox: Optional[list[float]] = None,
413
+ ) -> str:
414
+ """Gets an Earth Engine tile URL for a validated asset request.
415
+
416
+ Args:
417
+ asset_id: Earth Engine asset ID.
418
+ vis_params: Visualization parameters.
419
+ start_date: Optional start date for ImageCollection filtering.
420
+ end_date: Optional end date for ImageCollection filtering.
421
+ bbox: Optional [west, south, east, north] bounding box.
422
+
423
+ Returns:
424
+ str: Tile URL or an Error-prefixed message for UI callers.
425
+ """
426
+ try:
427
+ start_date, end_date = validate_date_range(start_date, end_date)
428
+ bbox = validate_bbox(bbox)
429
+ vis_params = validate_vis_params(vis_params)
430
+ ee_object = get_ee_object(asset_id)
431
 
432
  # Apply date range filtering for ImageCollections
433
  if start_date or end_date:
 
445
 
446
  # Apply bounding box filtering
447
  if bbox:
 
 
 
 
448
  geometry = ee.Geometry.BBox(*bbox)
449
  if isinstance(ee_object, ee.ImageCollection):
450
  ee_object = ee_object.filterBounds(geometry)
 
457
  f"Bounding box filtering not supported for {type(ee_object)}"
458
  )
459
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
460
  url = _get_tile_url_format(ee_object, vis_params)
461
  return url
462
  except Exception as e:
463
  return f"Error: {str(e)}"
464
 
465
 
466
+ @asynccontextmanager
467
+ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
468
+ """Initializes Earth Engine during the API server lifespan.
469
+
470
+ Args:
471
+ _app: FastAPI application instance.
472
+
473
+ Yields:
474
+ None: Control to the running ASGI application.
475
+ """
476
+ ee_initialize()
477
+ yield
478
+
479
+
480
+ app = FastAPI(lifespan=lifespan)
481
+
482
+
483
+ @app.middleware("http")
484
+ async def enforce_request_size(request: Request, call_next):
485
+ """Rejects requests with a body larger than MAX_REQUEST_BYTES.
486
+
487
+ Args:
488
+ request: Incoming HTTP request.
489
+ call_next: Next handler in the ASGI middleware chain.
490
+
491
+ Returns:
492
+ Response: The next response or a 413 error.
493
+ """
494
+ content_length = request.headers.get("content-length")
495
+ if content_length is not None:
496
+ try:
497
+ request_bytes = int(content_length)
498
+ except ValueError:
499
+ return JSONResponse({"detail": "Invalid Content-Length"}, status_code=400)
500
+ if request_bytes > MAX_REQUEST_BYTES:
501
+ return JSONResponse(
502
+ {"detail": "Request body is too large"}, status_code=413
503
+ )
504
+
505
+ return await call_next(request)
506
+
507
 
 
 
508
  app.add_middleware(
509
+ CORSMiddleware,
510
+ allow_origins=parse_allowed_origins(),
511
+ allow_methods=["GET", "POST", "OPTIONS"],
512
+ allow_headers=["Authorization", "Content-Type"],
513
  )
514
+ app.add_middleware(TrustedHostMiddleware, allowed_hosts=parse_allowed_hosts())
515
 
516
 
517
  class TileRequest(BaseModel):
518
+ """Request model for the tile endpoint."""
519
+
520
  asset_id: str
521
+ vis_params: dict | str | None = None
522
  start_date: str | None = None
523
  end_date: str | None = None
524
  bbox: list[float] | None = None # [west, south, east, north]
 
528
  """Request model for JRC water statistics endpoint."""
529
 
530
  bbox: list[float] # [west, south, east, north]
531
+ scale: float = 30
532
+ start_date: str = "1984-03-16"
533
  end_date: str | None = None # defaults to today
534
+ start_month: int = 1
535
+ end_month: int = 12
536
+ frequency: str = "year" # "month" or "year"
537
+ denominator: float = 10000.0 # m² to hectares
538
+
539
+
540
+ @app.get("/healthz")
541
+ def healthz() -> dict[str, str]:
542
+ """Returns a lightweight health check response.
543
+
544
+ Returns:
545
+ dict[str, str]: Health check status.
546
+ """
547
+ return {"status": "ok"}
548
 
549
 
550
  @app.post("/tile")
551
+ def get_tile_api(req: TileRequest) -> dict[str, str]:
552
+ """Returns a tile URL for a supported Earth Engine asset.
553
+
554
+ Args:
555
+ req: Tile URL request.
556
+
557
+ Returns:
558
+ dict[str, str]: Tile URL response.
559
+ """
560
  result = get_tile(
561
  req.asset_id, req.vis_params, req.start_date, req.end_date, req.bbox
562
  )
 
566
 
567
 
568
  @app.post("/jrc-water-stats")
569
+ def get_jrc_water_stats(req: JRCWaterStatsRequest) -> dict[str, Any]:
570
  """Compute JRC monthly water history and water occurrence statistics.
571
 
572
  Args:
 
576
  dict: Monthly history data and water occurrence statistics.
577
  """
578
  try:
579
+ bbox, end_date = validate_jrc_request(req)
580
+ region = ee.Geometry.BBox(*bbox)
 
 
 
 
 
 
 
581
 
582
  # Compute monthly water history from JRC MonthlyHistory
583
  collection = ee.ImageCollection("JRC/GSW1_4/MonthlyHistory")
 
588
  )
589
 
590
  def cal_area(img):
591
+ """Calculates water area for a JRC monthly image.
592
+
593
+ Args:
594
+ img: Earth Engine image.
595
+
596
+ Returns:
597
+ ee.Image: Image with computed area metadata.
598
+ """
599
  pixel_area = img.multiply(ee.Image.pixelArea()).divide(req.denominator)
600
  img_area = pixel_area.reduceRegion(
601
  geometry=region,
 
673
  "histogram": histogram,
674
  },
675
  "parameters": {
676
+ "bbox": bbox,
677
  "scale": req.scale,
678
  "start_date": req.start_date,
679
  "end_date": end_date,
 
688
 
689
  # ---- Gradio UI ----
690
  def get_tile_gradio(asset_id, vis_params, start_date, end_date, bbox_str):
691
+ """Wrapper for Gradio that converts string inputs to proper types.
692
+
693
+ Args:
694
+ asset_id: Earth Engine asset ID from the UI.
695
+ vis_params: Visualization parameters as JSON.
696
+ start_date: Optional start date.
697
+ end_date: Optional end date.
698
+ bbox_str: Optional comma-separated bounding box.
699
+
700
+ Returns:
701
+ str: Tile URL or validation error for the UI.
702
+ """
703
  # Convert empty strings to None
704
  start_date = start_date.strip() if start_date and start_date.strip() else None
705
  end_date = end_date.strip() if end_date and end_date.strip() else None