fix: 이력 처리중 버그 수정 + 관리자 모델 제한 기능

This commit is contained in:
root
2026-04-23 07:38:22 +09:00
parent 4af1279a08
commit f9075ae3f6
4 changed files with 412 additions and 181 deletions

View File

@@ -9,6 +9,8 @@ RUN apt-get update && apt-get install -y \
libxext6 \ libxext6 \
libxrender1 \ libxrender1 \
libgl1 \ libgl1 \
libgles2 \
libegl1 \
wget \ wget \
curl \ curl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
@@ -17,11 +19,8 @@ WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
# PaddlePaddle CPU (AMD64) — paddleocr 3.x 호환 RUN pip install --no-cache-dir paddlepaddle==3.0.0
RUN pip install --no-cache-dir paddlepaddle==3.0.0 \
-i https://pypi.tuna.tsinghua.edu.cn/simple
# 나머지 패키지
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .

View File

@@ -1,7 +1,16 @@
""" """
인증 모듈 — 다중 사용자 JSON 파일 기반 인증 모듈 — 다중 사용자 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 import os, json, threading
from pathlib import Path 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 DATA_DIR = Path(os.getenv("UPLOAD_DIR", "/data/uploads")).parent
USERS_FILE = DATA_DIR / "users.json" USERS_FILE = DATA_DIR / "users.json"
_lock = threading.Lock() _lock = threading.Lock()
bearer = HTTPBearer(auto_error=False) bearer = HTTPBearer(auto_error=False)
# ── 파일 I/O ────────────────────────────────────────────────── # ── 파일 I/O ──────────────────────────────────────────────────
def _load() -> dict: def _load() -> dict:
if not USERS_FILE.exists(): if not USERS_FILE.exists(): return {}
return {}
with open(USERS_FILE, "r", encoding="utf-8") as f: with open(USERS_FILE, "r", encoding="utf-8") as f:
return json.load(f) return json.load(f)
@@ -37,72 +45,66 @@ def _save(users: dict):
json.dump(users, f, ensure_ascii=False, indent=2) json.dump(users, f, ensure_ascii=False, indent=2)
# ── 초기화 (앱 시작 시 1회) ──────────────────────────────────── # ── 초기화 ────────────────────────────────────────────────────
def init_users(): def init_users():
with _lock: with _lock:
users = _load() users = _load()
# 관리자 계정은 항상 env var 기준으로 동기화
users[ADMIN_USERNAME] = { users[ADMIN_USERNAME] = {
"password": ADMIN_PASSWORD, "password": ADMIN_PASSWORD,
"role": "admin", "role": "admin",
"permissions": {"stt": True, "ocr": True}, "permissions": {
"stt": True, "ocr": True,
"allowed_stt_models": [], # 빈 배열 = 제한 없음
"allowed_ocr_models": [],
},
} }
_save(users) _save(users)
# ── CRUD ────────────────────────────────────────────────────── # ── CRUD ──────────────────────────────────────────────────────
def authenticate(username: str, password: str): def authenticate(username: str, password: str):
"""성공 시 user dict, 실패 시 None""" with _lock: users = _load()
with _lock:
users = _load()
u = users.get(username) u = users.get(username)
if not u or u["password"] != password: if not u or u["password"] != password: return None
return None
return {"username": username, **u} return {"username": username, **u}
def get_user(username: str): def get_user(username: str):
with _lock: with _lock: return _load().get(username)
return _load().get(username)
def list_users() -> dict: def list_users() -> dict:
with _lock: with _lock: users = _load()
users = _load() return {k: {kk: vv for kk, vv in v.items() if kk != "password"}
# 비밀번호 마스킹
return {k: {**{kk: vv for kk, vv in v.items() if kk != "password"}}
for k, v in users.items()} for k, v in users.items()}
def create_user(username: str, password: str, permissions: dict) -> tuple: def create_user(username: str, password: str, permissions: dict) -> tuple:
with _lock: with _lock:
users = _load() users = _load()
if username in users: if username in users: return False, "이미 존재하는 사용자입니다"
return False, "이미 존재하는 사용자입니다" # 기본값 보완
users[username] = {"password": password, "role": "user", permissions.setdefault("allowed_stt_models", [])
"permissions": permissions} permissions.setdefault("allowed_ocr_models", [])
users[username] = {"password": password, "role": "user", "permissions": permissions}
_save(users) _save(users)
return True, "사용자가 생성되었습니다" return True, "사용자가 생성되었습니다"
def update_user(username: str, permissions: dict, password: str = None) -> tuple: def update_user(username: str, permissions: dict, password: str = None) -> tuple:
if username == ADMIN_USERNAME: if username == ADMIN_USERNAME: return False, "기본 관리자 계정은 수정할 수 없습니다"
return False, "기본 관리자 계정은 수정할 수 없습니다"
with _lock: with _lock:
users = _load() users = _load()
if username not in users: if username not in users: return False, "사용자를 찾을 수 없습니다"
return False, "사용자를 찾을 수 없습니다" permissions.setdefault("allowed_stt_models", [])
permissions.setdefault("allowed_ocr_models", [])
users[username]["permissions"] = permissions users[username]["permissions"] = permissions
if password: if password: users[username]["password"] = password
users[username]["password"] = password
_save(users) _save(users)
return True, "업데이트되었습니다" return True, "업데이트되었습니다"
def delete_user(username: str) -> tuple: def delete_user(username: str) -> tuple:
if username == ADMIN_USERNAME: if username == ADMIN_USERNAME: return False, "기본 관리자 계정은 삭제할 수 없습니다"
return False, "기본 관리자 계정은 삭제할 수 없습니다"
with _lock: with _lock:
users = _load() users = _load()
if username not in users: if username not in users: return False, "사용자를 찾을 수 없습니다"
return False, "사용자를 찾을 수 없습니다" del users[username]; _save(users)
del users[username]
_save(users)
return True, "삭제되었습니다" return True, "삭제되었습니다"
@@ -112,35 +114,28 @@ def create_access_token(username: str) -> str:
return jwt.encode({"sub": username, "exp": exp}, SECRET_KEY, algorithm=ALGORITHM) return jwt.encode({"sub": username, "exp": exp}, SECRET_KEY, algorithm=ALGORITHM)
# ── FastAPI 의존성 ──────────────────────────────────────────── # ── FastAPI 의존성 ────────────────────────────────────────────
def require_auth(credentials: HTTPAuthorizationCredentials = Depends(bearer)) -> dict: def require_auth(credentials: HTTPAuthorizationCredentials = Depends(bearer)) -> dict:
if credentials is None: if credentials is None:
raise HTTPException(401, "인증이 필요합니다", raise HTTPException(401, "인증이 필요합니다", headers={"WWW-Authenticate": "Bearer"})
headers={"WWW-Authenticate": "Bearer"})
try: try:
payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM]) payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
username = payload.get("sub") username = payload.get("sub")
if not username: if not username: raise JWTError()
raise JWTError()
u = get_user(username) u = get_user(username)
if not u: if not u: raise JWTError()
raise JWTError()
return {"username": username, **u} return {"username": username, **u}
except JWTError: except JWTError:
raise HTTPException(401, "토큰이 유효하지 않거나 만료되었습니다", raise HTTPException(401, "토큰이 유효하지 않거나 만료되었습니다", headers={"WWW-Authenticate": "Bearer"})
headers={"WWW-Authenticate": "Bearer"})
def require_admin(user: dict = Depends(require_auth)) -> dict: def require_admin(user: dict = Depends(require_auth)) -> dict:
if user.get("role") != "admin": if user.get("role") != "admin": raise HTTPException(403, "관리자 권한이 필요합니다")
raise HTTPException(403, "관리자 권한이 필요합니다")
return user return user
def require_stt(user: dict = Depends(require_auth)) -> dict: def require_stt(user: dict = Depends(require_auth)) -> dict:
if not user.get("permissions", {}).get("stt", False): if not user.get("permissions", {}).get("stt", False): raise HTTPException(403, "STT 사용 권한이 없습니다")
raise HTTPException(403, "STT 사용 권한이 없습니다")
return user return user
def require_ocr(user: dict = Depends(require_auth)) -> dict: def require_ocr(user: dict = Depends(require_auth)) -> dict:
if not user.get("permissions", {}).get("ocr", False): if not user.get("permissions", {}).get("ocr", False): raise HTTPException(403, "OCR 사용 권한이 없습니다")
raise HTTPException(403, "OCR 사용 권한이 없습니다")
return user return user

