From 9074764273a2c014eff0dc58c278e087aa59751a Mon Sep 17 00:00:00 2001 From: dsyoon Date: Sun, 8 Feb 2026 12:59:47 +0900 Subject: [PATCH] =?UTF-8?q?Roundcube=20=EC=8A=A4=ED=82=A8=20=ED=8C=8C?= =?UTF-8?q?=EB=B9=84=EC=BD=98=20=ED=83=90=EC=83=89=20=EB=B0=8F=20CSP=20?= =?UTF-8?q?=EC=95=88=EC=A0=84=20=ED=8F=B4=EB=B0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mail.ncue.net에서 Roundcube 스킨 경로 favicon 후보를 순차 탐색 - 이미지 오류 처리 로직을 JS 이벤트로 바꿔 CSP 환경에서도 폴백 동작 Co-authored-by: Cursor --- index.html | 63 ++++++++++++++++++++++++++++++++++++++------ script.js | 76 +++++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 122 insertions(+), 17 deletions(-) diff --git a/index.html b/index.html index 68c03e1..4ccd687 100644 --- a/index.html +++ b/index.html @@ -523,14 +523,35 @@ const isNcue = host === "ncue.net" || host.endsWith(".ncue.net"); const parts = uu.pathname.split("/").filter(Boolean); const rootFav = `${uu.origin}/favicon.ico`; - const hintPath = host === "mail.ncue.net" ? "/roundcube/favicon.ico" : ""; - const hintFav = hintPath ? `${uu.origin}${hintPath}` : ""; + const candidates = []; + if (host === "mail.ncue.net") { + candidates.push( + `${uu.origin}/roundcube/skins/elastic/images/favicon.ico`, + `${uu.origin}/roundcube/skins/larry/images/favicon.ico`, + `${uu.origin}/roundcube/skins/classic/images/favicon.ico`, + `${uu.origin}/skins/elastic/images/favicon.ico`, + `${uu.origin}/skins/larry/images/favicon.ico`, + `${uu.origin}/skins/classic/images/favicon.ico`, + `${uu.origin}/roundcube/favicon.ico` + ); + } const pathFav = isNcue && parts.length ? `${uu.origin}/${parts[0]}/favicon.ico` : ""; - const primary = pathFav || hintFav || rootFav; - const fallback = primary !== rootFav ? rootFav : ""; - return { primary, fallback }; + const list = []; + if (pathFav) list.push(pathFav); + list.push(...candidates); + list.push(rootFav); + 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); + } + return { primary: uniq[0] || "", fallbackList: uniq.slice(1) }; } catch { - return { primary: "", fallback: "" }; + return { primary: "", fallbackList: [] }; } } function buildOpenUrl(rawUrl) { @@ -558,13 +579,13 @@ const copyAttrs = accessible ? "" : ` disabled aria-disabled="true" title="이 링크는 현재 권한으로 접근할 수 없습니다."`; const fav = faviconCandidates(link.url); const faviconHtml = fav && fav.primary - ? `` + ? `` : `
${letter}
`; return `
- +
${t}
${d}
@@ -594,6 +615,32 @@ .filter((l) => matches(l, q)) .sort(compare); if (el.grid) el.grid.innerHTML = filtered.map(cardHtml).join(""); + // bind favicon fallback chain (no inline onerror; works under CSP) + if (el.grid) { + const imgs = 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", `
${esc(letter || "L")}
`); + }, + { passive: true } + ); + } + } if (el.empty) el.empty.hidden = filtered.length !== 0; if (el.meta) { const favCount = all.filter((l) => l.favorite).length; diff --git a/script.js b/script.js index a629ab8..b2a16e4 100644 --- a/script.js +++ b/script.js @@ -189,18 +189,75 @@ const parts = u.pathname.split("/").filter(Boolean); const rootFav = `${u.origin}/favicon.ico`; - // Host-specific hint (Roundcube etc.) - const hintPath = host === "mail.ncue.net" ? "/roundcube/favicon.ico" : ""; - const hintFav = hintPath ? `${u.origin}${hintPath}` : ""; + // 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 primary = pathFav || hintFav || rootFav; - const fallback = primary !== rootFav ? rootFav : ""; - return { primary, fallback }; + 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: "", fallback: "" }; + 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 } + ); } } @@ -310,6 +367,7 @@ .sort(compareLinks); el.grid.innerHTML = filtered.map(cardHtml).join(""); + wireFaviconFallbacks(); el.empty.hidden = filtered.length !== 0; const favCount = all.filter((l) => l.favorite).length; @@ -355,10 +413,10 @@ }">
-