From 7ee9ea230370f3aeeabce563b67ce3405ca7270d Mon Sep 17 00:00:00 2001 From: kang Date: Fri, 15 May 2026 15:15:47 +0800 Subject: [PATCH] auto-save 2026-05-15 15:15 (~4) --- .memory/worklog.json | 27 +++++----- api/main.py | 112 +++++++++++++++++++++++++++++++++++++++- deploy/nginx.conf | 75 +++++++++++++++++++++++++-- docker-compose.prod.yml | 2 - 4 files changed, 196 insertions(+), 20 deletions(-) diff --git a/.memory/worklog.json b/.memory/worklog.json index 16d14b0..ac6040b 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1,19 +1,5 @@ { "entries": [ - { - "files_changed": 1, - "hash": "8547db9", - "message": "auto-save 2026-05-13 23:45 (~1)", - "ts": "2026-05-13T23:46:07+08:00", - "type": "commit" - }, - { - "files_changed": 1, - "hash": "093c924", - "message": "auto-save 2026-05-13 23:51 (~1)", - "ts": "2026-05-13T23:51:46+08:00", - "type": "commit" - }, { "files_changed": 1, "message": "Claude 会话活跃 · 最近命令:claude · 1 项未提交变更 · 最近提交:auto-save 2026-05-13 23:51 (~1)", @@ -3251,6 +3237,19 @@ "type": "session-heartbeat", "message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-15 15:04 (~8)", "files_changed": 1 + }, + { + "ts": "2026-05-15T15:10:14+08:00", + "type": "commit", + "message": "auto-save 2026-05-15 15:10 (~2)", + "hash": "8bdb797", + "files_changed": 2 + }, + { + "ts": "2026-05-15T07:14:46Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 4 项未提交变更 · 最近提交:auto-save 2026-05-15 15:10 (~2)", + "files_changed": 4 } ] } diff --git a/api/main.py b/api/main.py index 6d1fef3..80ad454 100644 --- a/api/main.py +++ b/api/main.py @@ -2,9 +2,12 @@ from __future__ import annotations import asyncio import base64 +import hashlib +import hmac import json import os import random +import secrets import shutil import subprocess import threading @@ -16,7 +19,7 @@ from typing import Literal import httpx from dotenv import load_dotenv -from fastapi import BackgroundTasks, FastAPI, File, HTTPException, UploadFile +from fastapi import BackgroundTasks, FastAPI, File, HTTPException, Request, Response, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from pydantic import BaseModel, Field @@ -91,6 +94,12 @@ VIDEO_MODEL_ALIASES = { } VIDEO_API_BASE_URL = os.getenv("VIDEO_API_BASE_URL", "").strip() VIDEO_API_KEY = os.getenv("VIDEO_API_KEY", "").strip() +WEB_AUTH_USERNAME = os.getenv("WEB_AUTH_USERNAME", "").strip() +WEB_AUTH_PASSWORD = os.getenv("WEB_AUTH_PASSWORD", "").strip() +WEB_AUTH_SESSION_SECRET = os.getenv("WEB_AUTH_SESSION_SECRET", "").strip() +WEB_AUTH_COOKIE_NAME = os.getenv("WEB_AUTH_COOKIE_NAME", "skg_marketing_session").strip() or "skg_marketing_session" +WEB_AUTH_COOKIE_SECURE = os.getenv("WEB_AUTH_COOKIE_SECURE", "true").strip().lower() not in {"0", "false", "no"} +WEB_AUTH_CONFIGURED = bool(WEB_AUTH_USERNAME and WEB_AUTH_PASSWORD and WEB_AUTH_SESSION_SECRET) def default_video_gateway_paths(base_url: str) -> tuple[str, str, str]: @@ -455,6 +464,12 @@ class Job(BaseModel): error: str = "" +class AuthLoginPayload(BaseModel): + username: str + password: str + remember: bool = False + + JOBS: dict[str, Job] = {} ANALYZE_QUEUE: list[AnalyzeTask] = [] ANALYZE_WORKER_RUNNING = False @@ -462,6 +477,58 @@ AUDIO_WORKERS_RUNNING: set[str] = set() AUDIO_WORKERS_LOCK = threading.Lock() +def ensure_auth_configured() -> None: + if not WEB_AUTH_CONFIGURED: + raise HTTPException(503, "WEB_AUTH_USERNAME、WEB_AUTH_PASSWORD 或 WEB_AUTH_SESSION_SECRET 未配置") + + +def _auth_signature(body: str) -> str: + return hmac.new(WEB_AUTH_SESSION_SECRET.encode("utf-8"), body.encode("utf-8"), hashlib.sha256).hexdigest() + + +def _encode_auth_body(payload: dict) -> str: + raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +def _decode_auth_body(body: str) -> dict: + padded = body + "=" * (-len(body) % 4) + raw = base64.urlsafe_b64decode(padded.encode("ascii")) + data = json.loads(raw.decode("utf-8")) + return data if isinstance(data, dict) else {} + + +def make_auth_token(username: str, ttl_seconds: int) -> str: + body = _encode_auth_body({ + "u": username, + "exp": int(time.time()) + ttl_seconds, + "n": secrets.token_hex(8), + }) + return f"{body}.{_auth_signature(body)}" + + +def verify_auth_token(token: str) -> str | None: + if not WEB_AUTH_CONFIGURED or "." not in token: + return None + body, supplied_sig = token.rsplit(".", 1) + if not hmac.compare_digest(_auth_signature(body), supplied_sig): + return None + try: + payload = _decode_auth_body(body) + username = str(payload.get("u") or "") + expires_at = int(payload.get("exp") or 0) + except Exception: + return None + if username != WEB_AUTH_USERNAME or expires_at < int(time.time()): + return None + return username + + +def auth_username_from_request(request: Request) -> str | None: + token = request.cookies.get(WEB_AUTH_COOKIE_NAME, "") + return verify_auth_token(token) + + def job_dir(job_id: str) -> Path: d = JOBS_DIR / job_id d.mkdir(parents=True, exist_ok=True) @@ -724,6 +791,48 @@ app.add_middleware( ) +@app.get("/auth/check") +def auth_check(request: Request) -> Response: + ensure_auth_configured() + if not auth_username_from_request(request): + raise HTTPException(401, "unauthorized") + return Response(status_code=204) + + +@app.post("/auth/login") +def auth_login(payload: AuthLoginPayload, response: Response) -> dict: + ensure_auth_configured() + username = payload.username.strip() + password = payload.password + valid_user = hmac.compare_digest(username, WEB_AUTH_USERNAME) + valid_password = hmac.compare_digest(password, WEB_AUTH_PASSWORD) + if not (valid_user and valid_password): + raise HTTPException(401, "用户名或密码不正确") + + ttl_seconds = 60 * 60 * 24 * 30 if payload.remember else 60 * 60 * 12 + response.set_cookie( + key=WEB_AUTH_COOKIE_NAME, + value=make_auth_token(WEB_AUTH_USERNAME, ttl_seconds), + max_age=ttl_seconds, + httponly=True, + secure=WEB_AUTH_COOKIE_SECURE, + samesite="lax", + path="/", + ) + return {"ok": True, "username": WEB_AUTH_USERNAME} + + +@app.post("/auth/logout") +def auth_logout(response: Response) -> dict: + response.delete_cookie( + key=WEB_AUTH_COOKIE_NAME, + path="/", + secure=WEB_AUTH_COOKIE_SECURE, + samesite="lax", + ) + return {"ok": True} + + # ---------- Pipeline 实现 ---------- def run(cmd: list[str], cwd: Path | None = None) -> str: @@ -2157,6 +2266,7 @@ def health() -> dict: return { "ok": True, "llm_configured": bool(LLM_API_KEY), + "auth_configured": WEB_AUTH_CONFIGURED, "base_url": LLM_BASE_URL or "openai-default", "models": { "asr": ASR_MODEL, diff --git a/deploy/nginx.conf b/deploy/nginx.conf index bda763b..bc4bc99 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -4,17 +4,61 @@ server { client_max_body_size 2g; - auth_basic "SKG Marketing Studio"; - auth_basic_user_file /etc/nginx/auth/.htpasswd; - gzip on; gzip_types text/plain text/css application/json application/javascript application/xml image/svg+xml; + location = /__auth { + internal; + proxy_pass http://skg-marketing-api:4291/auth/check; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header Cookie $http_cookie; + proxy_set_header X-Original-URI $request_uri; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /api/auth/login { + proxy_pass http://skg-marketing-api:4291/auth/login; + proxy_http_version 1.1; + proxy_request_buffering off; + proxy_buffering off; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60s; + proxy_send_timeout 60s; + proxy_connect_timeout 60s; + } + + location = /api/auth/logout { + proxy_pass http://skg-marketing-api:4291/auth/logout; + proxy_http_version 1.1; + proxy_request_buffering off; + proxy_buffering off; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60s; + proxy_send_timeout 60s; + proxy_connect_timeout 60s; + } + + location @api_unauthorized { + default_type application/json; + return 401 '{"error":"unauthorized"}'; + } + location = /api { return 308 /api/; } location /api/ { + auth_request /__auth; + error_page 401 = @api_unauthorized; proxy_pass http://skg-marketing-api:4291/; proxy_http_version 1.1; proxy_request_buffering off; @@ -28,7 +72,32 @@ server { proxy_connect_timeout 60s; } + location = /login { + return 308 /login/; + } + + location /login/ { + root /usr/share/nginx/html; + try_files $uri $uri/ /login/index.html; + } + + location /_next/ { + root /usr/share/nginx/html; + try_files $uri =404; + } + + location ~* ^/(icon|apple-icon|favicon|manifest|placeholder).* { + root /usr/share/nginx/html; + try_files $uri =404; + } + + location @login_redirect { + return 302 /login/; + } + location / { + auth_request /__auth; + error_page 401 = @login_redirect; root /usr/share/nginx/html; try_files $uri $uri/ /index.html; } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b635e7f..791eb39 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -26,8 +26,6 @@ services: container_name: skg-marketing-web depends_on: - api - volumes: - - ./deploy/.htpasswd:/etc/nginx/auth/.htpasswd:ro restart: unless-stopped networks: - skg-marketing-internal