View File

@@ -35,27 +35,22 @@ _DEFAULT_SETTINGS = {
"stt_ollama_model": "", "stt_ollama_model": "",
"ocr_ollama_model": "granite3.2-vision:latest", "ocr_ollama_model": "granite3.2-vision:latest",
"cpu_threads": 0, "cpu_threads": 0,
"stt_timeout": 0, # 0 = 무제한 "stt_timeout": 0,
"ollama_timeout": 600, # 초 "ollama_timeout": 600,
} }
_hist_lock = threading.Lock() _hist_lock = threading.Lock()
# ── 설정 I/O ───────────────────────────────────────────────── # ── 설정 I/O ─────────────────────────────────────────────────
def _load_settings() -> dict: def _load_settings() -> dict:
if not SETTINGS_FILE.exists(): if not SETTINGS_FILE.exists(): return dict(_DEFAULT_SETTINGS)
return dict(_DEFAULT_SETTINGS) with open(SETTINGS_FILE, "r", encoding="utf-8") as f: data = json.load(f)
with open(SETTINGS_FILE, "r", encoding="utf-8") as f: for k, v in _DEFAULT_SETTINGS.items(): data.setdefault(k, v)
data = json.load(f)
for k, v in _DEFAULT_SETTINGS.items():
data.setdefault(k, v)
return data return data
def _save_settings(data: dict): def _save_settings(data: dict):
SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True) SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(SETTINGS_FILE, "w", encoding="utf-8") as f: with open(SETTINGS_FILE, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2)
json.dump(data, f, ensure_ascii=False, indent=2)
# ── 이력 I/O ───────────────────────────────────────────────── # ── 이력 I/O ─────────────────────────────────────────────────
@@ -66,6 +61,11 @@ def _load_history() -> list:
with open(HISTORY_FILE, "r", encoding="utf-8") as f: return json.load(f) with open(HISTORY_FILE, "r", encoding="utf-8") as f: return json.load(f)
except: return [] 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): def append_history(record: dict):
with _hist_lock: with _hist_lock:
try: try:
@@ -73,52 +73,47 @@ def append_history(record: dict):
if HISTORY_FILE.exists(): if HISTORY_FILE.exists():
with open(HISTORY_FILE, "r", encoding="utf-8") as f: history = json.load(f) with open(HISTORY_FILE, "r", encoding="utf-8") as f: history = json.load(f)
history.insert(0, record) history.insert(0, record)
history = history[:HISTORY_MAX] _write_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)
except: pass 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: with _hist_lock:
if not HISTORY_FILE.exists(): return if not HISTORY_FILE.exists(): return
try: try:
with open(HISTORY_FILE, "r", encoding="utf-8") as f: history = json.load(f) with open(HISTORY_FILE, "r", encoding="utf-8") as f: history = json.load(f)
for h in history: for h in history:
if h.get("id") == file_id and h.get("status") == "processing": # task_id 필드로 매칭
h["status"] = "success" if h.get("task_id") != task_id: continue
if h["type"] == "stt": if h.get("status") != "processing": break
h["output"] = { if not success:
"filename": result.get("output_file",""), h["status"] = "failed"
"language": result.get("language",""), h["output"] = {"error": error_msg[:300]}
"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","")),
}
break break
with open(HISTORY_FILE, "w", encoding="utf-8") as f: json.dump(history, f, ensure_ascii=False, indent=2) h["status"] = "success"
except: pass if h["type"] == "stt":
h["output"] = {
def _update_history_fail(file_id: str, error_msg: str): "filename": result.get("output_file", ""),
with _hist_lock: "language": result.get("language", ""),
if not HISTORY_FILE.exists(): return "duration_s": result.get("duration", 0),
try: "segments": len(result.get("segments", [])),
with open(HISTORY_FILE, "r", encoding="utf-8") as f: history = json.load(f) "text_preview": result.get("text", "")[:200] + ("" if len(result.get("text",""))>200 else ""),
for h in history: "ollama_used": result.get("ollama_used", False),
if h.get("id") == file_id and h.get("status") == "processing": "ollama_model": result.get("ollama_model", ""),
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) 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 except: pass
def delete_history_item(history_id: str) -> bool: 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) 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] new = [h for h in history if h.get("id") != history_id]
if len(new) == len(history): return False 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) _write_history(new); return True
return True
except: return False except: return False
def clear_history(): def clear_history():
@@ -166,9 +160,7 @@ def me(user: dict = Depends(require_auth)):
# ════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════
@app.get("/api/system") @app.get("/api/system")
def system_info(user: dict = Depends(require_auth)): def system_info(user: dict = Depends(require_auth)):
mem = psutil.virtual_memory() mem = psutil.virtual_memory(); swap = psutil.swap_memory(); s = _load_settings()
swap = psutil.swap_memory()
s = _load_settings()
return { return {
"ram_total_gb": round(mem.total / 1024**3, 1), "ram_total_gb": round(mem.total / 1024**3, 1),
"ram_used_gb": round(mem.used / 1024**3, 1), "ram_used_gb": round(mem.used / 1024**3, 1),
@@ -197,22 +189,36 @@ async def transcribe(
_check_size(request) _check_size(request)
ext = _ext(file.filename) ext = _ext(file.filename)
if ext not in AUDIO_EXT: raise HTTPException(400, f"지원하지 않는 형식: {', '.join(sorted(AUDIO_EXT))}") 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}") save_path = os.path.join(UPLOAD_DIR, f"{file_id}.{ext}")
await _save(file, save_path) await _save(file, save_path)
file_size = os.path.getsize(save_path) file_size = os.path.getsize(save_path)
_use_ollama = use_ollama.lower() == "true" _use_ollama = use_ollama.lower() == "true"
s = _load_settings() s = _load_settings()
if _use_ollama and not ollama_model.strip(): ollama_model = s.get("stt_ollama_model","") 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})
task = transcribe_task.delay(file_id, save_path, _use_ollama, 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} 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" if backend not in ("paddle","ollama"): backend = "paddle"
s = _load_settings() s = _load_settings()
if backend == "ollama" and not ollama_model.strip(): ollama_model = s.get("ocr_ollama_model","granite3.2-vision:latest") 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}") save_path = os.path.join(UPLOAD_DIR, f"{file_id}.{ext}")
await _save(file, save_path) await _save(file, save_path)
file_size = os.path.getsize(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 = 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} return {"task_id": task.id, "file_id": file_id, "filename": file.filename, "mode": mode, "backend": backend}
# ════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════
# 상태 # 상태 — task_id 기준으로 이력 업데이트
# ════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════
@app.get("/api/status/{task_id}") @app.get("/api/status/{task_id}")
def get_status(task_id: str, user: dict = Depends(require_auth)): def get_status(task_id: str, user: dict = Depends(require_auth)):
r = celery_app.AsyncResult(task_id) r = celery_app.AsyncResult(task_id)
if r.state == "PENDING": return {"state":"pending", "progress":0, "message":"대기 중..."} if r.state == "PENDING":
if r.state == "PROGRESS": m=r.info or {}; return {"state":"progress","progress":m.get("progress",0),"message":m.get("message","처리 중...")} return {"state": "pending", "progress": 0, "message": "대기 중..."}
if r.state == "SUCCESS": _update_history(task_id, r.result or {}); return {"state":"success","progress":100,**(r.result or {})} if r.state == "PROGRESS":
if r.state == "FAILURE": _update_history_fail(task_id, str(r.info)); return {"state":"failure","progress":0,"message":str(r.info)} m = r.info or {}
return {"state":r.state.lower(),"progress":0} 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 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_] if type_ in ("stt","ocr"): history = [h for h in history if h.get("type")==type_]
total = len(history); start = (page-1)*per_page 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}") @app.delete("/api/history/{history_id}")
def delete_history(history_id: str, user: dict=Depends(require_auth)): def delete_history(history_id: str, user: dict=Depends(require_auth)):
if not delete_history_item(history_id): raise HTTPException(404,"이력을 찾을 수 없습니다") if not delete_history_item(history_id): raise HTTPException(404, "이력을 찾을 수 없습니다")
return {"ok":True} return {"ok": True}
@app.delete("/api/history") @app.delete("/api/history")
def clear_all_history(user: dict=Depends(require_admin)): 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}") @app.get("/api/download/{filename}")
def download(filename: str, user: dict=Depends(require_auth)): 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) path = os.path.join(OUTPUT_DIR, filename)
if not os.path.exists(path): raise HTTPException(404,"파일을 찾을 수 없습니다") if not os.path.exists(path): raise HTTPException(404, "파일을 찾을 수 없습니다")
media = ("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" if filename.endswith(".xlsx") else "text/plain") media = ("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
if filename.endswith(".xlsx") else "text/plain")
return FileResponse(path, media_type=media, filename=filename) return FileResponse(path, media_type=media, filename=filename)
@app.get("/api/ollama/models") @app.get("/api/ollama/models")
def ollama_models(user: dict=Depends(require_auth)): def ollama_models(user: dict=Depends(require_auth)):
try: try:
resp = httpx.get(f"{OLLAMA_URL}/api/tags", timeout=8.0); resp.raise_for_status() 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} return {"models": [m["name"] for m in resp.json().get("models",[])], "connected": True}
except Exception as e: return {"models":[], "connected":False, "error":str(e)} except Exception as e:
return {"models": [], "connected": False, "error": str(e)}
@app.get("/api/settings") @app.get("/api/settings")
def get_settings(user: dict=Depends(require_auth)): return _load_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"), ollama_timeout: str = Form("600"),
user: dict = Depends(require_auth), user: dict = Depends(require_auth),
): ):
def _int(v, default): def _int(v, d):
try: return max(0, int(v)) try: return max(0, int(v))
except: return default except: return d
data = { data = {
"stt_ollama_model": stt_ollama_model, "stt_ollama_model": stt_ollama_model,
"ocr_ollama_model": ocr_ollama_model, "ocr_ollama_model": ocr_ollama_model,
@@ -324,57 +354,85 @@ def save_settings_endpoint(
"ollama_timeout": _int(ollama_timeout, 600), "ollama_timeout": _int(ollama_timeout, 600),
} }
_save_settings(data) _save_settings(data)
return {"ok":True, "settings":data} return {"ok": True, "settings": data}
@app.get("/api/admin/users") @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") @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)): def admin_create_user(
perms={"stt":perm_stt.lower()=="true","ocr":perm_ocr.lower()=="true"} username: str = Form(...),
ok,msg=create_user(username,password,perms) password: str = Form(...),
if not ok: raise HTTPException(400,msg) perm_stt: str = Form("false"),
return {"ok":True,"message":msg} 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}") @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)): def admin_update_user(
perms={"stt":perm_stt.lower()=="true","ocr":perm_ocr.lower()=="true"} username: str,
ok,msg=update_user(username,perms,password or None) perm_stt: str = Form("false"),
if not ok: raise HTTPException(400,msg) perm_ocr: str = Form("false"),
return {"ok":True,"message":msg} 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}") @app.delete("/api/admin/users/{username}")
def admin_delete_user(username:str,user:dict=Depends(require_admin)): def admin_delete_user(username: str, user: dict=Depends(require_admin)):
ok,msg=delete_user(username) ok, msg = delete_user(username)
if not ok: raise HTTPException(400,msg) if not ok: raise HTTPException(400, msg)
return {"ok":True,"message":msg} return {"ok": True, "message": msg}
@app.post("/api/cleanup") @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") cl = request.headers.get("content-length")
if cl and int(cl) > MAX_UPLOAD_BYTES: raise HTTPException(413, f"파일이 너무 큽니다. 최대 {MAX_UPLOAD_BYTES//1024//1024}MB") 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 if OUTPUT_KEEP_SECS == 0: return 0
cutoff = time.time() - OUTPUT_KEEP_SECS; removed = 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: try:
if os.path.getmtime(f) < cutoff: os.remove(f); removed += 1 if os.path.getmtime(f) < cutoff: os.remove(f); removed += 1
except: pass except: pass
return removed 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): async def _save(file, path):
written = 0 written = 0
async with aiofiles.open(path,"wb") as f: async with aiofiles.open(path, "wb") as f:
while chunk := await file.read(1024*1024): while chunk := await file.read(1024 * 1024):
written += len(chunk) written += len(chunk)
if written > MAX_UPLOAD_BYTES: if written > MAX_UPLOAD_BYTES:
await f.close(); os.remove(path) await f.close(); os.remove(path)

