diff --git a/CLAUDE.md b/CLAUDE.md
index cf02427..c83a35b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -34,6 +34,7 @@ visibles des suivants exactement comme dans le monofichier. Ordre actuel :
js/argon2.js (IIFE, globalThis.NobleArgon2)
js/app.crypto.js (KDF, verifier, encrypt/decrypt — extrait §3.1)
js/app.totp.js (TOTP RFC 6238 + TOTP/custom-field crypto — extrait §3.1)
+js/app.favicon.js (favicon fetch/cache + faviconHost — extrait §3.1)
js/app.import.js (export container + import CSV/JSON — extrait §3.1)
js/app.backup.js (auto-backup planifié — extrait §3.1)
js/app.js (le reste : state, Bridge, api, UI…)
@@ -103,6 +104,7 @@ seule `api()` est stubbée).
| Frontend principal (en cours de découpage §3.1) | `js/app.js` |
| Crypto frontend (KDF, verifier, AES-GCM) — extrait §3.1 | `js/app.crypto.js` |
| TOTP (RFC 6238) + TOTP/custom-field crypto — extrait §3.1 | `js/app.totp.js` |
+| Favicons (fetch/cache, `faviconHost`) — extrait §3.1 | `js/app.favicon.js` |
| Import/export frontend (CSV/JSON parse, export container) — extrait §3.1 | `js/app.import.js` |
| Auto-backup frontend (planifié, chiffré) — extrait §3.1 | `js/app.backup.js` |
| Sync frontend (WebDAV, snapshot, merge) — extrait §3.1 | `js/app.sync.js` |
diff --git a/CODE_AUDIT.md b/CODE_AUDIT.md
index fbff5eb..fa30fac 100644
--- a/CODE_AUDIT.md
+++ b/CODE_AUDIT.md
@@ -238,11 +238,14 @@ réécriture des call-sites, risque quasi nul vs conversion en modules ES).
RFC 6238)** ajoutés en même temps → extraction *et* nouvelle couverture.
- ✅ `js/app.backup.js` extrait (auto-backup planifié) — byte-for-byte
identique, chargé AVANT app.js (pures déclarations).
-- `app.js` : 11 936 → **9 900 lignes** (crypto + totp + import + backup + sync
- sortis — 5 modules).
-- Suite de tests : 42 → **55 tests**.
+- ✅ `js/app.favicon.js` extrait (fetch/cache favicons) — chargé AVANT app.js.
+ **+7 tests `faviconHost`** (extraction hostname → décide quel domaine part
+ vers DDG). A révélé un commentaire faux (IPs « valides » alors qu'elles
+ sont rejetées par la règle TLD `[a-z]{2,}`) — corrigé.
+- `app.js` : 11 936 → **9 790 lignes** (6 modules sortis).
+- Suite de tests : 42 → **62 tests**.
- Reste à extraire (grosses sections cohésives) : slideover, settings,
- quicksearch, autofill, favicons, vault-health…
+ quicksearch, autofill, vault-health…
- ✅ `node --check` en pré-étape de `BuildAssets.ps1` : **déjà fait** (cf. §3.2).
### 3.2 🟡 Aucun test automatisé — **partiellement adressé (2026-07-04)**
diff --git a/delphi-backend/assets/BuildAssets.ps1 b/delphi-backend/assets/BuildAssets.ps1
index e9a955b..6011684 100644
--- a/delphi-backend/assets/BuildAssets.ps1
+++ b/delphi-backend/assets/BuildAssets.ps1
@@ -54,6 +54,7 @@ $patterns = @(
'js\argon2.js',
'js\app.crypto.js',
'js\app.totp.js',
+ 'js\app.favicon.js',
'js\app.import.js',
'js\app.backup.js',
'js\app.js',
diff --git a/delphi-backend/assets/assets.inc b/delphi-backend/assets/assets.inc
index 13111a1..4d934ed 100644
--- a/delphi-backend/assets/assets.inc
+++ b/delphi-backend/assets/assets.inc
@@ -1,11 +1,12 @@
// Auto-generated by BuildAssets.ps1 - do not edit by hand.
const
- EMBEDDED_ASSET_COUNT = 9;
+ EMBEDDED_ASSET_COUNT = 10;
EMBEDDED_ASSETS: array[0..EMBEDDED_ASSET_COUNT-1] of TEmbeddedAsset = (
(UrlPath: '/index.html'; ResName: 'INDEX_HTML'),
(UrlPath: '/js/argon2.js'; ResName: 'JS_ARGON2_JS'),
(UrlPath: '/js/app.crypto.js'; ResName: 'JS_APP_CRYPTO_JS'),
(UrlPath: '/js/app.totp.js'; ResName: 'JS_APP_TOTP_JS'),
+ (UrlPath: '/js/app.favicon.js'; ResName: 'JS_APP_FAVICON_JS'),
(UrlPath: '/js/app.import.js'; ResName: 'JS_APP_IMPORT_JS'),
(UrlPath: '/js/app.backup.js'; ResName: 'JS_APP_BACKUP_JS'),
(UrlPath: '/js/app.js'; ResName: 'JS_APP_JS'),
diff --git a/delphi-backend/assets/assets.rc b/delphi-backend/assets/assets.rc
index bd8fde6..f62f816 100644
--- a/delphi-backend/assets/assets.rc
+++ b/delphi-backend/assets/assets.rc
@@ -5,6 +5,7 @@ INDEX_HTML RCDATA "Z:\\password-manager\\index.html"
JS_ARGON2_JS RCDATA "Z:\\password-manager\\js\\argon2.js"
JS_APP_CRYPTO_JS RCDATA "Z:\\password-manager\\js\\app.crypto.js"
JS_APP_TOTP_JS RCDATA "Z:\\password-manager\\js\\app.totp.js"
+JS_APP_FAVICON_JS RCDATA "Z:\\password-manager\\js\\app.favicon.js"
JS_APP_IMPORT_JS RCDATA "Z:\\password-manager\\js\\app.import.js"
JS_APP_BACKUP_JS RCDATA "Z:\\password-manager\\js\\app.backup.js"
JS_APP_JS RCDATA "Z:\\password-manager\\js\\app.js"
diff --git a/delphi-backend/assets/assets.res b/delphi-backend/assets/assets.res
index 337a805..a0bef46 100644
Binary files a/delphi-backend/assets/assets.res and b/delphi-backend/assets/assets.res differ
diff --git a/index.html b/index.html
index 54c4579..9a02bd7 100644
--- a/index.html
+++ b/index.html
@@ -1195,6 +1195,7 @@
+
diff --git a/js/app.favicon.js b/js/app.favicon.js
new file mode 100644
index 0000000..8db0cbd
--- /dev/null
+++ b/js/app.favicon.js
@@ -0,0 +1,125 @@
+// ============================================================
+// app.favicon.js — FAVICONS module (extracted from app.js, §3.1)
+// ============================================================
+//
+// Website favicon fetch/cache (opt-in, via the Delphi DuckDuckGo proxy).
+// faviconHost() is a pure URL→validated-hostname function (unit-tested);
+// the rest are api/Bridge/render calls. Pure declarations, no top-level
+// side effects → loads BEFORE app.js. Uses state, api, Bridge, render via
+// shared global scope at call time.
+//
+// ============================================================
+// FAVICONS (opt-in, cached server-side as base64 data URI)
+// ============================================================
+
+// Extract a usable host from entry.site (we accept anything user-typed).
+// Returns '' for values that don't look like real hostnames — common case
+// is users storing a brand label ("Gitea", "Work GitHub") to help the
+// autofill matcher. Sending those to DDG would leak meaningless tokens
+// without ever producing an icon.
+function faviconHost(siteRaw) {
+ if (!siteRaw) return '';
+ let s = String(siteRaw).trim().toLowerCase();
+ s = s.replace(/^https?:\/\//, '').replace(/^www\./, '');
+ s = s.split('/')[0].split(':')[0];
+ // Validate: dot-separated labels, only hostname-safe chars, TLD ≥ 2
+ // LETTERS. Rejects "Gitea", "my work pwd", and raw IPs like "1.2.3.4"
+ // (numeric final label fails the [a-z]{2,} TLD rule — no favicon for a
+ // bare IP, which is fine). 253-char overall cap mirrors the DNS spec.
+ if (!s || s.length > 253) return '';
+ if (!/^[a-z0-9.-]+$/.test(s)) return '';
+ if (s.indexOf('.') < 1) return '';
+ if (!/\.[a-z]{2,}$/.test(s)) return '';
+ if (s.startsWith('.') || s.endsWith('.')) return '';
+ if (s.indexOf('..') >= 0) return '';
+ return s;
+}
+
+// Save the icon for one entry via the dedicated endpoint (no full PUT,
+// no re-encryption). Fire-and-forget: failures are silent so a flaky
+// network doesn't break the user's flow.
+async function saveEntryIcon(entryId, dataUri) {
+ try {
+ const r = await fetch(API + '/entries/' + entryId + '/icon', {
+ method: 'POST',
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
+ body: JSON.stringify({ icon_b64: dataUri || '' }),
+ });
+ if (!r.ok) {
+ // Surface the server's reason so silent persistence failures
+ // (size cap, auth) stop being invisible bugs.
+ let msg = 'HTTP ' + r.status;
+ try { const b = await r.json(); if (b && b.error) msg = b.error; }
+ catch (_) {}
+ toast('Icon NOT saved: ' + msg, 'error');
+ }
+ } catch (e) {
+ toast('Icon save failed: ' + (e && e.message || e), 'error');
+ }
+}
+
+// Fetch + save the favicon for one entry. Updates state.entries in-place
+// so the next render() picks it up. No-op if the entry already has one.
+// opts: { force: bypass "already has icon" skip, manual: bypass the global
+// faviconsEnabled toggle (for explicit user actions like the Refresh button) }
+async function ensureEntryFavicon(entry, opts) {
+ opts = opts || {};
+ if (!Bridge.active) return;
+ if (!opts.manual && !state.faviconsEnabled) return;
+ if (!opts.force && entry.icon_b64) return;
+ const host = faviconHost(entry.site);
+ if (!host) return;
+ const dataUri = await Bridge.fetchFavicon(host);
+ if (!dataUri) return;
+ entry.icon_b64 = dataUri;
+ await saveEntryIcon(entry.id, dataUri);
+ // Full render() — patching the avatar in place is fragile because
+ // the avatar also contains the checkbox overlay.
+ render();
+}
+
+// Backfill: walk state.entries, fetch missing icons one at a time so we
+// don't hammer the upstream. Used by the "Refresh icons" button.
+async function backfillFavicons(force) {
+ if (!Bridge.active) return;
+ const all = state.entries;
+ const eligible = all.filter(e => faviconHost(e.site));
+ const skipped = all.length - eligible.length;
+ const targets = eligible.filter(e => force || !e.icon_b64);
+ if (targets.length === 0) {
+ if (skipped > 0) {
+ toast('No icons to fetch — ' + skipped +
+ ' entries have a non-domain site (e.g. "Gitea")', 'warning');
+ } else {
+ toast('No icons to fetch');
+ }
+ return;
+ }
+ toast('Fetching ' + targets.length + ' icon' + (targets.length === 1 ? '' : 's') + '…');
+ let ok = 0;
+ for (const e of targets) {
+ // Explicit user action — bypass the global toggle so the buttons
+ // work even when "Fetch website icons" is OFF (the toggle only
+ // gates auto-fetch on save).
+ await ensureEntryFavicon(e, { force: !!force, manual: true });
+ if (e.icon_b64) ok++;
+ }
+ toast('Fetched ' + ok + ' / ' + targets.length + ' icons');
+ render();
+}
+
+async function clearAllFavicons() {
+ try {
+ await fetch(API + '/entries/icons/all', {
+ method: 'DELETE',
+ headers: authHeaders(),
+ });
+ } catch (e) {
+ toast('Failed to clear icons', 'error');
+ return;
+ }
+ state.entries.forEach(e => { e.icon_b64 = null; });
+ render();
+ toast('Cached icons cleared');
+}
+
diff --git a/js/app.js b/js/app.js
index ab8617d..5300cd1 100644
--- a/js/app.js
+++ b/js/app.js
@@ -718,120 +718,10 @@ async function api(path, opts) {
// ============================================================
// ============================================================
-// FAVICONS (opt-in, cached server-side as base64 data URI)
+// FAVICONS — extracted to js/app.favicon.js (§3.1), loaded as a
+// separate