(() => { "use strict"; // If index.html inline fallback already booted, do nothing. if (globalThis.__LINKS_APP_BOOTED__) return; // Mark boot so index.html fallback won't run globalThis.__LINKS_APP_BOOTED__ = true; const STORAGE_KEY = "links_home_v1"; const THEME_KEY = "links_home_theme_v1"; const AUTH_TOAST_ONCE_KEY = "links_home_auth_toast_once_v1"; const AUTH_OVERRIDE_KEY = "links_home_auth_override_v1"; const AUTH_PKCE_KEY = "links_home_auth_pkce_v1"; const AUTH_TOKEN_KEY = "links_home_auth_tokens_v1"; const el = { subtitle: document.getElementById("subtitle"), q: document.getElementById("q"), sort: document.getElementById("sort"), onlyFav: document.getElementById("onlyFav"), meta: document.getElementById("meta"), grid: document.getElementById("grid"), empty: document.getElementById("empty"), btnAdd: document.getElementById("btnAdd"), btnImport: document.getElementById("btnImport"), btnExport: document.getElementById("btnExport"), btnTheme: document.getElementById("btnTheme"), user: document.getElementById("user"), userText: document.getElementById("userText"), btnLogout: document.getElementById("btnLogout"), snsLogin: document.getElementById("snsLogin"), btnGoogle: document.getElementById("btnGoogle"), modal: document.getElementById("modal"), btnClose: document.getElementById("btnClose"), btnCancel: document.getElementById("btnCancel"), form: document.getElementById("form"), id: document.getElementById("id"), title: document.getElementById("title"), url: document.getElementById("url"), description: document.getElementById("description"), tags: document.getElementById("tags"), favorite: document.getElementById("favorite"), file: document.getElementById("file"), toast: document.getElementById("toast"), }; // NOTE: // 예전에는 links.json을 못 읽는 환경(file:// 등)에서 "내장 기본 목록"으로 조용히 대체했는데, // 그러면 links.json의 순서/내용 변경이 반영되지 않아 혼란이 생깁니다. // 이제는 links.json 로드를 우선하며, 실패 시 경고를 띄우고 빈 목록(또는 localStorage 커스텀)으로 동작합니다. const DEFAULT_LINKS_INLINE = []; const state = { baseLinks: [], baseOrder: new Map(), store: loadStore(), query: "", sortKey: "json", onlyFav: false, canManage: false, serverMode: false, // true when /api/links is available }; // Access levels (open/copy) const ACCESS_ANON_IDS = new Set(["dsyoon-ncue-net", "family-ncue-net", "link-ncue-net"]); const ACCESS_USER_IDS = new Set([ "dsyoon-ncue-net", "family-ncue-net", "tts-ncue-net", "meeting-ncue-net", "link-ncue-net", "dreamgirl-ncue-net", ]); const DEFAULT_ADMIN_EMAILS = new Set([ "dsyoon@ncue.net", "dosangyoon@gmail.com", "dosangyoon2@gmail.com", "dosangyoon3@gmail.com", ]); function getUserEmail() { const e = auth && auth.user && auth.user.email ? String(auth.user.email) : ""; return e.trim().toLowerCase(); } function isAdminEmail(email) { const e = String(email || "").trim().toLowerCase(); const cfg = getAuthConfig(); const admins = Array.isArray(cfg.adminEmails) ? cfg.adminEmails : []; if (admins.length) return admins.includes(e); return DEFAULT_ADMIN_EMAILS.has(e); } function canAccessLink(link) { const email = getUserEmail(); if (email && isAdminEmail(email)) return true; const id = String(link && link.id ? link.id : ""); if (email) return ACCESS_USER_IDS.has(id); return ACCESS_ANON_IDS.has(id); } function buildOpenUrl(rawUrl) { const url = String(rawUrl || "").trim(); if (!url) return ""; let host = ""; try { host = new URL(url).hostname.toLowerCase(); } catch { return url; } const isNcue = host === "ncue.net" || host.endsWith(".ncue.net"); if (!isNcue) return url; const email = getUserEmail(); const qs = new URLSearchParams(); qs.set("u", url); if (email) qs.set("e", email); return `/go?${qs.toString()}`; } const auth = { client: null, user: null, authorized: false, ready: false, mode: "disabled", // enabled | misconfigured | sdk_missing | disabled serverCanManage: null, idTokenRaw: "", }; function nowIso() { return new Date().toISOString(); } function safeJsonParse(s, fallback) { try { return JSON.parse(s); } catch { return fallback; } } function loadStore() { const raw = localStorage.getItem(STORAGE_KEY); const data = raw ? safeJsonParse(raw, null) : null; const store = { overridesById: {}, tombstones: [], custom: [], }; if (!data || typeof data !== "object") return store; if (data.overridesById && typeof data.overridesById === "object") store.overridesById = data.overridesById; if (Array.isArray(data.tombstones)) store.tombstones = data.tombstones; if (Array.isArray(data.custom)) store.custom = data.custom; return store; } function saveStore() { localStorage.setItem(STORAGE_KEY, JSON.stringify(state.store)); } function normalizeUrl(url) { const u = String(url || "").trim(); if (!u) return ""; if (/^https?:\/\//i.test(u)) return u; return "https://" + u; } function normalizeTags(tagsText) { if (!tagsText) return []; return String(tagsText) .split(",") .map((t) => t.trim()) .filter(Boolean) .slice(0, 12); } function getDomain(url) { try { return new URL(url).host; } catch { return url.replace(/^https?:\/\//i, "").split("/")[0] || ""; } } function faviconCandidates(url) { try { const u = new URL(url); const host = String(u.hostname || "").toLowerCase(); const isNcue = host === "ncue.net" || host.endsWith(".ncue.net"); const parts = u.pathname.split("/").filter(Boolean); const rootFav = `${u.origin}/favicon.ico`; // Host-specific hints (Roundcube etc.) const candidates = []; if (host === "mail.ncue.net") { // common Roundcube skin favicon locations (server files) candidates.push( `${u.origin}/roundcube/skins/elastic/images/favicon.ico`, `${u.origin}/roundcube/skins/larry/images/favicon.ico`, `${u.origin}/roundcube/skins/classic/images/favicon.ico`, // sometimes Roundcube is mounted at / `${u.origin}/skins/elastic/images/favicon.ico`, `${u.origin}/skins/larry/images/favicon.ico`, `${u.origin}/skins/classic/images/favicon.ico`, // legacy attempt `${u.origin}/roundcube/favicon.ico` ); } // Path-based favicon like https://ncue.net/dsyoon/favicon.ico (internal only) const pathFav = isNcue && parts.length ? `${u.origin}/${parts[0]}/favicon.ico` : ""; const list = []; if (pathFav) list.push(pathFav); list.push(...candidates); list.push(rootFav); // de-dup + drop empties const uniq = []; const seen = new Set(); for (const x of list) { const v = String(x || "").trim(); if (!v) continue; if (seen.has(v)) continue; seen.add(v); uniq.push(v); } const primary = uniq[0] || ""; const rest = uniq.slice(1); return { primary, fallbackList: rest }; } catch { return { primary: "", fallbackList: [] }; } } function wireFaviconFallbacks() { const imgs = el.grid ? el.grid.querySelectorAll("img[data-fb]") : []; for (const img of imgs) { if (img.dataset.bound === "1") continue; img.dataset.bound = "1"; img.addEventListener( "error", () => { const fb = String(img.dataset.fb || ""); const list = fb ? fb.split("|").filter(Boolean) : []; const next = list.shift(); if (next) { img.dataset.fb = list.join("|"); img.src = next; return; } const p = img.parentNode; const letter = p && p.getAttribute ? p.getAttribute("data-letter") : ""; img.remove(); if (p) { p.insertAdjacentHTML("beforeend", `
${escapeHtml(letter || "L")}
`); } }, { passive: true } ); } } function escapeHtml(s) { return String(s) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function idFromUrl(url) { const d = getDomain(url).toLowerCase(); const cleaned = d.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); return cleaned || "link"; } function newId(prefix = "custom") { if (globalThis.crypto && crypto.randomUUID) return `${prefix}-${crypto.randomUUID()}`; return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`; } function normalizeLink(link) { const url = normalizeUrl(link.url); const id = String(link.id || "").trim() || idFromUrl(url) || newId("link"); const title = String(link.title || "").trim() || getDomain(url) || "Link"; const description = String(link.description || "").trim(); const tags = Array.isArray(link.tags) ? link.tags.map((t) => String(t).trim()).filter(Boolean) : []; const favorite = Boolean(link.favorite); const createdAt = String(link.createdAt || nowIso()); const updatedAt = String(link.updatedAt || createdAt); return { id, title, url, description, tags, favorite, createdAt, updatedAt }; } 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 || {}; const byId = new Map(); for (const base of state.baseLinks) { if (!base || !base.id) continue; if (tomb.has(base.id)) continue; const o = overrides[base.id]; byId.set(base.id, { ...base, ...(o || {}) }); } for (const c of state.store.custom || []) { const n = normalizeLink(c); byId.set(n.id, n); } return [...byId.values()]; } function matchesQuery(link, q) { if (!q) return true; const hay = [ link.title, link.url, getDomain(link.url), link.description || "", (link.tags || []).join(" "), ] .join(" ") .toLowerCase(); return hay.includes(q); } function toTime(s) { const t = Date.parse(String(s || "")); return Number.isFinite(t) ? t : 0; } function orderKey(link) { const idx = state.baseOrder.get(link.id); if (typeof idx === "number") return idx; // custom/imported links go after base list, in creation order return 1_000_000 + toTime(link.createdAt); } function compareLinks(a, b) { const key = state.sortKey; if (key === "json") { const oa = orderKey(a); const ob = orderKey(b); if (oa !== ob) return oa - ob; return a.title.localeCompare(b.title, "ko"); } if (key === "favorite") { if (a.favorite !== b.favorite) return a.favorite ? -1 : 1; // tie-breaker: keep json order const oa = orderKey(a); const ob = orderKey(b); if (oa !== ob) return oa - ob; } if (key === "name") return a.title.localeCompare(b.title, "ko"); if (key === "domain") return getDomain(a.url).localeCompare(getDomain(b.url), "en"); // recent (default) return String(b.updatedAt).localeCompare(String(a.updatedAt)); } function render() { const q = state.query.trim().toLowerCase(); const all = getMergedLinks(); const filtered = all .filter((l) => (state.onlyFav ? l.favorite : true)) .filter((l) => matchesQuery(l, q)) .sort(compareLinks); el.grid.innerHTML = filtered.map(cardHtml).join(""); wireFaviconFallbacks(); el.empty.hidden = filtered.length !== 0; const favCount = all.filter((l) => l.favorite).length; el.meta.textContent = `표시 ${filtered.length}개 · 전체 ${all.length}개 · 즐겨찾기 ${favCount}개`; el.subtitle.textContent = all.length ? `링크 ${all.length}개` : "개인 링크 관리"; } function cardHtml(link) { const domain = escapeHtml(getDomain(link.url)); const title = escapeHtml(link.title); const desc = escapeHtml(link.description || ""); const url = escapeHtml(link.url); const starClass = link.favorite ? "star on" : "star"; const accessible = canAccessLink(link); const tags = (link.tags || []).slice(0, 8); const tagHtml = [ link.favorite ? `★ 즐겨찾기` : "", accessible ? "" : `접근 제한`, ...tags.map((t) => `#${escapeHtml(t)}`), ] .filter(Boolean) .join(""); const letter = escapeHtml((link.title || domain || "L").trim().slice(0, 1).toUpperCase()); const lockAttr = state.canManage ? "" : ' disabled aria-disabled="true"'; const lockTitle = state.canManage ? "" : ' title="관리 기능은 로그인 후 사용 가능합니다."'; const fav = faviconCandidates(link.url); const accessDisabledAttr = accessible ? "" : " disabled aria-disabled=\"true\""; const accessDisabledTitle = accessible ? "" : " title=\"이 링크는 현재 권한으로 접근할 수 없습니다.\""; const openHref = escapeHtml(buildOpenUrl(link.url)); const openHtml = accessible ? `열기` : ``; const copyDisabledAttr = accessible ? "" : " disabled aria-disabled=\"true\""; const copyDisabledTitle = accessible ? "" : " title=\"이 링크는 현재 권한으로 접근할 수 없습니다.\""; return `
${title}
${domain}
${desc || " "}
${tagHtml || ""}
${openHtml}
`; } function openModal(mode, link) { el.modal.hidden = false; document.body.style.overflow = "hidden"; const isEdit = mode === "edit"; document.getElementById("modalTitle").textContent = isEdit ? "링크 편집" : "링크 추가"; el.id.value = isEdit ? link.id : ""; el.title.value = isEdit ? link.title : ""; el.url.value = isEdit ? link.url : ""; el.description.value = isEdit ? link.description || "" : ""; el.tags.value = isEdit ? (link.tags || []).join(", ") : ""; el.favorite.checked = isEdit ? Boolean(link.favorite) : false; setTimeout(() => el.title.focus(), 0); } function closeModal() { el.modal.hidden = true; document.body.style.overflow = ""; el.form.reset(); el.id.value = ""; } function getLinkById(id) { return getMergedLinks().find((l) => l.id === id) || null; } function isBaseId(id) { return state.baseLinks.some((l) => l.id === id); } function setOverride(id, patch) { state.store.overridesById[id] = { ...(state.store.overridesById[id] || {}), ...patch }; saveStore(); } function removeOverride(id) { if (state.store.overridesById && state.store.overridesById[id]) { delete state.store.overridesById[id]; } saveStore(); } function toast(msg) { el.toast.textContent = msg; el.toast.hidden = false; clearTimeout(toast._t); toast._t = setTimeout(() => { el.toast.hidden = true; }, 2400); } function toastOnce(key, msg) { const k = `${AUTH_TOAST_ONCE_KEY}:${key}`; if (localStorage.getItem(k)) return; localStorage.setItem(k, "1"); toast(msg); } function loadAuthOverride() { const raw = localStorage.getItem(AUTH_OVERRIDE_KEY); const data = raw ? safeJsonParse(raw, null) : null; if (!data || typeof data !== "object") return null; const auth0 = data.auth0 && typeof data.auth0 === "object" ? data.auth0 : {}; // legacy: allowedEmails -> adminEmails const adminEmails = Array.isArray(data.adminEmails) ? data.adminEmails : Array.isArray(data.allowedEmails) ? data.allowedEmails : []; return { auth0: { domain: String(auth0.domain || "").trim(), clientId: String(auth0.clientId || "").trim(), }, connections: data.connections && typeof data.connections === "object" ? { google: String(data.connections.google || "").trim(), // legacy keys kept for backward compatibility kakao: String(data.connections.kakao || "").trim(), naver: String(data.connections.naver || "").trim(), } : { google: "", kakao: "", naver: "" }, adminEmails: adminEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean), }; } function saveAuthOverride(cfg) { localStorage.setItem(AUTH_OVERRIDE_KEY, JSON.stringify(cfg)); } function clearAuthOverride() { localStorage.removeItem(AUTH_OVERRIDE_KEY); } function getAuthConfig() { const cfg = globalThis.AUTH_CONFIG && typeof globalThis.AUTH_CONFIG === "object" ? globalThis.AUTH_CONFIG : {}; const apiBase = String(cfg.apiBase || "").trim(); // optional, e.g. https://api.ncue.net const auth0 = cfg.auth0 && typeof cfg.auth0 === "object" ? cfg.auth0 : {}; // legacy: allowedEmails -> adminEmails const adminEmails = Array.isArray(cfg.adminEmails) ? cfg.adminEmails : Array.isArray(cfg.allowedEmails) ? cfg.allowedEmails : []; const base = { apiBase, auth0: { domain: String(auth0.domain || "").trim(), clientId: String(auth0.clientId || "").trim(), }, connections: { google: "", kakao: "", naver: "", }, adminEmails: adminEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean), }; const override = loadAuthOverride(); if (!override) return base; // override가 있으면 우선 적용 (서버 재배포 없이 테스트 가능) return { apiBase, auth0: { domain: override.auth0.domain || base.auth0.domain, clientId: override.auth0.clientId || base.auth0.clientId, }, connections: { google: override.connections?.google || "", kakao: override.connections?.kakao || "", naver: override.connections?.naver || "", }, adminEmails: override.adminEmails.length ? override.adminEmails : base.adminEmails, }; } function apiUrl(pathname) { const cfg = getAuthConfig(); const base = cfg.apiBase; if (!base) return pathname; // same-origin try { return new URL(pathname, base).toString(); } catch { return pathname; } } async function hydrateAuthConfigFromServerIfNeeded() { const cfg = getAuthConfig(); const hasLocal = Boolean(cfg.auth0.domain && cfg.auth0.clientId && cfg.connections.google); if (hasLocal) return true; try { const r = await fetch(apiUrl("/api/config/auth"), { cache: "no-store" }); if (!r.ok) return false; const data = await r.json(); if (!data || !data.ok || !data.value) return false; const v = data.value; const auth0 = v.auth0 || {}; const connections = v.connections || {}; // legacy: allowedEmails -> adminEmails const adminEmails = Array.isArray(v.adminEmails) ? v.adminEmails : Array.isArray(v.allowedEmails) ? v.allowedEmails : []; const domain = String(auth0.domain || "").trim(); const clientId = String(auth0.clientId || "").trim(); const google = String(connections.google || "").trim(); if (!domain || !clientId || !google) return false; saveAuthOverride({ auth0: { domain, clientId }, connections: { google }, adminEmails, }); return true; } catch { return false; } } function currentUrlNoQuery() { // Auth0 callback 후 URL 정리용 const u = new URL(location.href); u.searchParams.delete("code"); u.searchParams.delete("state"); return u.toString(); } function redirectUri() { return location.origin === "null" ? location.href : location.origin + location.pathname; } function b64urlFromBytes(bytes) { let bin = ""; const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); for (let i = 0; i < arr.length; i++) bin += String.fromCharCode(arr[i]); const b64 = btoa(bin).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); return b64; } async function sha256Bytes(input) { const data = new TextEncoder().encode(String(input)); const hash = await crypto.subtle.digest("SHA-256", data); return new Uint8Array(hash); } function randomString(len = 43) { const bytes = new Uint8Array(len); crypto.getRandomValues(bytes); return b64urlFromBytes(bytes); } function decodeJwtPayload(token) { try { const parts = String(token || "").split("."); if (parts.length < 2) return null; const b64 = parts[1].replaceAll("-", "+").replaceAll("_", "/"); const pad = "=".repeat((4 - (b64.length % 4)) % 4); const json = atob(b64 + pad); return safeJsonParse(json, null); } catch { return null; } } function loadTokens() { const raw = localStorage.getItem(AUTH_TOKEN_KEY); const data = raw ? safeJsonParse(raw, null) : null; if (!data || typeof data !== "object") return null; return { id_token: typeof data.id_token === "string" ? data.id_token : "", access_token: typeof data.access_token === "string" ? data.access_token : "", received_at: typeof data.received_at === "string" ? data.received_at : "", expires_in: typeof data.expires_in === "number" ? data.expires_in : 0, }; } function saveTokens(t) { localStorage.setItem( AUTH_TOKEN_KEY, JSON.stringify({ id_token: t.id_token || "", access_token: t.access_token || "", expires_in: t.expires_in || 0, received_at: nowIso(), }) ); } function clearTokens() { localStorage.removeItem(AUTH_TOKEN_KEY); } async function manualAuthorize(connection) { const cfg = getAuthConfig(); if (!cfg.auth0.domain || !cfg.auth0.clientId) { toast("로그인 설정이 서버(.env)에 없습니다. 관리자에게 문의하세요."); return; } if (!globalThis.crypto || !crypto.subtle) { toast("이 브라우저는 보안 로그인(PKCE)을 지원하지 않습니다."); return; } const verifier = randomString(64); const challenge = b64urlFromBytes(await sha256Bytes(verifier)); const state = randomString(24); sessionStorage.setItem( AUTH_PKCE_KEY, JSON.stringify({ verifier, state, redirect_uri: redirectUri(), created_at: nowIso(), }) ); const u = new URL(`https://${cfg.auth0.domain}/authorize`); u.searchParams.set("response_type", "code"); u.searchParams.set("client_id", cfg.auth0.clientId); u.searchParams.set("redirect_uri", redirectUri()); u.searchParams.set("scope", "openid profile email"); u.searchParams.set("state", state); u.searchParams.set("code_challenge", challenge); u.searchParams.set("code_challenge_method", "S256"); if (connection) u.searchParams.set("connection", connection); location.assign(u.toString()); } async function manualHandleCallbackIfNeeded() { const cfg = getAuthConfig(); const u = new URL(location.href); const code = u.searchParams.get("code") || ""; const stateParam = u.searchParams.get("state") || ""; if (!code || !stateParam) return null; const raw = sessionStorage.getItem(AUTH_PKCE_KEY); const pkce = raw ? safeJsonParse(raw, null) : null; if (!pkce || pkce.state !== stateParam) { toast("로그인 상태값(state)이 일치하지 않습니다. 다시 시도하세요."); return null; } const body = new URLSearchParams(); body.set("grant_type", "authorization_code"); body.set("client_id", cfg.auth0.clientId); body.set("code_verifier", pkce.verifier); body.set("code", code); body.set("redirect_uri", pkce.redirect_uri || redirectUri()); const tokenRes = await fetch(`https://${cfg.auth0.domain}/oauth/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, }); if (!tokenRes.ok) { toast("토큰 발급에 실패했습니다. Auth0 Callback URL 설정을 확인하세요."); return null; } const tokenJson = await tokenRes.json(); const idToken = String(tokenJson.id_token || ""); const accessToken = String(tokenJson.access_token || ""); const expiresIn = Number(tokenJson.expires_in || 0) || 0; if (!idToken) { toast("ID 토큰이 없습니다. 로그인 설정을 확인하세요."); return null; } saveTokens({ id_token: idToken, access_token: accessToken, expires_in: expiresIn }); sessionStorage.removeItem(AUTH_PKCE_KEY); history.replaceState({}, document.title, currentUrlNoQuery()); return { idToken, accessToken }; } async function manualLoadUser() { const t = loadTokens(); if (!t || !t.id_token) return null; const payload = decodeJwtPayload(t.id_token); if (!payload) return null; // 최소 표시/권한 확인용 user shape return { sub: payload.sub, email: payload.email, name: payload.name, picture: payload.picture, }; } async function syncUserToServerWithIdToken(idToken) { try { auth.idTokenRaw = String(idToken || ""); const cfg = getAuthConfig(); const r = await fetch(apiUrl("/api/auth/sync"), { method: "POST", headers: { Authorization: `Bearer ${idToken}`, "X-Auth0-Issuer": `https://${cfg.auth0.domain}/`, "X-Auth0-ClientId": cfg.auth0.clientId, }, }); if (!r.ok) { toastOnce( "syncfail", `사용자 저장(API)이 실패했습니다. (${r.status}) 정적 호스팅이면 /api 를 서버로 프록시하거나 apiBase를 설정해야 합니다.` ); return null; } const data = await r.json(); if (data && data.ok) return Boolean(data.canManage); return null; } catch { toastOnce("syncerr", "사용자 저장(API)에 연결하지 못했습니다. /api 서버 연결을 확인하세요."); return null; } } 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(); const payload = JSON.stringify({ t: Date.now() }); // Prefer sendBeacon to survive navigation try { const blob = new Blob([payload], { type: "application/json" }); const ok = navigator.sendBeacon(apiUrl("/api/auth/logout"), blob); if (ok) return; } catch { // ignore } // Fallback fetch keepalive (best-effort) try { fetch(apiUrl("/api/auth/logout"), { method: "POST", headers: { Authorization: `Bearer ${idToken}`, "X-Auth0-Issuer": `https://${cfg.auth0.domain}/`, "X-Auth0-ClientId": cfg.auth0.clientId, "Content-Type": "application/json", }, body: payload, keepalive: true, }).catch(() => {}); } catch { // ignore } } function isManageAdminEmail(email) { const cfg = getAuthConfig(); const admins = Array.isArray(cfg.adminEmails) ? cfg.adminEmails : []; const e = String(email || "").trim().toLowerCase(); if (admins.length) return admins.includes(e); // 안전한 기본값: 설정이 비어있으면 기본 관리자만 관리 가능 return DEFAULT_ADMIN_EMAILS.has(e); } function updateAuthUi() { // 로그인 전에는 사용자 배지를 숨김(요청: "로그인 설정 필요" 영역 제거) if (!auth.user) { el.user.hidden = true; } else { el.user.hidden = false; const email = auth.user && auth.user.email ? String(auth.user.email) : ""; const name = auth.user && auth.user.name ? String(auth.user.name) : ""; const label = email ? email : name ? name : "로그인됨"; el.userText.textContent = label; if (auth.authorized) el.user.setAttribute("data-auth", "ok"); else el.user.removeAttribute("data-auth"); } // 로그아웃은 로그인 된 이후에만 노출 el.btnLogout.hidden = !auth.user; el.btnLogout.disabled = false; // 간편 로그인 버튼 노출 // - 설정 전(로그인 설정 필요)에도 디자인이 보이도록 영역은 항상 노출 // - connection이 없으면 클릭 시 설정 모달을 띄움(핸들러에서 처리) const cfg = getAuthConfig(); const showQuick = !auth.user; if (el.snsLogin) el.snsLogin.hidden = !showQuick; if (el.btnGoogle) { el.btnGoogle.hidden = !showQuick; el.btnGoogle.classList.toggle("is-disabled", !cfg.connections.google); } } function applyManageLock() { // AUTH_CONFIG가 없는 상태에서는 기존처럼 자유롭게 관리 가능. // 로그인 기능이 "enabled"일 때만 관리 잠금을 적용합니다. state.canManage = auth.mode === "enabled" ? Boolean(auth.user && auth.authorized) : true; const lockMsg = "관리 기능은 로그인(관리자 이메일) 후 사용 가능합니다."; el.btnAdd.disabled = !state.canManage; el.btnImport.disabled = !state.canManage; // 요청: 로그인 전에는 내보내기도 비활성화 el.btnExport.disabled = auth.mode === "enabled" ? !auth.user : false; if (!state.canManage) { el.btnAdd.title = lockMsg; el.btnImport.title = lockMsg; } else { el.btnAdd.title = ""; el.btnImport.title = ""; } if (el.btnExport.disabled) el.btnExport.title = "내보내기는 로그인 후 사용 가능합니다."; else el.btnExport.title = ""; } async function initAuth() { auth.ready = true; const cfg = getAuthConfig(); const hasAuth0 = cfg.auth0.domain && cfg.auth0.clientId; const hasSdk = typeof globalThis.createAuth0Client === "function"; if (!hasAuth0) { // 설정이 없으면: 로그인 비활성(운영자 설정 필요), 관리 기능은 잠그지 않음 auth.client = null; auth.user = null; auth.authorized = false; auth.mode = "misconfigured"; updateAuthUi(); applyManageLock(); toastOnce("misconf", "로그인 설정이 서버(.env)에 없습니다. 관리자에게 문의하세요."); return; } if (!hasSdk) { // SDK가 없어도 PKCE 수동 로그인으로 동작 가능 auth.client = null; auth.mode = "enabled"; await manualHandleCallbackIfNeeded().catch(() => {}); auth.user = await manualLoadUser(); const email = auth.user && auth.user.email ? String(auth.user.email) : ""; auth.authorized = Boolean(auth.user) && isManageAdminEmail(email); 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; auth.authorized = can; } } updateAuthUi(); applyManageLock(); return; } if (location.protocol === "file:") { toastOnce("file", "소셜 로그인은 보통 HTTPS 사이트에서만 동작합니다. (file://에서는 제한될 수 있어요)"); } try { auth.client = await createAuth0Client({ domain: cfg.auth0.domain, clientId: cfg.auth0.clientId, authorizationParams: { redirect_uri: redirectUri(), }, cacheLocation: "localstorage", useRefreshTokens: true, }); auth.mode = "enabled"; } catch { auth.client = null; auth.user = null; auth.authorized = false; auth.mode = "sdk_missing"; toastOnce("authinit", "로그인 초기화에 실패했습니다. AUTH_CONFIG 값과 Auth0 설정(Callback URL)을 확인하세요."); updateAuthUi(); applyManageLock(); return; } const u = new URL(location.href); const isCallback = u.searchParams.has("code") && u.searchParams.has("state"); if (isCallback) { try { await auth.client.handleRedirectCallback(); } finally { history.replaceState({}, document.title, currentUrlNoQuery()); } } const isAuthed = await auth.client.isAuthenticated(); auth.user = isAuthed ? await auth.client.getUser() : null; const email = auth.user && auth.user.email ? auth.user.email : ""; auth.authorized = Boolean(auth.user) && isManageAdminEmail(email); auth.serverCanManage = null; // 로그인되었으면 서버에 사용자 upsert 및 can_manage 동기화(서버가 있을 때만) if (auth.user) { try { 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", headers: { Authorization: `Bearer ${raw}`, "X-Auth0-Issuer": `https://${cfg.auth0.domain}/`, "X-Auth0-ClientId": cfg.auth0.clientId, }, }); if (r.ok) { const data = await r.json(); if (data && data.ok) { auth.serverCanManage = Boolean(data.canManage); auth.authorized = auth.serverCanManage; } } } } catch { // ignore: server not running or blocked } } if (auth.user && !auth.authorized) { toastOnce("deny", "로그인은 되었지만 관리자 이메일이 아니라서 관리 기능이 잠금 상태입니다."); } updateAuthUi(); applyManageLock(); } async function login() { if (auth.mode !== "enabled" || !auth.client) { toast("로그인 설정이 서버(.env)에 필요합니다."); return; } await auth.client.loginWithRedirect(); } async function loginWithConnection(connection) { if (auth.mode !== "enabled") { toast("로그인 설정이 서버(.env)에 필요합니다."); return; } if (auth.client) { await auth.client.loginWithRedirect({ authorizationParams: { connection }, }); return; } await manualAuthorize(connection); } async function logout() { if (auth.mode !== "enabled") return; // SDK가 있으면 SDK로, 없으면 수동 로그아웃 if (auth.client) { try { const claims = await auth.client.getIdTokenClaims(); const raw = claims && claims.__raw ? String(claims.__raw) : ""; if (raw) sendLogoutToServer(raw); } catch { // ignore } auth.user = null; auth.authorized = false; updateAuthUi(); applyManageLock(); await auth.client.logout({ logoutParams: { returnTo: redirectUri(), }, }); return; } // manual token logout const t = loadTokens(); if (t && t.id_token) sendLogoutToServer(t.id_token); clearTokens(); auth.user = null; auth.authorized = false; updateAuthUi(); applyManageLock(); const cfg = getAuthConfig(); const u = new URL(`https://${cfg.auth0.domain}/v2/logout`); u.searchParams.set("client_id", cfg.auth0.clientId); u.searchParams.set("returnTo", redirectUri()); location.assign(u.toString()); } async function copyText(text) { try { await navigator.clipboard.writeText(text); toast("복사했습니다."); } catch { // fallback const ta = document.createElement("textarea"); ta.value = text; ta.setAttribute("readonly", "readonly"); ta.style.position = "fixed"; ta.style.left = "-9999px"; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); ta.remove(); toast("복사했습니다."); } } 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; else state.store.custom.push(n); saveStore(); } 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); state.store.tombstones = [...s]; removeOverride(id); saveStore(); toast("기본 링크를 숨겼습니다."); return; } const before = state.store.custom.length; state.store.custom = (state.store.custom || []).filter((c) => c && c.id !== id); saveStore(); if (state.store.custom.length !== before) toast("삭제했습니다."); } function toggleFavorite(id) { 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 { upsertCustom({ ...link, favorite: next, updatedAt: nowIso() }); } render(); } function editLink(id) { const link = getLinkById(id); if (!link) return; openModal("edit", link); } async function loadBaseLinks() { // 1) index.html 내부 내장 데이터(서버 없이도 동작) const dataEl = document.getElementById("linksData"); if (dataEl && dataEl.textContent) { const parsed = safeJsonParse(dataEl.textContent, null); if (Array.isArray(parsed)) return parsed.map(normalizeLink); } // 2) 동일 디렉토리의 links.json (서버 환경에서 권장) const candidates = [ new URL("./links.json", document.baseURI).toString(), new URL("links.json", document.baseURI).toString(), ]; for (const url of candidates) { try { const res = await fetch(url, { cache: "no-store" }); if (!res.ok) continue; const data = await res.json(); if (!Array.isArray(data)) continue; return data.map(normalizeLink); } catch { // try next } } const hint = location.protocol === "file:" ? "기본 링크 데이터가 없습니다. index.html의 linksData를 확인하세요." : "links.json을 불러오지 못했습니다. 배포 경로에 links.json이 있는지 확인하세요."; toast(hint); return DEFAULT_LINKS_INLINE.map(normalizeLink); } function applyTheme(theme) { const t = theme === "light" ? "light" : "dark"; document.documentElement.setAttribute("data-theme", t); el.btnTheme.setAttribute("aria-pressed", t === "dark" ? "true" : "false"); localStorage.setItem(THEME_KEY, t); } function initTheme() { const saved = localStorage.getItem(THEME_KEY); if (saved === "light" || saved === "dark") return applyTheme(saved); const prefersLight = window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches; applyTheme(prefersLight ? "light" : "dark"); } function exportJson() { const data = getMergedLinks().sort((a, b) => a.title.localeCompare(b.title, "ko")); const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json;charset=utf-8" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = `links-export-${new Date().toISOString().slice(0, 10)}.json`; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 0); toast("내보내기 파일을 생성했습니다."); } function importJsonText(text) { const parsed = safeJsonParse(text, null); if (!parsed) throw new Error("JSON 파싱 실패"); const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed.links) ? parsed.links : null; if (!list) throw new Error("JSON 형식이 올바르지 않습니다. (배열 또는 {links:[...]} )"); const merged = getMergedLinks(); const used = new Set(merged.map((l) => l.id)); let added = 0; for (const item of list) { if (!item) continue; const n0 = normalizeLink(item); let n = n0; if (used.has(n.id)) { n = { ...n, id: newId("import"), createdAt: nowIso(), updatedAt: nowIso() }; } used.add(n.id); // 가져오기는 custom로 추가(기본과 충돌 방지) if (state.serverMode) state.baseLinks.push(n); else state.store.custom.push(n); added++; } if (!state.serverMode) saveStore(); state.baseOrder = new Map(state.baseLinks.map((l, i) => [l.id, i])); toast(`가져오기 완료: ${added}개`); } function onGridClick(e) { const btn = e.target.closest("[data-act]"); if (!btn) return; const card = e.target.closest(".card"); if (!card) return; const id = card.getAttribute("data-id"); if (!id) return; const act = btn.getAttribute("data-act"); // access gate for open/copy if ((act === "open" || act === "copy") && card.getAttribute("data-access") === "0") { toast("이 링크는 현재 권한으로 접근할 수 없습니다."); e.preventDefault(); return; } if (auth.mode === "enabled" && !state.canManage && (act === "fav" || act === "edit" || act === "del")) { toast("관리 기능은 로그인(관리자 이메일) 후 사용 가능합니다."); return; } if (act === "fav") { toggleFavorite(id); persistLinksIfServerMode(); return; } if (act === "copy") { const link = getLinkById(id); if (link) copyText(link.url); return; } if (act === "edit") { editLink(id); return; } if (act === "del") { const link = getLinkById(id); const name = link ? link.title : id; if (confirm(`삭제할까요?\n\n- ${name}`)) { deleteLink(id); render(); persistLinksIfServerMode(); } return; } } function onFormSubmit(e) { e.preventDefault(); const isEdit = Boolean(el.id.value); const title = String(el.title.value || "").trim(); const url = normalizeUrl(el.url.value); if (!title) return toast("제목을 입력하세요."); if (!url) return toast("URL을 입력하세요."); let parsed; try { parsed = new URL(url); } catch { return toast("URL 형식이 올바르지 않습니다."); } if (!/^https?:$/.test(parsed.protocol)) return toast("http/https URL만 지원합니다."); const description = String(el.description.value || "").trim(); const tags = normalizeTags(el.tags.value); const favorite = Boolean(el.favorite.checked); if (isEdit) { const id = el.id.value; const current = getLinkById(id); if (!current) { closeModal(); toast("편집 대상이 없습니다."); return; } const patch = { title, url, description, tags, favorite, updatedAt: nowIso(), }; if (isBaseId(id)) setOverride(id, patch); else upsertCustom({ ...current, ...patch }); closeModal(); render(); toast("저장했습니다."); persistLinksIfServerMode(); return; } const id = newId("custom"); upsertCustom({ id, title, url, description, tags, favorite, createdAt: nowIso(), updatedAt: nowIso(), }); closeModal(); render(); toast("추가했습니다."); persistLinksIfServerMode(); } function wire() { el.q.addEventListener("input", () => { state.query = el.q.value || ""; render(); }); el.sort.addEventListener("change", () => { state.sortKey = el.sort.value || "json"; render(); }); el.onlyFav.addEventListener("change", () => { state.onlyFav = Boolean(el.onlyFav.checked); render(); }); el.grid.addEventListener("click", onGridClick); el.btnAdd.addEventListener("click", () => { if (auth.mode === "enabled" && !state.canManage) return toast("관리 기능은 로그인(관리자 이메일) 후 사용 가능합니다."); openModal("add", null); }); el.btnClose.addEventListener("click", closeModal); el.btnCancel.addEventListener("click", closeModal); el.modal.addEventListener("click", (e) => { const close = e.target && e.target.getAttribute && e.target.getAttribute("data-close"); if (close) closeModal(); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !el.modal.hidden) closeModal(); }); el.form.addEventListener("submit", onFormSubmit); el.btnExport.addEventListener("click", exportJson); el.btnImport.addEventListener("click", () => { if (auth.mode === "enabled" && !state.canManage) return toast("관리 기능은 로그인(관리자 이메일) 후 사용 가능합니다."); el.file.click(); }); el.file.addEventListener("change", async () => { const f = el.file.files && el.file.files[0]; el.file.value = ""; if (!f) return; try { const text = await f.text(); importJsonText(text); render(); } catch (err) { toast(String(err && err.message ? err.message : "가져오기 실패")); } }); el.btnTheme.addEventListener("click", () => { const cur = document.documentElement.getAttribute("data-theme") || "dark"; applyTheme(cur === "dark" ? "light" : "dark"); }); if (el.btnLogout) el.btnLogout.addEventListener("click", () => logout().catch(() => toast("로그아웃에 실패했습니다."))); if (el.btnGoogle) el.btnGoogle.addEventListener("click", () => { const c = getAuthConfig().connections.google; if (!c) return toast("서버(.env)에 AUTH0_GOOGLE_CONNECTION 설정이 필요합니다."); return loginWithConnection(c).catch(() => toast("로그인에 실패했습니다.")); }); } async function main() { initTheme(); wire(); await hydrateAuthConfigFromServerIfNeeded(); await initAuth(); 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(); render(); } main().catch(() => { toast("초기화에 실패했습니다."); }); })();