링크 공유 저장(links.json) 지원

- Flask에 /api/links 추가: GET은 links.json 로드, PUT은 관리자만 links.json 저장
- 프론트는 /api/links 사용 가능 시 serverMode로 전환하여 추가/편집/삭제/즐겨찾기/가져오기를 서버에 저장

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dsyoon
2026-02-09 19:55:40 +09:00
parent 9074764273
commit cfa8c98872
2 changed files with 161 additions and 3 deletions

View File

@@ -169,6 +169,35 @@ def bearer_token() -> str:
return m.group(1).strip() if m else "" 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 <id_token>
- 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) @dataclass(frozen=True)
class JwksCacheEntry: class JwksCacheEntry:
jwks_url: str 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 ROOT_DIR = Path(__file__).resolve().parent
LINKS_FILE = ROOT_DIR / "links.json"
app = Flask(__name__) app = Flask(__name__)
@@ -467,6 +497,49 @@ def api_config_auth_get() -> Response:
return jsonify({"ok": False, "error": "server_error"}), 500 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") @app.post("/api/config/auth")
def api_config_auth_post() -> Response: def api_config_auth_post() -> Response:
try: try:

View File

@@ -58,6 +58,7 @@
sortKey: "json", sortKey: "json",
onlyFav: false, onlyFav: false,
canManage: false, canManage: false,
serverMode: false, // true when /api/links is available
}; };
// Access levels (open/copy) // Access levels (open/copy)
@@ -124,6 +125,7 @@
ready: false, ready: false,
mode: "disabled", // enabled | misconfigured | sdk_missing | disabled mode: "disabled", // enabled | misconfigured | sdk_missing | disabled
serverCanManage: null, serverCanManage: null,
idTokenRaw: "",
}; };
function nowIso() { function nowIso() {
@@ -294,6 +296,10 @@
} }
function getMergedLinks() { 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 tomb = new Set(state.store.tombstones || []);
const overrides = state.store.overridesById || {}; const overrides = state.store.overridesById || {};
@@ -793,6 +799,7 @@
async function syncUserToServerWithIdToken(idToken) { async function syncUserToServerWithIdToken(idToken) {
try { try {
auth.idTokenRaw = String(idToken || "");
const cfg = getAuthConfig(); const cfg = getAuthConfig();
const r = await fetch(apiUrl("/api/auth/sync"), { const r = await fetch(apiUrl("/api/auth/sync"), {
method: "POST", 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) { function sendLogoutToServer(idToken) {
if (!idToken) return; if (!idToken) return;
const cfg = getAuthConfig(); const cfg = getAuthConfig();
@@ -939,6 +987,7 @@
auth.serverCanManage = null; auth.serverCanManage = null;
const t = loadTokens(); const t = loadTokens();
if (auth.user && t && t.id_token) { if (auth.user && t && t.id_token) {
auth.idTokenRaw = String(t.id_token || "");
const can = await syncUserToServerWithIdToken(t.id_token); const can = await syncUserToServerWithIdToken(t.id_token);
if (typeof can === "boolean") { if (typeof can === "boolean") {
auth.serverCanManage = can; auth.serverCanManage = can;
@@ -998,6 +1047,7 @@
const claims = await auth.client.getIdTokenClaims(); const claims = await auth.client.getIdTokenClaims();
const raw = claims && claims.__raw ? String(claims.__raw) : ""; const raw = claims && claims.__raw ? String(claims.__raw) : "";
if (raw) { if (raw) {
auth.idTokenRaw = raw;
const cfg = getAuthConfig(); const cfg = getAuthConfig();
const r = await fetch(apiUrl("/api/auth/sync"), { const r = await fetch(apiUrl("/api/auth/sync"), {
method: "POST", method: "POST",
@@ -1107,6 +1157,14 @@
} }
function upsertCustom(link) { 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 n = normalizeLink(link);
const idx = state.store.custom.findIndex((c) => c && c.id === n.id); const idx = state.store.custom.findIndex((c) => c && c.id === n.id);
if (idx >= 0) state.store.custom[idx] = n; if (idx >= 0) state.store.custom[idx] = n;
@@ -1115,6 +1173,13 @@
} }
function deleteLink(id) { 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)) { if (isBaseId(id)) {
const s = new Set(state.store.tombstones || []); const s = new Set(state.store.tombstones || []);
s.add(id); s.add(id);
@@ -1134,6 +1199,11 @@
const link = getLinkById(id); const link = getLinkById(id);
if (!link) return; if (!link) return;
const next = !link.favorite; const next = !link.favorite;
if (state.serverMode) {
upsertCustom({ ...link, favorite: next, updatedAt: nowIso() });
render();
return;
}
if (isBaseId(id)) { if (isBaseId(id)) {
setOverride(id, { favorite: next, updatedAt: nowIso() }); setOverride(id, { favorite: next, updatedAt: nowIso() });
} else { } else {
@@ -1231,10 +1301,12 @@
} }
used.add(n.id); used.add(n.id);
// 가져오기는 custom로 추가(기본과 충돌 방지) // 가져오기는 custom로 추가(기본과 충돌 방지)
state.store.custom.push(n); if (state.serverMode) state.baseLinks.push(n);
else state.store.custom.push(n);
added++; added++;
} }
saveStore(); if (!state.serverMode) saveStore();
state.baseOrder = new Map(state.baseLinks.map((l, i) => [l.id, i]));
toast(`가져오기 완료: ${added}`); toast(`가져오기 완료: ${added}`);
} }
@@ -1259,6 +1331,7 @@
} }
if (act === "fav") { if (act === "fav") {
toggleFavorite(id); toggleFavorite(id);
persistLinksIfServerMode();
return; return;
} }
if (act === "copy") { if (act === "copy") {
@@ -1276,6 +1349,7 @@
if (confirm(`삭제할까요?\n\n- ${name}`)) { if (confirm(`삭제할까요?\n\n- ${name}`)) {
deleteLink(id); deleteLink(id);
render(); render();
persistLinksIfServerMode();
} }
return; return;
} }
@@ -1323,6 +1397,7 @@
closeModal(); closeModal();
render(); render();
toast("저장했습니다."); toast("저장했습니다.");
persistLinksIfServerMode();
return; return;
} }
@@ -1340,6 +1415,7 @@
closeModal(); closeModal();
render(); render();
toast("추가했습니다."); toast("추가했습니다.");
persistLinksIfServerMode();
} }
function wire() { function wire() {
@@ -1415,7 +1491,16 @@
wire(); wire();
await hydrateAuthConfigFromServerIfNeeded(); await hydrateAuthConfigFromServerIfNeeded();
await initAuth(); 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])); state.baseOrder = new Map(state.baseLinks.map((l, i) => [l.id, i]));
el.sort.value = state.sortKey; el.sort.value = state.sortKey;
applyManageLock(); applyManageLock();