From f9075ae3f6719c613452989d4230832e6459a0c7 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 23 Apr 2026 07:38:22 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=9D=B4=EB=A0=A5=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EC=A4=91=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20+=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EB=AA=A8=EB=8D=B8=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Dockerfile | 7 +- app/auth.py | 101 +++++++-------- app/main.py | 284 +++++++++++++++++++++++++----------------- app/static/index.html | 201 ++++++++++++++++++++++++++++-- 4 files changed, 412 insertions(+), 181 deletions(-) diff --git a/app/Dockerfile b/app/Dockerfile index 6da8ab2..a2bbb0c 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -9,6 +9,8 @@ RUN apt-get update && apt-get install -y \ libxext6 \ libxrender1 \ libgl1 \ + libgles2 \ + libegl1 \ wget \ curl \ && rm -rf /var/lib/apt/lists/* @@ -17,11 +19,8 @@ WORKDIR /app COPY requirements.txt . -# PaddlePaddle CPU (AMD64) — paddleocr 3.x 호환 -RUN pip install --no-cache-dir paddlepaddle==3.0.0 \ - -i https://pypi.tuna.tsinghua.edu.cn/simple +RUN pip install --no-cache-dir paddlepaddle==3.0.0 -# 나머지 패키지 RUN pip install --no-cache-dir -r requirements.txt COPY . . diff --git a/app/auth.py b/app/auth.py index 3efb0d9..03f4169 100644 --- a/app/auth.py +++ b/app/auth.py @@ -1,7 +1,16 @@ """ 인증 모듈 — 다중 사용자 JSON 파일 기반 -/data/users.json 에 사용자 정보 저장 -관리자(admin)는 환경변수 AUTH_USERNAME/AUTH_PASSWORD 기준으로 초기화 +사용자 구조: +{ + "password": "...", + "role": "admin" | "user", + "permissions": { + "stt": true | false, + "ocr": true | false, + "allowed_stt_models": ["medium", "large-v3", ...], # 빈 배열 = 모두 허용 + "allowed_ocr_models": ["granite3.2-vision", ...] # 빈 배열 = 모두 허용 + } +} """ import os, json, threading from pathlib import Path @@ -20,14 +29,13 @@ ADMIN_PASSWORD = os.getenv("AUTH_PASSWORD", "changeme1234") DATA_DIR = Path(os.getenv("UPLOAD_DIR", "/data/uploads")).parent USERS_FILE = DATA_DIR / "users.json" -_lock = threading.Lock() +_lock = threading.Lock() bearer = HTTPBearer(auto_error=False) -# ── 파일 I/O ─────────────────────────────────────────────────── +# ── 파일 I/O ────────────────────────────────────────────────── def _load() -> dict: - if not USERS_FILE.exists(): - return {} + if not USERS_FILE.exists(): return {} with open(USERS_FILE, "r", encoding="utf-8") as f: return json.load(f) @@ -37,72 +45,66 @@ def _save(users: dict): json.dump(users, f, ensure_ascii=False, indent=2) -# ── 초기화 (앱 시작 시 1회) ──────────────────────────────────── +# ── 초기화 ──────────────────────────────────────────────────── def init_users(): with _lock: users = _load() - # 관리자 계정은 항상 env var 기준으로 동기화 users[ADMIN_USERNAME] = { - "password": ADMIN_PASSWORD, - "role": "admin", - "permissions": {"stt": True, "ocr": True}, + "password": ADMIN_PASSWORD, + "role": "admin", + "permissions": { + "stt": True, "ocr": True, + "allowed_stt_models": [], # 빈 배열 = 제한 없음 + "allowed_ocr_models": [], + }, } _save(users) # ── CRUD ────────────────────────────────────────────────────── def authenticate(username: str, password: str): - """성공 시 user dict, 실패 시 None""" - with _lock: - users = _load() + with _lock: users = _load() u = users.get(username) - if not u or u["password"] != password: - return None + if not u or u["password"] != password: return None return {"username": username, **u} def get_user(username: str): - with _lock: - return _load().get(username) + with _lock: return _load().get(username) def list_users() -> dict: - with _lock: - users = _load() - # 비밀번호 마스킹 - return {k: {**{kk: vv for kk, vv in v.items() if kk != "password"}} + with _lock: users = _load() + return {k: {kk: vv for kk, vv in v.items() if kk != "password"} for k, v in users.items()} def create_user(username: str, password: str, permissions: dict) -> tuple: with _lock: users = _load() - if username in users: - return False, "이미 존재하는 사용자입니다" - users[username] = {"password": password, "role": "user", - "permissions": permissions} + if username in users: return False, "이미 존재하는 사용자입니다" + # 기본값 보완 + permissions.setdefault("allowed_stt_models", []) + permissions.setdefault("allowed_ocr_models", []) + users[username] = {"password": password, "role": "user", "permissions": permissions} _save(users) return True, "사용자가 생성되었습니다" def update_user(username: str, permissions: dict, password: str = None) -> tuple: - if username == ADMIN_USERNAME: - return False, "기본 관리자 계정은 수정할 수 없습니다" + if username == ADMIN_USERNAME: return False, "기본 관리자 계정은 수정할 수 없습니다" with _lock: users = _load() - if username not in users: - return False, "사용자를 찾을 수 없습니다" + if username not in users: return False, "사용자를 찾을 수 없습니다" + permissions.setdefault("allowed_stt_models", []) + permissions.setdefault("allowed_ocr_models", []) users[username]["permissions"] = permissions - if password: - users[username]["password"] = password + if password: users[username]["password"] = password _save(users) return True, "업데이트되었습니다" def delete_user(username: str) -> tuple: - if username == ADMIN_USERNAME: - return False, "기본 관리자 계정은 삭제할 수 없습니다" + if username == ADMIN_USERNAME: return False, "기본 관리자 계정은 삭제할 수 없습니다" with _lock: users = _load() - if username not in users: - return False, "사용자를 찾을 수 없습니다" - del users[username] - _save(users) + if username not in users: return False, "사용자를 찾을 수 없습니다" + del users[username]; _save(users) return True, "삭제되었습니다" @@ -112,35 +114,28 @@ def create_access_token(username: str) -> str: return jwt.encode({"sub": username, "exp": exp}, SECRET_KEY, algorithm=ALGORITHM) -# ── FastAPI 의존성 ──────────────────────────────────────────── +# ── FastAPI 의존성 ───────────────────────────────────────────── def require_auth(credentials: HTTPAuthorizationCredentials = Depends(bearer)) -> dict: if credentials is None: - raise HTTPException(401, "인증이 필요합니다", - headers={"WWW-Authenticate": "Bearer"}) + raise HTTPException(401, "인증이 필요합니다", headers={"WWW-Authenticate": "Bearer"}) try: payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM]) username = payload.get("sub") - if not username: - raise JWTError() + if not username: raise JWTError() u = get_user(username) - if not u: - raise JWTError() + if not u: raise JWTError() return {"username": username, **u} except JWTError: - raise HTTPException(401, "토큰이 유효하지 않거나 만료되었습니다", - headers={"WWW-Authenticate": "Bearer"}) + raise HTTPException(401, "토큰이 유효하지 않거나 만료되었습니다", headers={"WWW-Authenticate": "Bearer"}) def require_admin(user: dict = Depends(require_auth)) -> dict: - if user.get("role") != "admin": - raise HTTPException(403, "관리자 권한이 필요합니다") + if user.get("role") != "admin": raise HTTPException(403, "관리자 권한이 필요합니다") return user def require_stt(user: dict = Depends(require_auth)) -> dict: - if not user.get("permissions", {}).get("stt", False): - raise HTTPException(403, "STT 사용 권한이 없습니다") + if not user.get("permissions", {}).get("stt", False): raise HTTPException(403, "STT 사용 권한이 없습니다") return user def require_ocr(user: dict = Depends(require_auth)) -> dict: - if not user.get("permissions", {}).get("ocr", False): - raise HTTPException(403, "OCR 사용 권한이 없습니다") + if not user.get("permissions", {}).get("ocr", False): raise HTTPException(403, "OCR 사용 권한이 없습니다") return user diff --git a/app/main.py b/app/main.py index 13c9ba4..0d510a0 100644 --- a/app/main.py +++ b/app/main.py @@ -35,27 +35,22 @@ _DEFAULT_SETTINGS = { "stt_ollama_model": "", "ocr_ollama_model": "granite3.2-vision:latest", "cpu_threads": 0, - "stt_timeout": 0, # 0 = 무제한 - "ollama_timeout": 600, # 초 + "stt_timeout": 0, + "ollama_timeout": 600, } - _hist_lock = threading.Lock() # ── 설정 I/O ───────────────────────────────────────────────── def _load_settings() -> dict: - if not SETTINGS_FILE.exists(): - return dict(_DEFAULT_SETTINGS) - with open(SETTINGS_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - for k, v in _DEFAULT_SETTINGS.items(): - data.setdefault(k, v) + if not SETTINGS_FILE.exists(): return dict(_DEFAULT_SETTINGS) + with open(SETTINGS_FILE, "r", encoding="utf-8") as f: data = json.load(f) + for k, v in _DEFAULT_SETTINGS.items(): data.setdefault(k, v) return data def _save_settings(data: dict): SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True) - with open(SETTINGS_FILE, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) + with open(SETTINGS_FILE, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) # ── 이력 I/O ───────────────────────────────────────────────── @@ -66,6 +61,11 @@ def _load_history() -> list: with open(HISTORY_FILE, "r", encoding="utf-8") as f: return json.load(f) except: return [] +def _write_history(history: list): + HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(HISTORY_FILE, "w", encoding="utf-8") as f: + json.dump(history, f, ensure_ascii=False, indent=2) + def append_history(record: dict): with _hist_lock: try: @@ -73,52 +73,47 @@ def append_history(record: dict): if HISTORY_FILE.exists(): with open(HISTORY_FILE, "r", encoding="utf-8") as f: history = json.load(f) history.insert(0, record) - history = history[:HISTORY_MAX] - HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True) - with open(HISTORY_FILE, "w", encoding="utf-8") as f: json.dump(history, f, ensure_ascii=False, indent=2) + _write_history(history[:HISTORY_MAX]) except: pass -def _update_history(file_id: str, result: dict): +def _update_history_by_task(task_id: str, result: dict, success: bool, error_msg: str = ""): + """task_id로 이력을 찾아 결과 업데이트 — 핵심 버그 수정""" with _hist_lock: if not HISTORY_FILE.exists(): return try: with open(HISTORY_FILE, "r", encoding="utf-8") as f: history = json.load(f) for h in history: - if h.get("id") == file_id and h.get("status") == "processing": - h["status"] = "success" - if h["type"] == "stt": - h["output"] = { - "filename": result.get("output_file",""), - "language": result.get("language",""), - "duration_s": result.get("duration", 0), - "segments": len(result.get("segments",[])), - "text_preview": (result.get("text","")[:200]+"…" if len(result.get("text",""))>200 else result.get("text","")), - "ollama_used": result.get("ollama_used", False), - "ollama_model": result.get("ollama_model",""), - } - else: - h["output"] = { - "txt_file": result.get("txt_file",""), - "xlsx_file": result.get("xlsx_file",""), - "line_count": result.get("line_count", 0), - "table_count": len(result.get("tables",[])), - "backend": result.get("backend",""), - "ollama_model": result.get("ollama_model",""), - "text_preview": (result.get("full_text","")[:200]+"…" if len(result.get("full_text",""))>200 else result.get("full_text","")), - } + # task_id 필드로 매칭 + if h.get("task_id") != task_id: continue + if h.get("status") != "processing": break + if not success: + h["status"] = "failed" + h["output"] = {"error": error_msg[:300]} break - with open(HISTORY_FILE, "w", encoding="utf-8") as f: json.dump(history, f, ensure_ascii=False, indent=2) - except: pass - -def _update_history_fail(file_id: str, error_msg: str): - with _hist_lock: - if not HISTORY_FILE.exists(): return - try: - with open(HISTORY_FILE, "r", encoding="utf-8") as f: history = json.load(f) - for h in history: - if h.get("id") == file_id and h.get("status") == "processing": - h["status"] = "failed"; h["output"] = {"error": error_msg[:300]}; break - with open(HISTORY_FILE, "w", encoding="utf-8") as f: json.dump(history, f, ensure_ascii=False, indent=2) + h["status"] = "success" + if h["type"] == "stt": + h["output"] = { + "filename": result.get("output_file", ""), + "language": result.get("language", ""), + "duration_s": result.get("duration", 0), + "segments": len(result.get("segments", [])), + "text_preview": result.get("text", "")[:200] + ("…" if len(result.get("text",""))>200 else ""), + "ollama_used": result.get("ollama_used", False), + "ollama_model": result.get("ollama_model", ""), + } + else: + full_text = result.get("full_text", "") + h["output"] = { + "txt_file": result.get("txt_file", ""), + "xlsx_file": result.get("xlsx_file", ""), + "line_count": result.get("line_count", 0), + "table_count": len(result.get("tables", [])), + "backend": result.get("backend", ""), + "ollama_model": result.get("ollama_model", ""), + "text_preview": full_text[:200] + ("…" if len(full_text)>200 else ""), + } + break + _write_history(history) except: pass def delete_history_item(history_id: str) -> bool: @@ -128,8 +123,7 @@ def delete_history_item(history_id: str) -> bool: with open(HISTORY_FILE, "r", encoding="utf-8") as f: history = json.load(f) new = [h for h in history if h.get("id") != history_id] if len(new) == len(history): return False - with open(HISTORY_FILE, "w", encoding="utf-8") as f: json.dump(new, f, ensure_ascii=False, indent=2) - return True + _write_history(new); return True except: return False def clear_history(): @@ -166,9 +160,7 @@ def me(user: dict = Depends(require_auth)): # ════════════════════════════════════════════════════════════════ @app.get("/api/system") def system_info(user: dict = Depends(require_auth)): - mem = psutil.virtual_memory() - swap = psutil.swap_memory() - s = _load_settings() + mem = psutil.virtual_memory(); swap = psutil.swap_memory(); s = _load_settings() return { "ram_total_gb": round(mem.total / 1024**3, 1), "ram_used_gb": round(mem.used / 1024**3, 1), @@ -197,22 +189,36 @@ async def transcribe( _check_size(request) ext = _ext(file.filename) if ext not in AUDIO_EXT: raise HTTPException(400, f"지원하지 않는 형식: {', '.join(sorted(AUDIO_EXT))}") - file_id = str(uuid.uuid4()) + file_id = str(uuid.uuid4()) save_path = os.path.join(UPLOAD_DIR, f"{file_id}.{ext}") await _save(file, save_path) file_size = os.path.getsize(save_path) _use_ollama = use_ollama.lower() == "true" s = _load_settings() - if _use_ollama and not ollama_model.strip(): ollama_model = s.get("stt_ollama_model","") - append_history({"id": file_id, "type": "stt", "status": "processing", - "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "username": user["username"], - "input": {"filename": file.filename, "size_bytes": file_size, "format": ext.upper()}, - "settings": {"model": os.getenv("WHISPER_MODEL","medium"), "language": os.getenv("WHISPER_LANGUAGE","ko"), - "compute_type": os.getenv("WHISPER_COMPUTE_TYPE","int8"), "cpu_threads": s.get("cpu_threads",0), - "stt_timeout": s.get("stt_timeout",0), "use_ollama": _use_ollama, - "ollama_model": ollama_model if _use_ollama else ""}, - "output": None}) + if _use_ollama and not ollama_model.strip(): ollama_model = s.get("stt_ollama_model", "") + task = transcribe_task.delay(file_id, save_path, _use_ollama, ollama_model) + + # ★ task_id를 이력에 함께 저장 + append_history({ + "id": file_id, + "task_id": task.id, # ← 업데이트 매칭 키 + "type": "stt", + "status": "processing", + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "username": user["username"], + "input": {"filename": file.filename, "size_bytes": file_size, "format": ext.upper()}, + "settings": { + "model": os.getenv("WHISPER_MODEL", "medium"), + "language": os.getenv("WHISPER_LANGUAGE", "ko"), + "compute_type": os.getenv("WHISPER_COMPUTE_TYPE", "int8"), + "cpu_threads": s.get("cpu_threads", 0), + "stt_timeout": s.get("stt_timeout", 0), + "use_ollama": _use_ollama, + "ollama_model": ollama_model if _use_ollama else "", + }, + "output": None, + }) return {"task_id": task.id, "file_id": file_id, "filename": file.filename} @@ -233,33 +239,55 @@ async def ocr( if backend not in ("paddle","ollama"): backend = "paddle" s = _load_settings() if backend == "ollama" and not ollama_model.strip(): ollama_model = s.get("ocr_ollama_model","granite3.2-vision:latest") - file_id = str(uuid.uuid4()) + file_id = str(uuid.uuid4()) save_path = os.path.join(UPLOAD_DIR, f"{file_id}.{ext}") await _save(file, save_path) file_size = os.path.getsize(save_path) - append_history({"id": file_id, "type": "ocr", "status": "processing", - "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "username": user["username"], - "input": {"filename": file.filename, "size_bytes": file_size, "format": ext.upper()}, - "settings": {"backend": backend, "mode": mode, "ocr_lang": os.getenv("OCR_LANG","korean"), - "ollama_model": ollama_model if backend=="ollama" else "", - "ollama_timeout": s.get("ollama_timeout",600), - "custom_prompt": custom_prompt[:200] if custom_prompt else ""}, - "output": None}) + task = ocr_task.delay(file_id, save_path, mode, backend, ollama_model, custom_prompt) + + # ★ task_id를 이력에 함께 저장 + append_history({ + "id": file_id, + "task_id": task.id, # ← 업데이트 매칭 키 + "type": "ocr", + "status": "processing", + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "username": user["username"], + "input": {"filename": file.filename, "size_bytes": file_size, "format": ext.upper()}, + "settings": { + "backend": backend, + "mode": mode, + "ocr_lang": os.getenv("OCR_LANG", "korean"), + "ollama_model": ollama_model if backend == "ollama" else "", + "ollama_timeout":s.get("ollama_timeout", 600), + "custom_prompt": custom_prompt[:200] if custom_prompt else "", + }, + "output": None, + }) return {"task_id": task.id, "file_id": file_id, "filename": file.filename, "mode": mode, "backend": backend} # ════════════════════════════════════════════════════════════════ -# 상태 +# 상태 — task_id 기준으로 이력 업데이트 # ════════════════════════════════════════════════════════════════ @app.get("/api/status/{task_id}") def get_status(task_id: str, user: dict = Depends(require_auth)): r = celery_app.AsyncResult(task_id) - if r.state == "PENDING": return {"state":"pending", "progress":0, "message":"대기 중..."} - if r.state == "PROGRESS": m=r.info or {}; return {"state":"progress","progress":m.get("progress",0),"message":m.get("message","처리 중...")} - if r.state == "SUCCESS": _update_history(task_id, r.result or {}); return {"state":"success","progress":100,**(r.result or {})} - if r.state == "FAILURE": _update_history_fail(task_id, str(r.info)); return {"state":"failure","progress":0,"message":str(r.info)} - return {"state":r.state.lower(),"progress":0} + if r.state == "PENDING": + return {"state": "pending", "progress": 0, "message": "대기 중..."} + if r.state == "PROGRESS": + m = r.info or {} + return {"state": "progress", "progress": m.get("progress",0), "message": m.get("message","처리 중...")} + if r.state == "SUCCESS": + result = r.result or {} + # ★ task_id로 이력 업데이트 (file_id 아님) + _update_history_by_task(task_id, result, success=True) + return {"state": "success", "progress": 100, **result} + if r.state == "FAILURE": + _update_history_by_task(task_id, {}, success=False, error_msg=str(r.info)) + return {"state": "failure", "progress": 0, "message": str(r.info)} + return {"state": r.state.lower(), "progress": 0} # ════════════════════════════════════════════════════════════════ @@ -271,16 +299,16 @@ def get_history(page: int=1, per_page: int=15, type_: str="", user: dict=Depends if user.get("role") != "admin": history = [h for h in history if h.get("username")==user["username"]] if type_ in ("stt","ocr"): history = [h for h in history if h.get("type")==type_] total = len(history); start = (page-1)*per_page - return {"total":total,"page":page,"per_page":per_page,"items":history[start:start+per_page]} + return {"total": total, "page": page, "per_page": per_page, "items": history[start:start+per_page]} @app.delete("/api/history/{history_id}") def delete_history(history_id: str, user: dict=Depends(require_auth)): - if not delete_history_item(history_id): raise HTTPException(404,"이력을 찾을 수 없습니다") - return {"ok":True} + if not delete_history_item(history_id): raise HTTPException(404, "이력을 찾을 수 없습니다") + return {"ok": True} @app.delete("/api/history") def clear_all_history(user: dict=Depends(require_admin)): - clear_history(); return {"ok":True} + clear_history(); return {"ok": True} # ════════════════════════════════════════════════════════════════ @@ -288,18 +316,20 @@ def clear_all_history(user: dict=Depends(require_admin)): # ════════════════════════════════════════════════════════════════ @app.get("/api/download/{filename}") def download(filename: str, user: dict=Depends(require_auth)): - if ".." in filename or "/" in filename: raise HTTPException(400,"잘못된 파일명") + if ".." in filename or "/" in filename: raise HTTPException(400, "잘못된 파일명") path = os.path.join(OUTPUT_DIR, filename) - if not os.path.exists(path): raise HTTPException(404,"파일을 찾을 수 없습니다") - media = ("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" if filename.endswith(".xlsx") else "text/plain") + if not os.path.exists(path): raise HTTPException(404, "파일을 찾을 수 없습니다") + media = ("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + if filename.endswith(".xlsx") else "text/plain") return FileResponse(path, media_type=media, filename=filename) @app.get("/api/ollama/models") def ollama_models(user: dict=Depends(require_auth)): try: resp = httpx.get(f"{OLLAMA_URL}/api/tags", timeout=8.0); resp.raise_for_status() - return {"models":[m["name"] for m in resp.json().get("models",[])], "connected":True} - except Exception as e: return {"models":[], "connected":False, "error":str(e)} + return {"models": [m["name"] for m in resp.json().get("models",[])], "connected": True} + except Exception as e: + return {"models": [], "connected": False, "error": str(e)} @app.get("/api/settings") def get_settings(user: dict=Depends(require_auth)): return _load_settings() @@ -313,9 +343,9 @@ def save_settings_endpoint( ollama_timeout: str = Form("600"), user: dict = Depends(require_auth), ): - def _int(v, default): + def _int(v, d): try: return max(0, int(v)) - except: return default + except: return d data = { "stt_ollama_model": stt_ollama_model, "ocr_ollama_model": ocr_ollama_model, @@ -324,57 +354,85 @@ def save_settings_endpoint( "ollama_timeout": _int(ollama_timeout, 600), } _save_settings(data) - return {"ok":True, "settings":data} + return {"ok": True, "settings": data} @app.get("/api/admin/users") -def admin_list_users(user: dict=Depends(require_admin)): return {"users":list_users()} +def admin_list_users(user: dict=Depends(require_admin)): return {"users": list_users()} @app.post("/api/admin/users") -def admin_create_user(username:str=Form(...),password:str=Form(...),perm_stt:str=Form("false"),perm_ocr:str=Form("false"),user:dict=Depends(require_admin)): - perms={"stt":perm_stt.lower()=="true","ocr":perm_ocr.lower()=="true"} - ok,msg=create_user(username,password,perms) - if not ok: raise HTTPException(400,msg) - return {"ok":True,"message":msg} +def admin_create_user( + username: str = Form(...), + password: str = Form(...), + perm_stt: str = Form("false"), + perm_ocr: str = Form("false"), + allowed_stt_models: str = Form(""), # 콤마 구분 모델명 + allowed_ocr_models: str = Form(""), + user: dict = Depends(require_admin), +): + def _parse_models(s): return [m.strip() for m in s.split(",") if m.strip()] + perms = { + "stt": perm_stt.lower() == "true", + "ocr": perm_ocr.lower() == "true", + "allowed_stt_models": _parse_models(allowed_stt_models), + "allowed_ocr_models": _parse_models(allowed_ocr_models), + } + ok, msg = create_user(username, password, perms) + if not ok: raise HTTPException(400, msg) + return {"ok": True, "message": msg} @app.put("/api/admin/users/{username}") -def admin_update_user(username:str,perm_stt:str=Form("false"),perm_ocr:str=Form("false"),password:str=Form(""),user:dict=Depends(require_admin)): - perms={"stt":perm_stt.lower()=="true","ocr":perm_ocr.lower()=="true"} - ok,msg=update_user(username,perms,password or None) - if not ok: raise HTTPException(400,msg) - return {"ok":True,"message":msg} +def admin_update_user( + username: str, + perm_stt: str = Form("false"), + perm_ocr: str = Form("false"), + password: str = Form(""), + allowed_stt_models: str = Form(""), + allowed_ocr_models: str = Form(""), + user: dict = Depends(require_admin), +): + def _parse_models(s): return [m.strip() for m in s.split(",") if m.strip()] + perms = { + "stt": perm_stt.lower() == "true", + "ocr": perm_ocr.lower() == "true", + "allowed_stt_models": _parse_models(allowed_stt_models), + "allowed_ocr_models": _parse_models(allowed_ocr_models), + } + ok, msg = update_user(username, perms, password or None) + if not ok: raise HTTPException(400, msg) + return {"ok": True, "message": msg} @app.delete("/api/admin/users/{username}") -def admin_delete_user(username:str,user:dict=Depends(require_admin)): - ok,msg=delete_user(username) - if not ok: raise HTTPException(400,msg) - return {"ok":True,"message":msg} +def admin_delete_user(username: str, user: dict=Depends(require_admin)): + ok, msg = delete_user(username) + if not ok: raise HTTPException(400, msg) + return {"ok": True, "message": msg} @app.post("/api/cleanup") -def cleanup(user:dict=Depends(require_auth)): return {"removed":_cleanup_outputs()} +def cleanup(user: dict=Depends(require_auth)): return {"removed": _cleanup_outputs()} # ════════════════════════════════════════════════════════════════ # 유틸 # ════════════════════════════════════════════════════════════════ -def _check_size(request): +def _check_size(request: Request): cl = request.headers.get("content-length") if cl and int(cl) > MAX_UPLOAD_BYTES: raise HTTPException(413, f"파일이 너무 큽니다. 최대 {MAX_UPLOAD_BYTES//1024//1024}MB") -def _cleanup_outputs(): +def _cleanup_outputs() -> int: if OUTPUT_KEEP_SECS == 0: return 0 cutoff = time.time() - OUTPUT_KEEP_SECS; removed = 0 - for f in glob.glob(os.path.join(OUTPUT_DIR,"*")): + for f in glob.glob(os.path.join(OUTPUT_DIR, "*")): try: if os.path.getmtime(f) < cutoff: os.remove(f); removed += 1 except: pass return removed -def _ext(fn): return fn.rsplit(".",1)[-1].lower() if "." in fn else "" +def _ext(fn): return fn.rsplit(".", 1)[-1].lower() if "." in fn else "" async def _save(file, path): written = 0 - async with aiofiles.open(path,"wb") as f: - while chunk := await file.read(1024*1024): + async with aiofiles.open(path, "wb") as f: + while chunk := await file.read(1024 * 1024): written += len(chunk) if written > MAX_UPLOAD_BYTES: await f.close(); os.remove(path) diff --git a/app/static/index.html b/app/static/index.html index 8514a0f..9da9725 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -582,7 +582,27 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border
-
+
+ +
+ + +
+
+ +
@@ -595,7 +615,25 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border @@ -864,15 +902,156 @@ function renderPagination(){ } // ══ ADMIN ══ -async function loadUsers(){const tbody=document.getElementById('user-tbody');tbody.innerHTML='';try{const r=await api('GET','/api/admin/users');const d=await r.json();Object.entries(d.users||{}).forEach(([name,info])=>{const tr=document.createElement('tr');const p=info.permissions||{};const isAdmin=info.role==='admin';tr.innerHTML=`${esc(name)}${info.role}${p.stt?'허용':'차단'}${p.ocr?'허용':'차단'}${isAdmin?'기본':``}`;tbody.appendChild(tr)})}catch{}} -document.getElementById('btn-reload-users').addEventListener('click',loadUsers); -document.getElementById('btn-add-user').addEventListener('click',async()=>{const u=document.getElementById('new-username').value.trim(),p=document.getElementById('new-password').value;const msg=document.getElementById('add-msg');if(!u||!p){showAdminMsg(msg,'아이디와 비밀번호를 입력하세요','err');return}const fd=new FormData();fd.append('username',u);fd.append('password',p);fd.append('perm_stt',document.getElementById('new-perm-stt').checked?'true':'false');fd.append('perm_ocr',document.getElementById('new-perm-ocr').checked?'true':'false');try{const r=await api('POST','/api/admin/users',fd);const d=await r.json();if(r.ok){showAdminMsg(msg,d.message,'ok');document.getElementById('new-username').value='';document.getElementById('new-password').value='';loadUsers()}else showAdminMsg(msg,d.detail||'실패','err')}catch{showAdminMsg(msg,'서버 오류','err')}}); -function openEditModal(n,stt,ocr){editTarget=n;document.getElementById('edit-modal-username').textContent=n;document.getElementById('edit-perm-stt').checked=stt;document.getElementById('edit-perm-ocr').checked=ocr;document.getElementById('edit-password').value='';document.getElementById('edit-msg').style.display='none';document.getElementById('edit-modal').classList.add('visible')} -document.getElementById('btn-modal-cancel').addEventListener('click',()=>document.getElementById('edit-modal').classList.remove('visible')); -document.getElementById('edit-modal').addEventListener('click',e=>{if(e.target===document.getElementById('edit-modal'))document.getElementById('edit-modal').classList.remove('visible')}); -document.getElementById('btn-modal-save').addEventListener('click',async()=>{if(!editTarget)return;const fd=new FormData();fd.append('perm_stt',document.getElementById('edit-perm-stt').checked?'true':'false');fd.append('perm_ocr',document.getElementById('edit-perm-ocr').checked?'true':'false');const pw=document.getElementById('edit-password').value;if(pw)fd.append('password',pw);try{const r=await fetch(`/api/admin/users/${editTarget}`,{method:'PUT',headers:{Authorization:'Bearer '+(token||'')},body:fd});const d=await r.json();const msg=document.getElementById('edit-msg');if(r.ok){showAdminMsg(msg,d.message,'ok');setTimeout(()=>{document.getElementById('edit-modal').classList.remove('visible');loadUsers()},800)}else showAdminMsg(msg,d.detail||'실패','err')}catch{showAdminMsg(document.getElementById('edit-msg'),'서버 오류','err')}}); -async function doDeleteUser(username){if(!confirm(`"${username}" 사용자를 삭제하시겠습니까?`))return;try{const r=await fetch(`/api/admin/users/${username}`,{method:'DELETE',headers:{Authorization:'Bearer '+(token||'')}});if(r.ok)loadUsers();else{const d=await r.json();alert(d.detail||'삭제 실패')}}catch{alert('서버 오류')}} -function showAdminMsg(el,msg,type){el.style.display='block';el.className='admin-msg '+type;el.textContent=msg;setTimeout(()=>el.style.display='none',3000)} + +// 모델 체크박스 렌더링 헬퍼 +function renderModelChecks(container, models, selected=[]) { + container.innerHTML = ''; + if (!models.length) { + container.innerHTML = '연결된 Ollama 모델 없음'; + return; + } + models.forEach(m => { + const lbl = document.createElement('label'); + lbl.className = 'perm-check'; + lbl.innerHTML = ` ${esc(m)}`; + container.appendChild(lbl); + }); +} + +// 체크된 모델 목록 수집 +function getCheckedModels(container) { + return Array.from(container.querySelectorAll('input[type=checkbox]:checked')).map(cb => cb.value); +} + +// STT 체크박스 토글 시 모델 섹션 표시 +document.getElementById('new-perm-stt').addEventListener('change', function() { + const wrap = document.getElementById('new-stt-models-wrap'); + wrap.style.display = this.checked ? 'block' : 'none'; + if (this.checked) renderModelChecks(document.getElementById('new-stt-model-checks'), whisperModels, []); +}); +document.getElementById('new-perm-ocr').addEventListener('change', function() { + const wrap = document.getElementById('new-ocr-models-wrap'); + wrap.style.display = this.checked ? 'block' : 'none'; + if (this.checked) renderModelChecks(document.getElementById('new-ocr-model-checks'), ollamaModels, []); +}); + +// Whisper 모델 목록 (하드코딩 + 환경 설정 기반) +const whisperModels = ['tiny', 'base', 'small', 'medium', 'large-v2', 'large-v3']; + +async function loadUsers() { + const tbody = document.getElementById('user-tbody'); tbody.innerHTML = ''; + try { + const r = await api('GET', '/api/admin/users'); const d = await r.json(); + Object.entries(d.users || {}).forEach(([name, info]) => { + const tr = document.createElement('tr'); + const p = info.permissions || {}; const isAdmin = info.role === 'admin'; + const sttModels = (p.allowed_stt_models||[]).length ? p.allowed_stt_models.join(', ') : '전체'; + const ocrModels = (p.allowed_ocr_models||[]).length ? p.allowed_ocr_models.join(', ') : '전체'; + tr.innerHTML = ` + ${esc(name)} + ${info.role} + + ${p.stt?'허용':'차단'} + ${p.stt?`${sttModels}`:''} + + + ${p.ocr?'허용':'차단'} + ${p.ocr?`${ocrModels}`:''} + + ${isAdmin + ? '기본' + : ` + ` + }`; + tbody.appendChild(tr); + }); + } catch {} +} +document.getElementById('btn-reload-users').addEventListener('click', loadUsers); + +document.getElementById('btn-add-user').addEventListener('click', async () => { + const u = document.getElementById('new-username').value.trim(); + const p = document.getElementById('new-password').value; + const msg = document.getElementById('add-msg'); + if (!u || !p) { showAdminMsg(msg, '아이디와 비밀번호를 입력하세요', 'err'); return; } + const fd = new FormData(); + fd.append('username', u); fd.append('password', p); + fd.append('perm_stt', document.getElementById('new-perm-stt').checked ? 'true' : 'false'); + fd.append('perm_ocr', document.getElementById('new-perm-ocr').checked ? 'true' : 'false'); + fd.append('allowed_stt_models', getCheckedModels(document.getElementById('new-stt-model-checks')).join(',')); + fd.append('allowed_ocr_models', getCheckedModels(document.getElementById('new-ocr-model-checks')).join(',')); + try { + const r = await api('POST', '/api/admin/users', fd); const d = await r.json(); + if (r.ok) { + showAdminMsg(msg, d.message, 'ok'); + document.getElementById('new-username').value = ''; + document.getElementById('new-password').value = ''; + document.getElementById('new-perm-stt').checked = false; + document.getElementById('new-perm-ocr').checked = false; + document.getElementById('new-stt-models-wrap').style.display = 'none'; + document.getElementById('new-ocr-models-wrap').style.display = 'none'; + loadUsers(); + } else showAdminMsg(msg, d.detail || '실패', 'err'); + } catch { showAdminMsg(msg, '서버 오류', 'err'); } +}); + +function openEditModal(name, perms) { + editTarget = name; + document.getElementById('edit-modal-username').textContent = name; + document.getElementById('edit-perm-stt').checked = perms.stt || false; + document.getElementById('edit-perm-ocr').checked = perms.ocr || false; + document.getElementById('edit-password').value = ''; + document.getElementById('edit-msg').style.display = 'none'; + + // STT 모델 + const sttWrap = document.getElementById('edit-stt-models-wrap'); + sttWrap.style.display = perms.stt ? 'block' : 'none'; + renderModelChecks(document.getElementById('edit-stt-model-checks'), whisperModels, perms.allowed_stt_models || []); + + // OCR 모델 + const ocrWrap = document.getElementById('edit-ocr-models-wrap'); + ocrWrap.style.display = perms.ocr ? 'block' : 'none'; + renderModelChecks(document.getElementById('edit-ocr-model-checks'), ollamaModels, perms.allowed_ocr_models || []); + + document.getElementById('edit-modal').classList.add('visible'); +} + +// 편집 모달 내 권한 체크박스 토글 +document.getElementById('edit-perm-stt').addEventListener('change', function() { + document.getElementById('edit-stt-models-wrap').style.display = this.checked ? 'block' : 'none'; +}); +document.getElementById('edit-perm-ocr').addEventListener('change', function() { + document.getElementById('edit-ocr-models-wrap').style.display = this.checked ? 'block' : 'none'; +}); + +document.getElementById('btn-modal-cancel').addEventListener('click', () => document.getElementById('edit-modal').classList.remove('visible')); +document.getElementById('edit-modal').addEventListener('click', e => { if (e.target === document.getElementById('edit-modal')) document.getElementById('edit-modal').classList.remove('visible') }); + +document.getElementById('btn-modal-save').addEventListener('click', async () => { + if (!editTarget) return; + const fd = new FormData(); + fd.append('perm_stt', document.getElementById('edit-perm-stt').checked ? 'true' : 'false'); + fd.append('perm_ocr', document.getElementById('edit-perm-ocr').checked ? 'true' : 'false'); + fd.append('allowed_stt_models', getCheckedModels(document.getElementById('edit-stt-model-checks')).join(',')); + fd.append('allowed_ocr_models', getCheckedModels(document.getElementById('edit-ocr-model-checks')).join(',')); + const pw = document.getElementById('edit-password').value; + if (pw) fd.append('password', pw); + try { + const r = await fetch(`/api/admin/users/${editTarget}`, { method: 'PUT', headers: { Authorization: 'Bearer ' + (token || '') }, body: fd }); + const d = await r.json(); const msg = document.getElementById('edit-msg'); + if (r.ok) { showAdminMsg(msg, d.message, 'ok'); setTimeout(() => { document.getElementById('edit-modal').classList.remove('visible'); loadUsers(); }, 800); } + else showAdminMsg(msg, d.detail || '실패', 'err'); + } catch { showAdminMsg(document.getElementById('edit-msg'), '서버 오류', 'err'); } +}); + +async function doDeleteUser(username) { + if (!confirm(`"${username}" 사용자를 삭제하시겠습니까?`)) return; + try { + const r = await fetch(`/api/admin/users/${username}`, { method: 'DELETE', headers: { Authorization: 'Bearer ' + (token || '') } }); + if (r.ok) loadUsers(); else { const d = await r.json(); alert(d.detail || '삭제 실패'); } + } catch { alert('서버 오류'); } +} +function showAdminMsg(el, msg, type) { el.style.display = 'block'; el.className = 'admin-msg ' + type; el.textContent = msg; setTimeout(() => el.style.display = 'none', 3000); } // ══ 공통 ══ document.addEventListener('click',e=>{if(!e.target.classList.contains('tab-btn'))return;const parent=e.target.closest('.result-tabs');parent.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));e.target.classList.add('active');const panel=parent.closest('.panel');panel.querySelectorAll('.tab-content').forEach(c=>c.classList.remove('active'));const t=document.getElementById(e.target.dataset.tab);if(t)t.classList.add('active')});