MeowSky49887 commited on
Commit
d2b94b1
·
verified ·
1 Parent(s): dea64a9

Upload 5 files

Browse files
Files changed (5) hide show
  1. Dockerfile +24 -0
  2. README.md +5 -3
  3. app.py +412 -0
  4. config.yaml +17 -0
  5. requirements.txt +7 -0
Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use the official Python latest image
2
+ FROM python:latest
3
+
4
+ # Set up a new user named "user" with user ID 1000
5
+ RUN useradd -m -u 1000 user
6
+
7
+ # Set the home to the user's home directory and add user's local bin to PATH
8
+ ENV HOME=/home/user \
9
+ PATH=/home/user/.local/bin:$PATH
10
+
11
+ # Switch to the "user" user
12
+ USER user
13
+
14
+ # Create the app directory
15
+ WORKDIR $HOME/app
16
+
17
+ # Copy the application code and the requirements.txt file
18
+ COPY --chown=user . $HOME/app
19
+
20
+ # Install requirements.txt
21
+ RUN pip install --requirement requirements.txt
22
+
23
+ # Run Python
24
+ CMD ["python", "app.py"]
README.md CHANGED
@@ -1,10 +1,12 @@
1
  ---
2
- title: MapTilesProxyService
3
- emoji: 🐠
4
  colorFrom: green
5
  colorTo: blue
6
  sdk: docker
 
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
+ title: MapTilesMiddleware
3
+ emoji: 🌍
4
  colorFrom: green
5
  colorTo: blue
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
+ short_description: Proxy for Map Tiles Service | Control Your Little Globe 🌍
10
  ---
11
 
