From cfa8c98872181f36473cbccba19e00227a970a11 Mon Sep 17 00:00:00 2001 From: dsyoon Date: Mon, 9 Feb 2026 19:55:40 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A7=81=ED=81=AC=20=EA=B3=B5=EC=9C=A0=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5(links.json)=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Flask에 /api/links 추가: GET은 links.json 로드, PUT은 관리자만 links.json 저장 - 프론트는 /api/links 사용 가능 시 serverMode로 전환하여 추가/편집/삭제/즐겨찾기/가져오기를 서버에 저장 Co-authored-by: Cursor --- flask_app.py | 73 +++++++++++++++++++++++++++++++++++++++++ script.js | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 161 insertions(+), 3 deletions(-) diff --git a/flask_app.py b/flask_app.py index e57b548..b5ce23c 100644 --- a/flask_app.py +++ b/flask_app.py @@ -169,6 +169,35 @@ def bearer_token() -> str: return m.group(1).strip() if m else "" +def verify_admin_from_request() -> Tuple[bool, str]: + """ + Returns (is_admin, email_lowercase). + Uses the same headers as /api/auth/sync: + - Authorization: Bearer + - X-Auth0-Issuer + - X-Auth0-ClientId + """ + id_token = bearer_token() + if not id_token: + return (False, "") + + issuer = str(request.headers.get("X-Auth0-Issuer", "")).strip() + audience = str(request.headers.get("X-Auth0-ClientId", "")).strip() + if not issuer or not audience: + return (False, "") + + payload = verify_id_token(id_token, issuer=issuer, audience=audience) + email = (str(payload.get("email")).strip().lower() if payload.get("email") else "") + return (bool(email and is_admin_email(email)), email) + + +def safe_write_json(path: Path, data: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + tmp.replace(path) + + @dataclass(frozen=True) class JwksCacheEntry: jwks_url: str @@ -232,6 +261,7 @@ def verify_id_token(id_token: str, issuer: str, audience: str) -> dict: ROOT_DIR = Path(__file__).resolve().parent +LINKS_FILE = ROOT_DIR / "links.json" app = Flask(__name__) @@ -467,6 +497,49 @@ def api_config_auth_get() -> Response: return jsonify({"ok": False, "error": "server_error"}), 500 +@app.get("/api/links") +def api_links_get() -> Response: + """ + Shared links source for all browsers. + Reads from links.json on disk (same directory). + """ + try: + if not LINKS_FILE.exists(): + return jsonify({"ok": True, "links": []}) + raw = LINKS_FILE.read_text(encoding="utf-8") + data = json.loads(raw) if raw.strip() else [] + links = data if isinstance(data, list) else data.get("links") if isinstance(data, dict) else [] + if not isinstance(links, list): + links = [] + return jsonify({"ok": True, "links": links}) + except Exception: + return jsonify({"ok": False, "error": "server_error"}), 500 + + +@app.put("/api/links") +def api_links_put() -> Response: + """ + Admin-only: overwrite shared links.json with provided array. + Body can be: + - JSON array + - {"links":[...]} + """ + try: + ok_admin, _email = verify_admin_from_request() + if not ok_admin: + return jsonify({"ok": False, "error": "forbidden"}), 403 + + body = request.get_json(silent=True) + links = body if isinstance(body, list) else body.get("links") if isinstance(body, dict) else None + if not isinstance(links, list): + return jsonify({"ok": False, "error": "invalid_body"}), 400 + + safe_write_json(LINKS_FILE, links) + return jsonify({"ok": True, "count": len(links)}) + except Exception: + return jsonify({"ok": False, "error": "server_error"}), 500 + + @app.post("/api/config/auth") def api_config_auth_post() -> Response: try: diff --git a/script.js b/script.js index b2a16e4..dfa214d 100644 --- a/script.js +++ b/script.js @@ -58,6 +58,7 @@ sortKey: "json", onlyFav: false, canManage: false, + serverMode: false, // true when /api/links is available }; // Access levels (open/copy) @@ -124,6 +125,7 @@ ready: false, mode: "disabled", // enabled | misconfigured | sdk_missing | disabled serverCanManage: null, + idTokenRaw: "", }; function nowIso() { @@ -294,6 +296,10 @@ } function getMergedLinks() { + if (state.serverMode) { + // In serverMode, baseLinks is the shared source of truth. + return (state.baseLinks || []).map(normalizeLink); + } const tomb = new Set(state.store.tombstones || []); const overrides = state.store.overridesById || {}; @@ -793,6 +799,7 @@ async function syncUserToServerWithIdToken(idToken) { try { + auth.idTokenRaw = String(idToken || ""); const cfg = getAuthConfig(); const r = await fetch(apiUrl("/api/auth/sync"), { method: "POST", @@ -818,6 +825,47 @@ } } + async function saveLinksToServer(links) { + if (!auth.idTokenRaw) return false; + try { + const cfg = getAuthConfig(); + const r = await fetch(apiUrl("/api/links"), { + method: "PUT", + headers: { + Authorization: `Bearer ${auth.idTokenRaw}`, + "X-Auth0-Issuer": `https://${cfg.auth0.domain}/`, + "X-Auth0-ClientId": cfg.auth0.clientId, + "Content-Type": "application/json", + }, + body: JSON.stringify(Array.isArray(links) ? links : []), + }); + return r.ok; + } catch { + return false; + } + } + + async function loadLinksFromServer() { + try { + const r = await fetch(apiUrl("/api/links"), { cache: "no-store" }); + if (!r.ok) return null; + const data = await r.json(); + const list = data && Array.isArray(data.links) ? data.links : null; + if (!list) return null; + return list.map(normalizeLink); + } catch { + return null; + } + } + + async function persistLinksIfServerMode() { + if (!state.serverMode) return; + if (!state.canManage) return; + const links = getMergedLinks(); // in serverMode this is baseLinks + const ok = await saveLinksToServer(links); + if (!ok) toastOnce("savefail", "서버 저장(links.json)에 실패했습니다. 권한/서버 로그를 확인하세요."); + } + function sendLogoutToServer(idToken) { if (!idToken) return; const cfg = getAuthConfig(); @@ -939,6 +987,7 @@ auth.serverCanManage = null; const t = loadTokens(); if (auth.user && t && t.id_token) { + auth.idTokenRaw = String(t.id_token || ""); const can = await syncUserToServerWithIdToken(t.id_token); if (typeof can === "boolean") { auth.serverCanManage = can; @@ -998,6 +1047,7 @@ const claims = await auth.client.getIdTokenClaims(); const raw = claims && claims.__raw ? String(claims.__raw) : ""; if (raw) { + auth.idTokenRaw = raw; const cfg = getAuthConfig(); const r = await fetch(apiUrl("/api/auth/sync"), { method: "POST", @@ -1107,6 +1157,14 @@ } function upsertCustom(link) { + if (state.serverMode) { + const n = normalizeLink(link); + const idx = state.baseLinks.findIndex((c) => c && c.id === n.id); + if (idx >= 0) state.baseLinks[idx] = n; + else state.baseLinks.push(n); + state.baseOrder = new Map(state.baseLinks.map((l, i) => [l.id, i])); + return; + } const n = normalizeLink(link); const idx = state.store.custom.findIndex((c) => c && c.id === n.id); if (idx >= 0) state.store.custom[idx] = n; @@ -1115,6 +1173,13 @@ } function deleteLink(id) { + if (state.serverMode) { + const before = state.baseLinks.length; + state.baseLinks = (state.baseLinks || []).filter((l) => l && l.id !== id); + state.baseOrder = new Map(state.baseLinks.map((l, i) => [l.id, i])); + if (state.baseLinks.length !== before) toast("삭제했습니다."); + return; + } if (isBaseId(id)) { const s = new Set(state.store.tombstones || []); s.add(id); @@ -1134,6 +1199,11 @@ const link = getLinkById(id); if (!link) return; const next = !link.favorite; + if (state.serverMode) { + upsertCustom({ ...link, favorite: next, updatedAt: nowIso() }); + render(); + return; + } if (isBaseId(id)) { setOverride(id, { favorite: next, updatedAt: nowIso() }); } else { @@ -1231,10 +1301,12 @@ } used.add(n.id); // 가져오기는 custom로 추가(기본과 충돌 방지) - state.store.custom.push(n); + if (state.serverMode) state.baseLinks.push(n); + else state.store.custom.push(n); added++; } - saveStore(); + if (!state.serverMode) saveStore(); + state.baseOrder = new Map(state.baseLinks.map((l, i) => [l.id, i])); toast(`가져오기 완료: ${added}개`); } @@ -1259,6 +1331,7 @@ } if (act === "fav") { toggleFavorite(id); + persistLinksIfServerMode(); return; } if (act === "copy") { @@ -1276,6 +1349,7 @@ if (confirm(`삭제할까요?\n\n- ${name}`)) { deleteLink(id); render(); + persistLinksIfServerMode(); } return; } @@ -1323,6 +1397,7 @@ closeModal(); render(); toast("저장했습니다."); + persistLinksIfServerMode(); return; } @@ -1340,6 +1415,7 @@ closeModal(); render(); toast("추가했습니다."); + persistLinksIfServerMode(); } function wire() { @@ -1415,7 +1491,16 @@ wire(); await hydrateAuthConfigFromServerIfNeeded(); await initAuth(); - state.baseLinks = await loadBaseLinks(); + const serverLinks = await loadLinksFromServer(); + if (serverLinks) { + state.serverMode = true; + state.baseLinks = serverLinks; + // serverMode에서는 localStorage 기반 커스텀/오버라이드는 사용하지 않음(공유 JSON이 진실) + state.store = { overridesById: {}, tombstones: [], custom: [] }; + saveStore(); + } else { + state.baseLinks = await loadBaseLinks(); + } state.baseOrder = new Map(state.baseLinks.map((l, i) => [l.id, i])); el.sort.value = state.sortKey; applyManageLock();