- Added X-API-Key middleware to inventory-service and budget-service - Services reject all requests without valid API key (401) - Gateway proxy injects service API keys for inventory and budget - Dashboard widget fetchers inject API keys - Generated unique API keys per service, stored in .env - Added SERVICE_API_KEY env var to docker-compose for both services Partial fix for #5 — internal services now require auth. Remaining: document trust model, validate service token semantics.
351 lines
12 KiB
Python
351 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Platform Gateway — Auth, session, proxy, dashboard aggregation.
|
|
Owns platform identity. Does NOT own business logic.
|
|
|
|
This file is thin routing only. All logic lives in submodules.
|
|
"""
|
|
|
|
import json
|
|
from datetime import datetime
|
|
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
|
|
|
|
from config import (
|
|
PORT, TRIPS_API_TOKEN, KINDLE_EMAIL_1, KINDLE_EMAIL_2,
|
|
KINDLE_LABELS, SMTP2GO_API_KEY, SMTP2GO_FROM_EMAIL,
|
|
)
|
|
from database import init_db
|
|
from sessions import get_session_user, get_service_token
|
|
import proxy as _proxy_module
|
|
from proxy import proxy_request, load_service_map, resolve_service
|
|
from responses import ResponseMixin
|
|
from auth import handle_login, handle_logout, handle_register
|
|
from dashboard import (
|
|
handle_dashboard, handle_apps, handle_me, handle_connections,
|
|
handle_set_connection, handle_pin, handle_unpin, handle_get_pinned,
|
|
)
|
|
from command import handle_command
|
|
from integrations.booklore import (
|
|
handle_booklore_libraries, handle_booklore_import,
|
|
handle_booklore_books, handle_booklore_cover,
|
|
)
|
|
from integrations.kindle import handle_send_to_kindle, handle_send_file_to_kindle
|
|
from integrations.karakeep import handle_karakeep_save, handle_karakeep_delete
|
|
from integrations.qbittorrent import handle_downloads_status
|
|
from integrations.image_proxy import handle_image_proxy
|
|
|
|
|
|
class GatewayHandler(ResponseMixin, BaseHTTPRequestHandler):
|
|
|
|
def log_message(self, format, *args):
|
|
print(f"[{datetime.now().strftime('%H:%M:%S')}] {args[0]}")
|
|
|
|
# ── Routing ──
|
|
|
|
def do_OPTIONS(self):
|
|
self.send_response(204)
|
|
self.send_header("Access-Control-Allow-Origin", "*")
|
|
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization, Cookie")
|
|
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
|
self.end_headers()
|
|
|
|
def do_GET(self):
|
|
path = self.path.split("?")[0]
|
|
|
|
# Image serving -- try services
|
|
if path.startswith("/images/"):
|
|
user = self._get_user()
|
|
for service_id, target in _proxy_module.SERVICE_MAP.items():
|
|
headers = {}
|
|
if user:
|
|
svc_token = get_service_token(user["id"], service_id)
|
|
if svc_token:
|
|
headers["Authorization"] = f"Bearer {svc_token['auth_token']}"
|
|
if not headers.get("Authorization") and service_id == "trips" and TRIPS_API_TOKEN:
|
|
headers["Authorization"] = f"Bearer {TRIPS_API_TOKEN}"
|
|
status, resp_headers, resp_body = proxy_request(f"{target}{path}", "GET", headers, timeout=15)
|
|
if status == 200:
|
|
self.send_response(200)
|
|
ct = resp_headers.get("Content-Type", "application/octet-stream")
|
|
self.send_header("Content-Type", ct)
|
|
self.send_header("Content-Length", len(resp_body))
|
|
self.send_header("Cache-Control", "public, max-age=86400")
|
|
self.end_headers()
|
|
self.wfile.write(resp_body)
|
|
return
|
|
self._send_json({"error": "Image not found"}, 404)
|
|
return
|
|
|
|
if path == "/api/health":
|
|
self._send_json({"status": "ok", "service": "gateway"})
|
|
return
|
|
|
|
if path == "/api/auth/me":
|
|
user = self._get_user()
|
|
if user:
|
|
self._send_json({
|
|
"authenticated": True,
|
|
"user": {"id": user["id"], "username": user["username"], "display_name": user["display_name"]}
|
|
})
|
|
else:
|
|
self._send_json({"authenticated": False, "user": None})
|
|
return
|
|
|
|
if path == "/api/apps":
|
|
user = self._require_auth()
|
|
if user:
|
|
handle_apps(self, user)
|
|
return
|
|
|
|
if path == "/api/me":
|
|
user = self._require_auth()
|
|
if user:
|
|
handle_me(self, user)
|
|
return
|
|
|
|
if path == "/api/me/connections":
|
|
user = self._require_auth()
|
|
if user:
|
|
handle_connections(self, user)
|
|
return
|
|
|
|
if path == "/api/dashboard":
|
|
user = self._require_auth()
|
|
if user:
|
|
handle_dashboard(self, user)
|
|
return
|
|
|
|
if path == "/api/pinned":
|
|
user = self._require_auth()
|
|
if user:
|
|
handle_get_pinned(self, user)
|
|
return
|
|
|
|
if path == "/api/booklore/books":
|
|
user = self._require_auth()
|
|
if user:
|
|
handle_booklore_books(self)
|
|
return
|
|
|
|
if path == "/api/kindle/targets":
|
|
user = self._require_auth()
|
|
if not user:
|
|
return
|
|
kindle_labels = KINDLE_LABELS.split(",")
|
|
targets = []
|
|
if KINDLE_EMAIL_1:
|
|
targets.append({"id": "1", "label": kindle_labels[0].strip() if kindle_labels else "Kindle 1", "email": KINDLE_EMAIL_1})
|
|
if KINDLE_EMAIL_2:
|
|
targets.append({"id": "2", "label": kindle_labels[1].strip() if len(kindle_labels) > 1 else "Kindle 2", "email": KINDLE_EMAIL_2})
|
|
self._send_json({"targets": targets, "configured": bool(SMTP2GO_API_KEY and SMTP2GO_FROM_EMAIL)})
|
|
return
|
|
|
|
if path.startswith("/api/booklore/books/") and path.endswith("/cover"):
|
|
user = self._require_auth()
|
|
if user:
|
|
book_id = path.split("/")[4]
|
|
handle_booklore_cover(self, book_id)
|
|
return
|
|
|
|
if path == "/api/downloads/status":
|
|
user = self._require_auth()
|
|
if user:
|
|
handle_downloads_status(self)
|
|
return
|
|
|
|
if path == "/api/booklore/libraries":
|
|
user = self._require_auth()
|
|
if user:
|
|
handle_booklore_libraries(self)
|
|
return
|
|
|
|
if path == "/api/image-proxy":
|
|
user = self._require_auth()
|
|
if user:
|
|
handle_image_proxy(self)
|
|
return
|
|
|
|
if path.startswith("/api/"):
|
|
self._proxy("GET", path)
|
|
return
|
|
|
|
self._send_json({"error": "Not found"}, 404)
|
|
|
|
def do_POST(self):
|
|
path = self.path.split("?")[0]
|
|
body = self._read_body()
|
|
|
|
if path == "/api/auth/login":
|
|
handle_login(self, body)
|
|
return
|
|
|
|
if path == "/api/auth/logout":
|
|
handle_logout(self)
|
|
return
|
|
|
|
if path == "/api/auth/register":
|
|
self._send_json({"error": "Registration is disabled"}, 403)
|
|
return
|
|
|
|
if path == "/api/me/connections":
|
|
user = self._require_auth()
|
|
if user:
|
|
handle_set_connection(self, user, body)
|
|
return
|
|
|
|
if path == "/api/pin":
|
|
user = self._require_auth()
|
|
if user:
|
|
handle_pin(self, user, body)
|
|
return
|
|
|
|
if path == "/api/unpin":
|
|
user = self._require_auth()
|
|
if user:
|
|
handle_unpin(self, user, body)
|
|
return
|
|
|
|
if path == "/api/kindle/send-file":
|
|
user = self._require_auth()
|
|
if user:
|
|
handle_send_file_to_kindle(self, body)
|
|
return
|
|
|
|
if path.startswith("/api/booklore/books/") and path.endswith("/send-to-kindle"):
|
|
user = self._require_auth()
|
|
if user:
|
|
book_id = path.split("/")[4]
|
|
handle_send_to_kindle(self, book_id, body)
|
|
return
|
|
|
|
if path == "/api/booklore/import":
|
|
user = self._require_auth()
|
|
if user:
|
|
handle_booklore_import(self, body)
|
|
return
|
|
|
|
if path == "/api/karakeep/save":
|
|
user = self._require_auth()
|
|
if user:
|
|
handle_karakeep_save(self, body)
|
|
return
|
|
|
|
if path == "/api/karakeep/delete":
|
|
user = self._require_auth()
|
|
if user:
|
|
handle_karakeep_delete(self, body)
|
|
return
|
|
|
|
if path == "/api/command":
|
|
user = self._require_auth()
|
|
if user:
|
|
handle_command(self, user, body)
|
|
return
|
|
|
|
if path.startswith("/api/"):
|
|
self._proxy("POST", path, body)
|
|
return
|
|
|
|
self._send_json({"error": "Not found"}, 404)
|
|
|
|
def do_PUT(self):
|
|
path = self.path.split("?")[0]
|
|
body = self._read_body()
|
|
if path.startswith("/api/"):
|
|
self._proxy("PUT", path, body)
|
|
return
|
|
self._send_json({"error": "Not found"}, 404)
|
|
|
|
def do_PATCH(self):
|
|
path = self.path.split("?")[0]
|
|
body = self._read_body()
|
|
if path.startswith("/api/"):
|
|
self._proxy("PATCH", path, body)
|
|
return
|
|
self._send_json({"error": "Not found"}, 404)
|
|
|
|
def do_DELETE(self):
|
|
path = self.path.split("?")[0]
|
|
body = self._read_body()
|
|
if path.startswith("/api/"):
|
|
self._proxy("DELETE", path, body)
|
|
return
|
|
self._send_json({"error": "Not found"}, 404)
|
|
|
|
# ── Service proxy ──
|
|
|
|
def _proxy(self, method, path, body=None):
|
|
user = self._get_user()
|
|
|
|
service_id, target, backend_path = resolve_service(path)
|
|
|
|
if not target:
|
|
self._send_json({"error": "Unknown service"}, 404)
|
|
return
|
|
|
|
from config import MINIFLUX_API_KEY, INVENTORY_SERVICE_API_KEY, BUDGET_SERVICE_API_KEY
|
|
headers = {}
|
|
ct = self.headers.get("Content-Type")
|
|
if ct:
|
|
headers["Content-Type"] = ct
|
|
|
|
# Inject service-level auth
|
|
if service_id == "reader" and MINIFLUX_API_KEY:
|
|
headers["X-Auth-Token"] = MINIFLUX_API_KEY
|
|
elif service_id == "trips" and TRIPS_API_TOKEN:
|
|
headers["Authorization"] = f"Bearer {TRIPS_API_TOKEN}"
|
|
elif service_id == "inventory" and INVENTORY_SERVICE_API_KEY:
|
|
headers["X-API-Key"] = INVENTORY_SERVICE_API_KEY
|
|
elif service_id == "budget" and BUDGET_SERVICE_API_KEY:
|
|
headers["X-API-Key"] = BUDGET_SERVICE_API_KEY
|
|
elif user:
|
|
svc_token = get_service_token(user["id"], service_id)
|
|
if svc_token:
|
|
if svc_token["auth_type"] == "bearer":
|
|
headers["Authorization"] = f"Bearer {svc_token['auth_token']}"
|
|
elif svc_token["auth_type"] == "cookie":
|
|
headers["Cookie"] = f"session={svc_token['auth_token']}"
|
|
|
|
if "Authorization" not in headers:
|
|
auth = self.headers.get("Authorization")
|
|
if auth:
|
|
headers["Authorization"] = auth
|
|
|
|
for h in ["X-API-Key", "X-Telegram-User-Id"]:
|
|
val = self.headers.get(h)
|
|
if val:
|
|
headers[h] = val
|
|
|
|
query = self.path.split("?", 1)[1] if "?" in self.path else ""
|
|
full_url = f"{target}{backend_path}"
|
|
if query:
|
|
full_url += f"?{query}"
|
|
|
|
status, resp_headers, resp_body = proxy_request(full_url, method, headers, body)
|
|
|
|
self.send_response(status)
|
|
for k, v in resp_headers.items():
|
|
k_lower = k.lower()
|
|
if k_lower in ("content-type", "content-disposition"):
|
|
self.send_header(k, v)
|
|
self.send_header("Content-Length", len(resp_body))
|
|
self.end_headers()
|
|
self.wfile.write(resp_body)
|
|
|
|
|
|
# ── Main ──
|
|
|
|
def main():
|
|
init_db()
|
|
load_service_map()
|
|
|
|
print(f"[Gateway] Services: {_proxy_module.SERVICE_MAP}")
|
|
print(f"[Gateway] Listening on port {PORT}")
|
|
|
|
server = ThreadingHTTPServer(("0.0.0.0", PORT), GatewayHandler)
|
|
server.serve_forever()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|