12
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
app.py ADDED
@@ -0,0 +1,412 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import base64
3
+ import math
4
+ import random
5
+ import re
6
+ import yaml
7
+ import uvicorn
8
+ import httpx
9
+ import secrets
10
+ from fastapi import FastAPI, Request, Response, HTTPException, Security, Depends, Path, Query, status
11
+ from fastapi.security.api_key import APIKeyHeader
12
+ from fastapi.openapi.utils import get_openapi
13
+ from fastapi.encoders import jsonable_encoder
14
+ from fastapi.openapi.docs import get_swagger_ui_html, get_redoc_html
15
+ from starlette.middleware.base import BaseHTTPMiddleware
16
+ from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse
17
+ from typing import Annotated
18
+ from cachetools import TTLCache
19
+
20
+
21
+ # ---------- Start Cache ---------- #
22
+ cache = TTLCache(maxsize=1000, ttl=21600)
23
+ # ---------- End Cache ---------- #
24
+
25
+
26
+ # ---------- Start App ---------- #
27
+ app = FastAPI(
28
+ debug = True,
29
+ title = "🗺️ Map Tiles Proxy Service",
30
+ # summary = "Proxy for Map Tiles Service | Control Your Little Globe 🌍",
31
+ description = (
32
+ "<br><hr><br>"
33
+ "<blockquote>🗺️ **Map Tiles Proxy Service** is an app that acts as a proxy for tile maps from multiple sources, allowing you to easily aggregate map services from various providers in one place ✨</blockquote>"
34
+ "<br><hr><br>"
35
+ ),
36
+ version = "template",
37
+ license_info = {
38
+ "name": "Web Sparkle © 2025 by MeowSkyKung is licensed under CC BY-SA 4.0",
39
+ "url": "https://creativecommons.org/licenses/by-sa/4.0/",
40
+ },
41
+ # contact = {
42
+ # "name": "MeowIsWorking",
43
+ # "url": "https://meowverse.azurewebsites.net",
44
+ # "email": "[email protected]"
45
+ # },
46
+ openapi_tags = [
47
+ {
48
+ "name": "🎉 Features 🎉",
49
+ "description": (
50
+ "* 🔐 Basic Authentication to prevent unauthorized access\n"
51
+ "* 🧭 Restricts geographic bounds and zoom levels to control map access precisely\n"
52
+ "* 🚀 Caching system to improve performance and reduce redundant requests\n"
53
+ "* 🔄 Supports subdomain switching for load balancing\n"
54
+ )
55
+ },
56
+ {
57
+ "name": "📥 Installation 📥",
58
+ "description": (
59
+ "1. Clone this repository\n"
60
+ "2. Configure the `config.yaml` file as shown in the repo\n"
61
+ "3. Set the Environment Secret `PASSWORD` for Basic Auth\n"
62
+ )
63
+ },
64
+ {
65
+ "name": "🚀 Usage 🚀",
66
+ "description": (
67
+ "* Use the API via the endpoint `/serviceId/{z}/{x}/{y}` with Basic Auth\n"
68
+ "* View each service’s details via the root endpoint `/`\n"
69
+ )
70
+ },
71
+ {
72
+ "name": "💖 Supporting by 💖",
73
+ "description": (
74
+ "* Like Me on Hugging Face 🌟\n"
75
+ "* Donate on Ko-Fi ☕\n"
76
+ )
77
+ }
78
+ ],
79
+ openapi_url = None,
80
+ docs_url = None,
81
+ redoc_url = None
82
+ )
83
+ # ---------- End App ---------- #
84
+
85
+
86
+ # ---------- Start Config ---------- #
87
+ CONFIG_FILE = "config.yaml"
88
+ configs = {}
89
+
90
+ def load():
91
+ global configs
92
+ with open(CONFIG_FILE, "r") as f:
93
+ data = yaml.safe_load(f)
94
+ configs.update({
95
+ service["serviceId"]: service for service in data.get("tileServices", [])
96
+ })
97
+ app.state.urls = list(configs.keys())
98
+
99
+ load()
100
+ # ---------- End Config ---------- #
101
+
102
+
103
+ # ---------- Start Limit ---------- #
104
+ def xyz_to_bbox(z, x, y):
105
+ """Returns (minLat, minLon, maxLat, maxLon) for a given tile."""
106
+ n = 2.0 ** z
107
+ lonDegMin = x / n * 360.0 - 180.0
108
+ lonDegMax = (x + 1) / n * 360.0 - 180.0
109
+
110
+ latRadMin = math.atan(math.sinh(math.pi * (1 - 2 * y / n)))
111
+ latRadMax = math.atan(math.sinh(math.pi * (1 - 2 * (y + 1) / n)))
112
+ latDegMin = math.degrees(latRadMax)
113
+ latDegMax = math.degrees(latRadMin)
114
+
115
+ return lonDegMin, latDegMin, lonDegMax, latDegMax # west, north, east, south
116
+ # ---------- End Limit ---------- #
117
+
118
+
119
+ # ---------- Start Switch ---------- #
120
+ def switch(template: str) -> str:
121
+ """Replace {switch:a,b,c,d} with a random subdomain."""
122
+ match = re.search(r"{switch:([^}]+)}", template)
123
+ if match:
124
+ options = match.group(1).split(",")
125
+ chosen = random.choice(options)
126
+ return template.replace(match.group(0), chosen)
127
+ return template
128
+ # ---------- End Switch ---------- #
129
+
130
+
131
+ # ---------- Start Auth ---------- #
132
+ PASSWORD = os.getenv("PASSWORD")
133
+ if not PASSWORD:
134
+ raise RuntimeError("Missing PASSWORD environment secret")
135
+
136
+ stats = {}
137
+
138
+ header = APIKeyHeader(name="X-Authorization", scheme_name="X-Authorization", description="base64 encoded username:password", auto_error=False)
139
+
140
+ def authenticate(credentials: Annotated[str, Security(header)]):
141
+ try:
142
+ decoded = base64.b64decode(credentials).decode("utf-8")
143
+ username, password = decoded.split(":", 1)
144
+ except Exception:
145
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid auth header")
146
+
147
+ if not secrets.compare_digest(password.encode("utf-8"), PASSWORD.encode("utf-8")):
148
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect password")
149
+
150
+ stats[username] = stats.get(username, 0) + 1
151
+ print(f"User '{username}' used the service {stats[username]} times.")
152
+
153
+ return username
154
+ # ---------- End Auth ---------- #
155
+
156
+
157
+ # ---------- Start Middleware ---------- #
158
+ class DebugHeadersMiddleware(BaseHTTPMiddleware):
159
+ async def dispatch(self, request: Request, next):
160
+ print("Request headers:", request.headers)
161
+ response = await next(request)
162
+ return response
163
+
164
+ class TileValidationMiddleware(BaseHTTPMiddleware):
165
+ async def dispatch(self, request: Request, next):
166
+ parts = request.url.path.strip("/").split("/")
167
+ if len(parts) >= 4:
168
+ id = parts[0]
169
+ config = configs.get(id)
170
+ if not config:
171
+ return JSONResponse(content={"detail": f"Service '{id}' not found"}, status_code=status.HTTP_404_NOT_FOUND)
172
+
173
+ minZoom = config.get("minZoom", 0)
174
+ maxZoom = config.get("maxZoom", 20)
175
+ minLat = config.get("minLat", -90.0)
176
+ maxLat = config.get("maxLat", 90.0)
177
+ minLon = config.get("minLon", -180.0)
178
+ maxLon = config.get("maxLon", 180.0)
179
+
180
+ if config:
181
+ try:
182
+ z = int(parts[1])
183
+ x = int(parts[2])
184
+ y = int(parts[3])
185
+
186
+ if not (minZoom <= z <= maxZoom):
187
+ return JSONResponse(content={"detail": "Zoom level out of bounds"}, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
188
+
189
+ west, north, east, south = xyz_to_bbox(z, x, y)
190
+
191
+ if (
192
+ south < minLat or north > maxLat or
193
+ west < minLon or east > maxLon
194
+ ):
195
+ return JSONResponse(content={"detail": "Tile out of bounding box"}, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
196
+
197
+ except Exception as e:
198
+ return JSONResponse(content={"detail": f"Invalid tile parameters: {str(e)}"}, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
199
+
200
+ return await next(request)
201
+
202
+ app.add_middleware(DebugHeadersMiddleware)
203
+ app.add_middleware(TileValidationMiddleware)
204
+ # ---------- End Middleware ---------- #
205
+
206
+
207
+ # ---------- Start Proxy ---------- #
208
+ @app.get("/{id}/{z}/{x}/{y}",
209
+ responses={
210
+ status.HTTP_200_OK: {
211
+ "description": "Tile Data",
212
+ "content": {"application/octet-stream": {}}
213
+ },
214
+ status.HTTP_400_BAD_REQUEST: {
215
+ "description": "Bad Request"
216
+ },
217
+ status.HTTP_401_UNAUTHORIZED: {
218
+ "description": "Authentication Required"
219
+ },
220
+ status.HTTP_404_NOT_FOUND: {
221
+ "description": "Service Not Found"
222
+ },
223
+ status.HTTP_422_UNPROCESSABLE_ENTITY: {
224
+ "description": "Unprocessable Entity"
225
+ },
226
+ },
227
+ response_class=Response,
228
+ summary="Fetch tile from service endpoint",
229
+ description=(
230
+ "Retrieve a map tile using the given service name, zoom level (`z`), and tile coordinates (`x`, `y`).\n\n"
231
+ "- If the service URL contains `{key}`, the `key` query parameter must be included.\n"
232
+ "- Returns a binary tile image (`application/octet-stream`).\n"
233
+ "- Caches responses for performance.\n\n"
234
+ "**Authentication**:\n"
235
+ "- X-Password Header required\n"
236
+ "- Username for statistics collection\n"
237
+ "- Password matched with environment secret"
238
+ ),
239
+ response_description="Binary tile data"
240
+ )
241
+ async def proxy(
242
+ request: Request,
243
+ username: Annotated[str, Security(authenticate)],
244
+ id: str = Path(..., description="Service name"),
245
+ z: int = Path(..., description="Zoom level"),
246
+ x: int = Path(..., description="Tile X coordinate"),
247
+ y: int = Path(..., description="Tile Y coordinate"),
248
+ key: str = Query(None, description="API key (required if serviceURL contains {key})")
249
+ ):
250
+ config = configs.get(id)
251
+ if not config:
252
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND , detail=f"Service '{id}' not found")
253
+
254
+ try:
255
+ queries = dict(request.query_params)
256
+ key = queries.get("key", None)
257
+
258
+ format = {"z": z, "x": x, "y": y}
259
+ if "{key}" in config["serviceURL"]:
260
+ if not key:
261
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing required 'key' parameter")
262
+ format["key"] = key
263
+
264
+ template = switch(config["serviceURL"])
265
+ url = template.format(**format)
266
+
267
+ find = f"{id}/{z}/{x}/{y}"
268
+ if find in cache:
269
+ content, type = cache[find]
270
+ else:
271
+ async with httpx.AsyncClient(http2=True) as client:
272
+ resp = await client.get(url)
273
+ if resp.status_code != 200:
274
+ raise HTTPException(resp.status_code, detail="Upstream service error")
275
+ content = resp.content
276
+ type = resp.headers.get("content-type", "application/octet-stream")
277
+ cache[find] = (content, type)
278
+
279
+ return Response(content=content, media_type=type, headers={
280
+ "Cache-Control": "public, max-age=21600, immutable"
281
+ })
282
+ except Exception as e:
283
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Error formatting tile URL: {str(e)}")
284
+ # ---------- End Proxy ---------- #
285
+
286
+
287
+ # ---------- Start Root ---------- #
288
+ @app.get("/",
289
+ responses={
290
+ status.HTTP_200_OK: {
291
+ "description": "Service Meta",
292
+ "content": {"application/json": {}},
293
+ }
294
+ },
295
+ response_class=Response,
296
+ summary="List all available tile services",
297
+ description=(
298
+ "Returns a dictionary of available tile services loaded from the `config.yaml`.\n"
299
+ "Each entry describes the service's URL pattern, zoom level limits, bounding box, and whether an API key is required."
300
+ ),
301
+ response_description="A dictionary of available services with metadata"
302
+ )
303
+ async def root(request: Request):
304
+ base = str(request.base_url).rstrip("/")
305
+ available = {}
306
+ for name in app.state.urls:
307
+ conf = configs[name]
308
+ available[name] = {
309
+ "url": f"{base}/{name}/{{z}}/{{x}}/{{y}}?key=",
310
+ "minZoom": conf.get("minZoom"),
311
+ "maxZoom": conf.get("maxZoom"),
312
+ "minLat": conf.get("minLat"),
313
+ "maxLat": conf.get("maxLat"),
314
+ "minLon": conf.get("minLon"),
315
+ "maxLon": conf.get("maxLon"),
316
+ "keyRequired": "{key}" in conf.get("serviceURL", ""),
317
+ }
318
+ return JSONResponse(content={"available": available}, status_code=status.HTTP_200_OK)
319
+ # ---------- End Root ---------- #
320
+
321
+
322
+ @app.get("/play", include_in_schema=False)
323
+ async def play():
324
+ # ดึง schema ต้นฉบับ
325
+ display = get_openapi(
326
+ title=app.title,
327
+ version=app.version,
328
+ openapi_version=app.openapi_version,
329
+ summary=app.summary,
330
+ description=app.description,
331
+ terms_of_service=app.terms_of_service,
332
+ contact=app.contact,
333
+ license_info=app.license_info,
334
+ routes=app.routes,
335
+ webhooks=app.webhooks.routes,
336
+ tags=app.openapi_tags,
337
+ servers=app.servers,
338
+ separate_input_output_schemas=app.separate_input_output_schemas,
339
+ )
340
+
341
+ # แก้ security ของแต่ละ path + method
342
+ for path, methods in display.get("paths", {}).items():
343
+ for method, details in methods.items():
344
+ if "security" in details:
345
+ # แปลงจาก [{"Authorization": []}] เป็น [{"HTTPBasic": []}]
346
+ details["security"] = [{"HTTPBasic": []}]
347
+
348
+ # แก้ components.securitySchemes
349
+ display["components"]["securitySchemes"] = {
350
+ "HTTPBasic": {
351
+ "type": "http",
352
+ "scheme": "basic"
353
+ }
354
+ }
355
+
356
+ display["tags"] = []
357
+
358
+ html = f"""
359
+ <!DOCTYPE html>
360
+ <html>
361
+ <head>
362
+ <link type="text/css" rel="stylesheet" href="{get_swagger_ui_html.__kwdefaults__.swagger_css_url}">
363
+ <link rel="shortcut icon" href="{get_swagger_ui_html.__kwdefaults__.swagger_favicon_url}">
364
+ <title>{app.title} - Swagger UI</title>
365
+ </head>
366
+ <body>
367
+ <div id="swagger-ui"></div>
368
+ <script src="{get_swagger_ui_html.__kwdefaults__.swagger_js_url}"></script>
369
+ <script>
370
+ const ui = SwaggerUIBundle({{
371
+ dom_id: 'swagger-ui',
372
+ spec: '{json.dumps(jsonable_encoder(display))}',
373
+ layout: 'BaseLayout',
374
+ presets: [
375
+ SwaggerUIBundle.presets.apis,
376
+ SwaggerUIBundle.SwaggerUIStandalonePreset
377
+ ]
378
+ }})
379
+ </script>
380
+ <script>
381
+ (function() {{
382
+ const originalFetch = window.fetch;
383
+ window.fetch = function(input, init = {{}}) {{
384
+ if(init.headers) {{
385
+ // ถ้า headers เป็น Headers object
386
+ if(init.headers instanceof Headers) {{
387
+ if(init.headers.has('Authorization')) {{
388
+ const authValue = init.headers.get('Authorization');
389
+ init.headers.delete('Authorization');
390
+ init.headers.set('X-Authorization', authValue);
391
+ }}
392
+ }} else if (typeof init.headers === 'object') {{
393
+ // ถ้า headers เป็น plain object
394
+ if('Authorization' in init.headers) {{
395
+ init.headers['X-Authorization'] = init.headers['Authorization'];
396
+ delete init.headers['Authorization'];
397
+ }}
398
+ }}
399
+ }}
400
+ return originalFetch(input, init);
401
+ }};
402
+ }})();
403
+ </script>
404
+ </body>
405
+ </html>
406
+ """
407
+
408
+ return HTMLResponse(html)
409
+
410
+
411
+ if __name__ == "__main__":
412
+ uvicorn.run("app:app", host="0.0.0.0", port=7860, reload=False, log_level="debug")
config.yaml ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ tileServices:
2
+ - serviceId: longdo-base
3
+ serviceURL: https://ms.longdo.com/mmmap/img.php?zoom={z}&x={x}&y={y}&HD=1&key={key}
4
+ minZoom: 1
5
+ maxZoom: 20
6
+ - serviceId: carto-base
7
+ serviceURL: https://{s:a,b,c}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}@2.png
8
+ minZoom: 1
9
+ maxZoom: 20
10
+ - serviceId: carto-no-labels
11
+ serviceURL: https://{s:a,b,c}.basemaps.cartocdn.com/rastertiles/voyager_nolabels/{z}/{x}/{y}@2.png
12
+ minZoom: 1
13
+ maxZoom: 20
14
+ - serviceId: carto-only-labels
15
+ serviceURL: https://{s:a,b,c}.basemaps.cartocdn.com/rastertiles/voyager_only_labels/{z}/{x}/{y}@2.png
16
+ minZoom: 1
17
+ maxZoom: 20
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ fastapi[standard]
2
+ uvicorn[standard]
3
+ httpx[http2]
4
+ cachetools
5
+ pyyaml
6
+ brotli
7
+ zstandard