diff --git a/gateway/Dockerfile b/gateway/Dockerfile index 070f411..f4ced20 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -1,5 +1,6 @@ FROM python:3.12-slim WORKDIR /app +RUN pip install bcrypt COPY server.py config.py database.py sessions.py proxy.py responses.py auth.py dashboard.py command.py ./ COPY integrations/ ./integrations/ EXPOSE 8100 diff --git a/gateway/auth.py b/gateway/auth.py index ec57152..ba2bd2d 100644 --- a/gateway/auth.py +++ b/gateway/auth.py @@ -2,10 +2,16 @@ Platform Gateway — Auth handlers (login, logout, register). """ +""" +NOTE: Passwords are hashed with bcrypt. Any existing SHA-256 hashed passwords +in the database will no longer work. The admin user is re-seeded on first boot +if no users exist. Other users need manual password reset. +""" import json -import hashlib import sqlite3 +import bcrypt + from database import get_db from sessions import create_session, delete_session @@ -24,14 +30,12 @@ def handle_login(handler, body): handler._send_json({"error": "Username and password required"}, 400) return - pw_hash = hashlib.sha256(password.encode()).hexdigest() - conn = get_db() - user = conn.execute("SELECT * FROM users WHERE username = ? AND password_hash = ?", - (username, pw_hash)).fetchone() + user = conn.execute("SELECT * FROM users WHERE username = ?", + (username,)).fetchone() conn.close() - if not user: + if not user or not bcrypt.checkpw(password.encode(), user["password_hash"].encode()): handler._send_json({"error": "Invalid credentials"}, 401) return @@ -76,7 +80,7 @@ def handle_register(handler, body): handler._send_json({"error": "Username and password required"}, 400) return - pw_hash = hashlib.sha256(password.encode()).hexdigest() + pw_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() conn = get_db() try: diff --git a/gateway/command.py b/gateway/command.py index a33d562..d07a2b6 100644 --- a/gateway/command.py +++ b/gateway/command.py @@ -6,9 +6,7 @@ import json import urllib.request from datetime import datetime -from config import ( - OPENAI_API_KEY, OPENAI_MODEL, TRIPS_URL, _ssl_ctx, -) +from config import OPENAI_API_KEY, OPENAI_MODEL, TRIPS_URL from sessions import get_service_token import proxy as _proxy_module from proxy import proxy_request @@ -33,7 +31,6 @@ def handle_command(handler, user, body): # Get context: user's trips list and today's date trips_token = get_service_token(user["id"], "trips") - fitness_token = get_service_token(user["id"], "fitness") trips_context = "" if trips_token: @@ -116,7 +113,7 @@ Guidelines: method="POST" ) - with urllib.request.urlopen(req, context=_ssl_ctx, timeout=30) as resp: + with urllib.request.urlopen(req, timeout=30) as resp: ai_result = json.loads(resp.read().decode()) ai_text = ai_result["choices"][0]["message"]["content"] diff --git a/gateway/config.py b/gateway/config.py index 5ca4d98..fc27af6 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -42,6 +42,12 @@ KINDLE_LABELS = os.environ.get("KINDLE_LABELS", "Kindle 1,Kindle 2") KARAKEEP_URL = os.environ.get("KARAKEEP_URL", "http://192.168.1.42:3005") KARAKEEP_API_KEY = os.environ.get("KARAKEEP_API_KEY", "") +# ── qBittorrent ── +QBITTORRENT_HOST = os.environ.get("QBITTORRENT_HOST", "192.168.1.42") +QBITTORRENT_PORT = os.environ.get("QBITTORRENT_PORT", "8080") +QBITTORRENT_USERNAME = os.environ.get("QBITTORRENT_USERNAME", "admin") +QBITTORRENT_PASSWORD = os.environ.get("QBITTORRENT_PASSWORD", "") + # ── AI ── OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "") OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-5.2") @@ -49,9 +55,6 @@ OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-5.2") # ── Session config ── SESSION_MAX_AGE = int(os.environ.get("SESSION_MAX_AGE", 30 * 86400)) # 30 days -# ── Shared state ── -_booklore_token = {"access": "", "refresh": "", "expires": 0} - # ── Ensure data dir exists ── DATA_DIR.mkdir(parents=True, exist_ok=True) diff --git a/gateway/dashboard.py b/gateway/dashboard.py index 4b4984c..be455e3 100644 --- a/gateway/dashboard.py +++ b/gateway/dashboard.py @@ -222,7 +222,7 @@ def handle_dashboard(handler, user): data = json.loads(b) return {"count": data.get("transactionCount", 0), "totalBalance": data.get("totalBalanceDollars", 0), "spending": data.get("spendingDollars", 0), "income": data.get("incomeDollars", 0), "topCategories": data.get("topCategories", [])[:5], "month": data.get("month", "")} except Exception as e: - pass + print(f"[Dashboard] Budget fetch error: {e}") return None def fetch_reader(app): diff --git a/gateway/database.py b/gateway/database.py index 19a441a..9c6593e 100644 --- a/gateway/database.py +++ b/gateway/database.py @@ -2,9 +2,10 @@ Platform Gateway — Database initialization and access. """ -import hashlib import sqlite3 +import bcrypt + from config import ( DB_PATH, TRIPS_URL, FITNESS_URL, INVENTORY_URL, MINIFLUX_URL, SHELFMARK_URL, SPOTIZERR_URL, BUDGET_URL, @@ -124,7 +125,7 @@ def init_db(): # Seed default admin user if empty user_count = c.execute("SELECT COUNT(*) FROM users").fetchone()[0] if user_count == 0: - pw_hash = hashlib.sha256("admin".encode()).hexdigest() + pw_hash = bcrypt.hashpw("admin".encode(), bcrypt.gensalt()).decode() c.execute("INSERT INTO users (username, password_hash, display_name) VALUES (?, ?, ?)", ("admin", pw_hash, "Yusuf")) conn.commit() diff --git a/gateway/integrations/booklore.py b/gateway/integrations/booklore.py index 91d44dd..b9e9c89 100644 --- a/gateway/integrations/booklore.py +++ b/gateway/integrations/booklore.py @@ -5,12 +5,12 @@ Platform Gateway — Booklore integration (book library manager). import json import time -from config import ( - BOOKLORE_URL, BOOKLORE_USER, BOOKLORE_PASS, - BOOKLORE_BOOKS_DIR, _booklore_token, -) +from config import BOOKLORE_URL, BOOKLORE_USER, BOOKLORE_PASS, BOOKLORE_BOOKS_DIR from proxy import proxy_request +# Mutable auth token state (not in config — config is for immutable values) +_booklore_token = {"access": "", "refresh": "", "expires": 0} + def booklore_auth(): """Get a valid Booklore JWT token, refreshing if needed.""" diff --git a/gateway/integrations/qbittorrent.py b/gateway/integrations/qbittorrent.py index ceeab1f..b3db59b 100644 --- a/gateway/integrations/qbittorrent.py +++ b/gateway/integrations/qbittorrent.py @@ -3,21 +3,18 @@ Platform Gateway — qBittorrent integration (download status). """ import json -import os import urllib.request import urllib.parse +from config import QBITTORRENT_HOST, QBITTORRENT_PORT, QBITTORRENT_USERNAME, QBITTORRENT_PASSWORD + def handle_downloads_status(handler): """Get active downloads from qBittorrent.""" - qbt_host = os.environ.get("QBITTORRENT_HOST", "192.168.1.42") - qbt_port = os.environ.get("QBITTORRENT_PORT", "8080") - qbt_user = os.environ.get("QBITTORRENT_USERNAME", "admin") - qbt_pass = os.environ.get("QBITTORRENT_PASSWORD", "") - base = f"http://{qbt_host}:{qbt_port}" + base = f"http://{QBITTORRENT_HOST}:{QBITTORRENT_PORT}" try: # Login - login_data = urllib.parse.urlencode({"username": qbt_user, "password": qbt_pass}).encode() + login_data = urllib.parse.urlencode({"username": QBITTORRENT_USERNAME, "password": QBITTORRENT_PASSWORD}).encode() req = urllib.request.Request(f"{base}/api/v2/auth/login", data=login_data) with urllib.request.urlopen(req, timeout=5) as resp: cookie = resp.headers.get("Set-Cookie", "").split(";")[0] diff --git a/gateway/server.py b/gateway/server.py index b2597b5..047ea56 100644 --- a/gateway/server.py +++ b/gateway/server.py @@ -8,7 +8,9 @@ This file is thin routing only. All logic lives in submodules. import json from datetime import datetime -from http.server import HTTPServer, BaseHTTPRequestHandler +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, @@ -337,7 +339,10 @@ def main(): print(f"[Gateway] Services: {_proxy_module.SERVICE_MAP}") print(f"[Gateway] Listening on port {PORT}") - server = HTTPServer(("0.0.0.0", PORT), GatewayHandler) + class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): + daemon_threads = True + + server = ThreadingHTTPServer(("0.0.0.0", PORT), GatewayHandler) server.serve_forever()