Roundcube 스킨 파비콘 탐색 및 CSP 안전 폴백

- mail.ncue.net에서 Roundcube 스킨 경로 favicon 후보를 순차 탐색
- 이미지 오류 처리 로직을 JS 이벤트로 바꿔 CSP 환경에서도 폴백 동작

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dsyoon
2026-02-08 12:59:47 +09:00
parent 6b426eaccc
commit 9074764273
2 changed files with 122 additions and 17 deletions

View File

@@ -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
? `<img src="${esc(fav.primary)}" data-fb="${esc(fav.fallback || "")}" alt="" loading="lazy" decoding="async" referrerpolicy="no-referrer" onerror="const p=this.parentNode; const fb=this.dataset.fb; if(fb){ this.dataset.fb=''; this.onerror=null; this.src=fb; return; } this.remove(); if(p) p.insertAdjacentHTML('beforeend','<div class=&quot;letter&quot;>${letter}</div>');" />`
? `<img src="${esc(fav.primary)}" data-fb="${esc((fav.fallbackList || []).join("|"))}" alt="" loading="lazy" decoding="async" referrerpolicy="no-referrer" />`
: `<div class="letter">${letter}</div>`;
return `
<article class="card${accessible ? "" : " disabled"}" data-id="${esc(link.id)}" data-access="${accessible ? "1" : "0"}">
<div class="card-head">
<div class="card-title">
<div class="favicon" aria-hidden="true">${faviconHtml}</div>
<div class="favicon" aria-hidden="true" data-letter="${letter}">${faviconHtml}</div>
<div class="title-wrap">
<div class="title" title="${t}">${t}</div>
<div class="domain" title="${d}">${d}</div>
@@ -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", `<div class="letter">${esc(letter || "L")}</div>`);
},
{ passive: true }
);
}
}
if (el.empty) el.empty.hidden = filtered.length !== 0;
if (el.meta) {
const favCount = all.filter((l) => l.favorite).length;

View File

@@ -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", `<div class="letter">${escapeHtml(letter || "L")}</div>`);
}
},
{ 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 @@
}">
<div class="card-head">
<div class="card-title">
<div class="favicon" aria-hidden="true">
<div class="favicon" aria-hidden="true" data-letter="${letter}">
${
fav && fav.primary
? `<img src="${escapeHtml(fav.primary)}" data-fb="${escapeHtml(fav.fallback || "")}" alt="" loading="lazy" decoding="async" referrerpolicy="no-referrer" onerror="const p=this.parentNode; const fb=this.dataset.fb; if(fb){ this.dataset.fb=''; this.onerror=null; this.src=fb; return; } this.remove(); if(p) p.insertAdjacentHTML('beforeend','<div class=&quot;letter&quot;>${letter}</div>');" />`
? `<img src="${escapeHtml(fav.primary)}" data-fb="${escapeHtml((fav.fallbackList || []).join("|"))}" alt="" loading="lazy" decoding="async" referrerpolicy="no-referrer" />`
: `<div class="letter">${letter}</div>`
}
</div>