#!/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 BaseHTTPRequestHandler from socketserver import ThreadingMixIn from http.server import HTTPServer 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": handle_register(self, body) 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 headers = {} ct = self.headers.get("Content-Type") if ct: headers["Content-Type"] = ct 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 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}") class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): daemon_threads = True server = ThreadingHTTPServer(("0.0.0.0", PORT), GatewayHandler) server.serve_forever() if __name__ == "__main__": main()