링크 공유 저장(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:
73
flask_app.py
73
flask_app.py
@@ -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:
|
||||||
|
|||||||
91
script.js
91
script.js
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user