View File

@@ -582,7 +582,27 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border
<div class="form-group"><label>아이디</label><input type="text" class="form-input" id="new-username" placeholder="username" autocomplete="off"></div> <div class="form-group"><label>아이디</label><input type="text" class="form-input" id="new-username" placeholder="username" autocomplete="off"></div>
<div class="form-group"><label>비밀번호</label><input type="password" class="form-input" id="new-password" placeholder="password" autocomplete="new-password"></div> <div class="form-group"><label>비밀번호</label><input type="password" class="form-input" id="new-password" placeholder="password" autocomplete="new-password"></div>
</div> </div>
<div class="form-group"><label>사용 권한</label><div class="perm-checks"><label class="perm-check"><input type="checkbox" id="new-perm-stt"> STT 음성변환</label><label class="perm-check"><input type="checkbox" id="new-perm-ocr"> OCR 이미지인식</label></div></div> <div class="form-group" style="margin-bottom:12px">
<label>기능 권한</label>
<div class="perm-checks">
<label class="perm-check"><input type="checkbox" id="new-perm-stt"> STT 음성변환</label>
<label class="perm-check"><input type="checkbox" id="new-perm-ocr"> OCR 이미지인식</label>
</div>
</div>
<div id="new-stt-models-wrap" style="margin-bottom:12px;display:none">
<label style="font-family:var(--mono);font-size:.62rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase">
STT Whisper 모델 제한
<span style="color:var(--muted);font-size:.6rem;text-transform:none;margin-left:4px">· 선택 없음 = 모두 허용</span>
</label>
<div id="new-stt-model-checks" style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px"></div>
</div>
<div id="new-ocr-models-wrap" style="margin-bottom:12px;display:none">
<label style="font-family:var(--mono);font-size:.62rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase">
OCR Ollama 모델 제한
<span style="color:var(--muted);font-size:.6rem;text-transform:none;margin-left:4px">· 선택 없음 = 모두 허용</span>
</label>
<div id="new-ocr-model-checks" style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px"></div>
</div>
<div style="margin-top:14px"><button class="btn-add" id="btn-add-user">사용자 추가</button></div> <div style="margin-top:14px"><button class="btn-add" id="btn-add-user">사용자 추가</button></div>
<div class="admin-msg" id="add-msg"></div> <div class="admin-msg" id="add-msg"></div>
</div> </div>
@@ -595,7 +615,25 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border
<div class="modal-box"> <div class="modal-box">
<div class="modal-title">권한 편집 — <span id="edit-modal-username"></span></div> <div class="modal-title">권한 편집 — <span id="edit-modal-username"></span></div>
<div class="form-group"><label style="font-family:var(--mono);font-size:.62rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase">새 비밀번호 (변경 시에만)</label><input type="password" class="form-input" id="edit-password" placeholder="비워두면 변경 안 함" style="width:100%;margin-top:5px" autocomplete="new-password"></div> <div class="form-group"><label style="font-family:var(--mono);font-size:.62rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase">새 비밀번호 (변경 시에만)</label><input type="password" class="form-input" id="edit-password" placeholder="비워두면 변경 안 함" style="width:100%;margin-top:5px" autocomplete="new-password"></div>
<div class="form-group" style="margin-top:14px"><label style="font-family:var(--mono);font-size:.62rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase">사용 권한</label><div class="perm-checks" style="margin-top:6px"><label class="perm-check"><input type="checkbox" id="edit-perm-stt"> STT 음성변환</label><label class="perm-check"><input type="checkbox" id="edit-perm-ocr"> OCR 이미지인식</label></div></div> <div class="form-group" style="margin-top:14px">
<label style="font-family:var(--mono);font-size:.62rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase">기능 권한</label>
<div class="perm-checks" style="margin-top:6px">
<label class="perm-check"><input type="checkbox" id="edit-perm-stt"> STT 음성변환</label>
<label class="perm-check"><input type="checkbox" id="edit-perm-ocr"> OCR 이미지인식</label>
</div>
</div>
<div id="edit-stt-models-wrap" style="margin-top:12px;display:none">
<label style="font-family:var(--mono);font-size:.62rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase">
STT 모델 제한 <span style="color:var(--muted);font-size:.6rem;text-transform:none;margin-left:4px">· 선택 없음 = 모두 허용</span>
</label>
<div id="edit-stt-model-checks" style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px"></div>
</div>
<div id="edit-ocr-models-wrap" style="margin-top:12px;display:none">
<label style="font-family:var(--mono);font-size:.62rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase">
OCR 모델 제한 <span style="color:var(--muted);font-size:.6rem;text-transform:none;margin-left:4px">· 선택 없음 = 모두 허용</span>
</label>
<div id="edit-ocr-model-checks" style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px"></div>
</div>
<div class="modal-actions"><button class="btn-sm" id="btn-modal-cancel">취소</button><button class="btn-add" id="btn-modal-save">저장</button></div> <div class="modal-actions"><button class="btn-sm" id="btn-modal-cancel">취소</button><button class="btn-add" id="btn-modal-save">저장</button></div>
<div class="admin-msg" id="edit-msg"></div> <div class="admin-msg" id="edit-msg"></div>
</div> </div>
@@ -864,15 +902,156 @@ function renderPagination(){
} }
// ══ ADMIN ══ // ══ 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=`<td style="font-family:var(--mono);font-size:.78rem">${esc(name)}</td><td><span class="role-badge ${info.role}">${info.role}</span></td><td><span class="perm-badge ${p.stt?'on':'off'}">${p.stt?'허용':'차단'}</span></td><td><span class="perm-badge ${p.ocr?'on':'off'}">${p.ocr?'허용':'차단'}</span></td><td>${isAdmin?'<span style="font-family:var(--mono);font-size:.6rem;color:var(--muted)">기본</span>':`<button class="btn-sm" onclick="openEditModal('${esc(name)}',${p.stt},${p.ocr})">편집</button><button class="btn-sm danger" onclick="doDeleteUser('${esc(name)}')">삭제</button>`}</td>`;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 renderModelChecks(container, models, selected=[]) {
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')} container.innerHTML = '';
document.getElementById('btn-modal-cancel').addEventListener('click',()=>document.getElementById('edit-modal').classList.remove('visible')); if (!models.length) {
document.getElementById('edit-modal').addEventListener('click',e=>{if(e.target===document.getElementById('edit-modal'))document.getElementById('edit-modal').classList.remove('visible')}); container.innerHTML = '<span style="font-family:var(--mono);font-size:.65rem;color:var(--muted)">연결된 Ollama 모델 없음</span>';
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')}}); return;
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)} models.forEach(m => {
const lbl = document.createElement('label');
lbl.className = 'perm-check';
lbl.innerHTML = `<input type="checkbox" value="${esc(m)}"${selected.includes(m)?' checked':''}> <span style="font-size:.7rem">${esc(m)}</span>`;
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 = `
<td style="font-family:var(--mono);font-size:.78rem">${esc(name)}</td>
<td><span class="role-badge ${info.role}">${info.role}</span></td>
<td>
<span class="perm-badge ${p.stt?'on':'off'}">${p.stt?'허용':'차단'}</span>
${p.stt?`<span style="font-family:var(--mono);font-size:.58rem;color:var(--muted)">${sttModels}</span>`:''}
</td>
<td>
<span class="perm-badge ${p.ocr?'on':'off'}">${p.ocr?'허용':'차단'}</span>
${p.ocr?`<span style="font-family:var(--mono);font-size:.58rem;color:var(--muted)">${ocrModels}</span>`:''}
</td>
<td>${isAdmin
? '<span style="font-family:var(--mono);font-size:.6rem;color:var(--muted)">기본</span>'
: `<button class="btn-sm" onclick="openEditModal('${esc(name)}',${JSON.stringify(p)})">편집</button>
<button class="btn-sm danger" onclick="doDeleteUser('${esc(name)}')">삭제</button>`
}</td>`;
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')}); 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')});