Files
platform/gateway/server.py
Yusuf Suleman 4592e35732
All checks were successful
Security Checks / dependency-audit (push) Successful in 1m13s
Security Checks / secret-scanning (push) Successful in 3s
Security Checks / dockerfile-lint (push) Successful in 3s
feat: major platform expansion — Brain service, RSS reader, iOS app, AI assistants, Firefox extension
Brain Service:
- Playwright stealth crawler replacing browserless (og:image, Readability, Reddit JSON API)
- AI classification with tag definitions and folder assignment
- YouTube video download via yt-dlp
- Karakeep migration complete (96 items)
- Taxonomy management (folders with icons/colors, tags)
- Discovery shuffle, sort options, search (Meilisearch + pgvector)
- Item tag/folder editing, card color accents

RSS Reader Service:
- Custom FastAPI reader replacing Miniflux
- Feed management (add/delete/refresh), category support
- Full article extraction via Readability
- Background content fetching for new entries
- Mark all read with confirmation
- Infinite scroll, retention cleanup (30/60 day)
- 17 feeds migrated from Miniflux

iOS App (SwiftUI):
- Native iOS 17+ app with @Observable architecture
- Cookie-based auth, configurable gateway URL
- Dashboard with custom background photo + frosted glass widgets
- Full fitness module (today/templates/goals/food library)
- AI assistant chat (fitness + brain, raw JSON state management)
- 120fps ProMotion support

AI Assistants (Gateway):
- Unified dispatcher with fitness/brain domain detection
- Fitness: natural language food logging, photo analysis, multi-item splitting
- Brain: save/append/update/delete notes, search & answer, undo support
- Madiha user gets fitness-only (brain disabled)

Firefox Extension:
- One-click save to Brain from any page
- Login with platform credentials
- Right-click context menu (save page/link/image)
- Notes field for URL saves
- Signed and published on AMO

Other:
- Reader bookmark button routes to Brain (was Karakeep)
- Fitness food library with "Add" button + add-to-meal popup
- Kindle send file size check (25MB SMTP2GO limit)
- Atelier UI as default (useAtelierShell=true)
- Mobile upload box in nav drawer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:56:29 -05:00

382 lines
13 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 assistant import handle_assistant, handle_fitness_assistant, handle_brain_assistant
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 == "/api/assistant":
user = self._require_auth()
if user:
handle_assistant(self, body, user)
return
if path == "/api/assistant/fitness":
user = self._require_auth()
if user:
handle_fitness_assistant(self, body, user)
return
if path == "/api/assistant/brain":
user = self._require_auth()
if user:
handle_brain_assistant(self, body, user)
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, TASKS_SERVICE_API_KEY
headers = {}
ct = self.headers.get("Content-Type")
if ct:
headers["Content-Type"] = ct
# Inject service-level auth
if service_id == "reader":
if user:
headers["X-Gateway-User-Id"] = str(user["id"])
headers["X-Gateway-User-Name"] = user.get("display_name", user.get("username", ""))
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 service_id == "tasks":
# Inject user identity for the task manager
if user:
headers["X-Gateway-User-Id"] = str(user["id"])
headers["X-Gateway-User-Name"] = user.get("display_name", user.get("username", ""))
elif service_id == "brain":
# Inject user identity for the brain service
if user:
headers["X-Gateway-User-Id"] = str(user["id"])
headers["X-Gateway-User-Name"] = user.get("display_name", user.get("username", ""))
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()