diff --git a/.gitignore b/.gitignore index 703dfeb..8367d6b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,39 @@ -node_modules/ -.svelte-kit/ -build/ +# Secrets and local config .env +.env.* +!.env.example +services/**/.env +services/**/.env.* + +# Dependencies +node_modules/ +frontend-v2/node_modules/ + +# Build artifacts +.svelte-kit/ +frontend-v2/.svelte-kit/ +build/ +frontend-v2/build/ +__pycache__/ +*.pyc + +# Runtime data *.db *.db-journal *.db-wal +*.db-shm data/ -__pycache__/ -*.pyc -.DS_Store +**/data/*.db +**/data/*.json services/fitness/data/ services/trips/data/ gateway/data/ -frontend-v2/.svelte-kit/ -frontend-v2/build/ -frontend-v2/node_modules/ + +# OS +.DS_Store + +# Media *.png + +# Test artifacts +test-results/ diff --git a/frontend-v2/src/hooks.server.ts b/frontend-v2/src/hooks.server.ts index 03dd224..ae1d79f 100644 --- a/frontend-v2/src/hooks.server.ts +++ b/frontend-v2/src/hooks.server.ts @@ -8,8 +8,26 @@ const karakeepUrl = env.KARAKEEP_URL || ''; const karakeepApiKey = env.KARAKEEP_API_KEY || ''; export const handle: Handle = async ({ event, resolve }) => { + async function isAuthenticated(request: Request): Promise { + const cookie = request.headers.get('cookie') || ''; + if (!cookie.includes('platform_session=')) return false; + try { + const res = await fetch(`${gatewayUrl}/api/auth/me`, { headers: { cookie } }); + if (!res.ok) return false; + const data = await res.json(); + return data.authenticated === true; + } catch { + return false; + } + } + // ── Immich API proxy (shared across all pages) ── if (event.url.pathname.startsWith('/api/immich/') && immichUrl && immichApiKey) { + if (!await isAuthenticated(event.request)) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, headers: { 'Content-Type': 'application/json' } + }); + } const immichPath = event.url.pathname.replace('/api/immich', '/api'); // Thumbnail/original image proxy — cache-friendly binary response @@ -78,6 +96,11 @@ export const handle: Handle = async ({ event, resolve }) => { // ── Karakeep API proxy (server-to-server) ── if (event.url.pathname.startsWith('/api/karakeep/') && karakeepUrl && karakeepApiKey) { + if (!await isAuthenticated(event.request)) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, headers: { 'Content-Type': 'application/json' } + }); + } const action = event.url.pathname.split('/').pop(); // 'save' or 'delete' try { const body = await event.request.json(); @@ -125,6 +148,9 @@ export const handle: Handle = async ({ event, resolve }) => { // Legacy trips-specific Immich thumbnail (keep for backward compat) if (event.url.pathname.startsWith('/api/trips/immich/thumb/') && immichUrl && immichApiKey) { + if (!await isAuthenticated(event.request)) { + return new Response('', { status: 401 }); + } const assetId = event.url.pathname.split('/').pop(); try { const response = await fetch(`${immichUrl}/api/assets/${assetId}/thumbnail`, { diff --git a/gateway/integrations/image_proxy.py b/gateway/integrations/image_proxy.py index 5a9ac91..670d968 100644 --- a/gateway/integrations/image_proxy.py +++ b/gateway/integrations/image_proxy.py @@ -1,5 +1,5 @@ """ -Platform Gateway — Image proxy (bypass hotlink protection). +Platform Gateway — Image proxy with domain allowlist. """ import urllib.request @@ -7,24 +7,65 @@ import urllib.parse from config import _ssl_ctx +ALLOWED_IMAGE_DOMAINS = { + "i.redd.it", + "preview.redd.it", + "external-preview.redd.it", + "i.imgur.com", + "imgur.com", + "images-na.ssl-images-amazon.com", + "m.media-amazon.com", + "cdn.discordapp.com", + "media.discordapp.net", + "pbs.twimg.com", + "abs.twimg.com", + "upload.wikimedia.org", + "images.unsplash.com", +} + def handle_image_proxy(handler): - """Proxy external images to bypass hotlink protection (e.g. Reddit).""" + """Proxy external images to bypass hotlink protection.""" qs = urllib.parse.urlparse(handler.path).query params = urllib.parse.parse_qs(qs) url = params.get("url", [None])[0] if not url: handler._send_json({"error": "Missing url parameter"}, 400) return + + # Validate URL scheme and domain allowlist + try: + parsed = urllib.parse.urlparse(url) + if parsed.scheme not in ("http", "https"): + handler._send_json({"error": "Invalid URL scheme"}, 400) + return + hostname = parsed.hostname or "" + if hostname not in ALLOWED_IMAGE_DOMAINS: + allowed = any( + hostname == d or hostname.endswith(f".{d}") + for d in ALLOWED_IMAGE_DOMAINS + ) + if not allowed: + print(f"[ImageProxy] Blocked request for domain: {hostname}") + handler._send_json({"error": "Domain not allowed"}, 403) + return + except Exception: + handler._send_json({"error": "Invalid URL"}, 400) + return + try: req = urllib.request.Request(url, headers={ "User-Agent": "Mozilla/5.0 (compatible; PlatformProxy/1.0)", "Accept": "image/*,*/*", - "Referer": urllib.parse.urlparse(url).scheme + "://" + urllib.parse.urlparse(url).netloc + "/", + "Referer": parsed.scheme + "://" + parsed.netloc + "/", }) resp = urllib.request.urlopen(req, timeout=10, context=_ssl_ctx) body = resp.read() ct = resp.headers.get("Content-Type", "image/jpeg") + # Only serve actual image content types + if not ct.startswith("image/"): + handler._send_json({"error": "Response is not an image"}, 400) + return handler.send_response(200) handler.send_header("Content-Type", ct) handler.send_header("Content-Length", len(body)) diff --git a/gateway/responses.py b/gateway/responses.py index 80a7c32..7d9bcb5 100644 --- a/gateway/responses.py +++ b/gateway/responses.py @@ -42,7 +42,7 @@ class ResponseMixin: def _set_session_cookie(self, token): self.send_header("Set-Cookie", - f"platform_session={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={SESSION_MAX_AGE}") + f"platform_session={token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={SESSION_MAX_AGE}") def _read_body(self): length = int(self.headers.get("Content-Length", 0)) diff --git a/gateway/server.py b/gateway/server.py index 984f305..d0691e6 100644 --- a/gateway/server.py +++ b/gateway/server.py @@ -184,7 +184,7 @@ class GatewayHandler(ResponseMixin, BaseHTTPRequestHandler): return if path == "/api/auth/register": - handle_register(self, body) + self._send_json({"error": "Registration is disabled"}, 403) return if path == "/api/me/connections": diff --git a/services/fitness/Dockerfile.backend b/services/fitness/Dockerfile.backend index 56dea8f..ce89e6a 100644 --- a/services/fitness/Dockerfile.backend +++ b/services/fitness/Dockerfile.backend @@ -1,5 +1,7 @@ FROM python:3.12-slim WORKDIR /app +RUN pip install bcrypt COPY server.py . EXPOSE 8095 +ENV PYTHONUNBUFFERED=1 CMD ["python3", "server.py"] diff --git a/services/fitness/server.py b/services/fitness/server.py index 074b098..7804eae 100644 --- a/services/fitness/server.py +++ b/services/fitness/server.py @@ -8,8 +8,8 @@ import os import json import sqlite3 import uuid -import hashlib import secrets +import bcrypt import re import unicodedata from http.server import HTTPServer, BaseHTTPRequestHandler @@ -517,21 +517,21 @@ def seed_default_users(): "username": os.environ.get("USER1_USERNAME", "yusuf"), "password": os.environ.get("USER1_PASSWORD", "changeme"), "display_name": os.environ.get("USER1_DISPLAY_NAME", "Yusuf"), - "telegram_user_id": os.environ.get("USER1_TELEGRAM_ID", "5878604567"), + "telegram_user_id": os.environ.get("USER1_TELEGRAM_ID"), }, { "id": str(uuid.uuid4()), "username": os.environ.get("USER2_USERNAME", "madiha"), "password": os.environ.get("USER2_PASSWORD", "changeme"), "display_name": os.environ.get("USER2_DISPLAY_NAME", "Madiha"), - "telegram_user_id": os.environ.get("USER2_TELEGRAM_ID", "6389024883"), + "telegram_user_id": os.environ.get("USER2_TELEGRAM_ID"), }, ] for user in users: existing = cursor.execute("SELECT id FROM users WHERE username = ?", (user["username"],)).fetchone() if not existing: - password_hash = hashlib.sha256(user["password"].encode()).hexdigest() + password_hash = bcrypt.hashpw(user["password"].encode(), bcrypt.gensalt()).decode() cursor.execute( "INSERT INTO users (id, username, password_hash, display_name, telegram_user_id) VALUES (?, ?, ?, ?, ?)", (user["id"], user["username"], password_hash, user["display_name"], user["telegram_user_id"]) @@ -2208,16 +2208,14 @@ class CalorieHandler(BaseHTTPRequestHandler): data = self._read_body() username = data.get('username', '').strip().lower() password = data.get('password', '') - password_hash = hashlib.sha256(password.encode()).hexdigest() - conn = get_db() user = conn.execute( - "SELECT * FROM users WHERE username = ? AND password_hash = ?", - (username, password_hash) + "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()): return self._send_json({'error': 'Invalid credentials'}, 401) token = create_session(user['id']) diff --git a/test-results/.last-run.json b/test-results/.last-run.json deleted file mode 100644 index 5fca3f8..0000000 --- a/test-results/.last-run.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "status": "failed", - "failedTests": [] -} \ No newline at end of file