Repository navigation
Load profile and preview favicons from our route, not from Google #517
Description
Part of #512. Read the tracker first. Work on main.
Summary
- Today. The webapp and the server use Google's favicon service,
https://www.google.com/s2/favicons. Each icon request sends the reader's IP address and the linked site's origin to Google. The privacy page does not name this.- Profile links in another user's profile dialog.
- Profile links in Settings.
- The link preview, when the metadata pipeline falls back. The server puts an s2 URL in
favicon, and the client loads it. - A fourth path stores an s2 URL.
sanitizeMetadatacopies the fallback'sfaviconinto a saved profile link. Nothing renders that stored value (read from code, not measured).
- After. Every site icon on these surfaces comes from our own route,
GET /api/metadata/icon?origin=, from Add an icon route to link-metadata with an operator-set byte cap #515. No code builds or loads a Google favicon URL. The privacy page says that our server fetches these icons. - Why. The pad's new link icons come from our route, and the privacy page will describe them. A page that leaves out these Google calls would be incomplete.
Maintainer rulings that bind this issue
#512 quotes every ruling with its number and date.
- Ruling 17, file the side issues. This issue is the side issue on the existing Google s2 favicon leaks, adjusted to the new route.
- Ruling 11, privacy page. This binds this issue too. Step 6 follows the "Link icons" rule in [Tracking] Inline link icons through a hyperlink mark view #512 §Rules every child keeps.
- Ruling 1, favicons from our own route. The icon route lives in hocuspocus. This issue moves the s2 surfaces onto it.
Depends on / Blocks
- Depends on Add an icon route to link-metadata with an operator-set byte cap #515. The icon route must be on
main. Step 6 also needs Add an icon route to link-metadata with an operator-set byte cap #515 Decision 1 (Traefik query logging) for one privacy sentence. - Step 6.3 depends on Show a link icon before every pad link in the webapp #516 Decision 2 (
LEGAL_UPDATED). That issue owns the date ruling. This issue only applies it. - Ships with Show a link icon before every pad link in the webapp #516, if Show a link icon before every pad link in the webapp #516 Decision 1 is ruled yes. That decision recommends one release for both issues. This issue does not wait for it. Steps 1 and 6 say what to do if either issue lands first.
- Blocks nothing.
Current behaviour
Links are pinned to 69ca1ef1649540f7c8989dfd74ad901c741a30b1.
getGoogleFaviconUrlbuilds the s2 URL from the input's origin (link-helpers.ts:17-29). It sends the origin only, not the full link. Its comment relies on s2 "always" answering with a PNG, so there is "no broken-image case". The parse addshttps://only when the input does not start withhttp. Somailto:a@b.comgives the originhttps://b.com, andhttpbin.orggivesundefined(laptop, plugin only, no view; probe below).- Two callers only.
git grep -n getGoogleFaviconUrlfindsProfileLinkRow.tsxandSocialLinks.tsx, plus the definition.- The profile dialog calls it for every link that is not email, phone or a known social brand (
ProfileLinkRow.tsx:36-55). The<img>has anonErrorthat hides it and shows theLuLinkfallback. This dialog shows another user's links, so the viewer's IP goes to Google. - Settings links call it through
getFaviconUrl(SocialLinks.tsx:79-82). The favicon branch, with the sameonErrorfallback, isSocialLinks.tsx:248-267.
- The profile dialog calls it for every link that is not email, phone or a known social brand (
- The server preview fallback returns an s2 URL as
favicon(fallback.ts:3-4,:26). It is the only code that setsfavicon.git grep -n faviconin the module finds no other writer. A unit test pins the s2 URL, and its title names Google (fallback.test.ts:22-27). - Both preview surfaces load
faviconin the fourth slot. The order isicon, publisher logo, OG image,favicon, thenoembed.thumbnail.- Desktop popover:
previewShared.ts:109-117. Only when no candidate exists does the special icon branch run, formailto:,tel:and app schemes (:128-134). - Mobile sheet:
pickImageSrc(data)takes nohref(LinkPreviewSheet.tsx:21-26). A missing image shows abg-base-300placeholder. A failed image is hidden, and the 24px slot stays empty (:65-80). - So a fallback result sends the reader to Google on each preview of a site the scraper could not read.
- Desktop popover:
- Cached fallbacks keep the s2 URL for a while (read from code, not measured):
- Redis keeps a fallback for 10 minutes (
cache.ts:5-6). - A cached hit is served with
public, max-age=3600, stale-while-revalidate=86400, because its stage readscache, notfallback(controller.ts:14-15,:57-73). - The webapp keeps a 5-minute session cache (
fetchMetadata.ts:42-59). - So a fix on the server alone would still let old responses reach Google after deploy. The client must never load the
faviconvalue.
- Redis keeps a fallback for 10 minutes (
- Settings stores the s2 URL.
sanitizeMetadatasetsicon: httpStr(raw.icon) || httpStr(raw.favicon)(link-helpers.ts:53).SocialLinks.tsxsaves that metadata in the profile link tree (SocialLinks.tsx:131-144). No webapp code readsmetadata.icon(git grep, read from code, not measured). - The REST base URL. See [Tracking] Inline link icons through a hyperlink mark view #512 §Rules every child keeps, on
apiBaseUrl.tsandiconRouteUrl.ts.- The client base,
NEXT_PUBLIC_RESTAPI_URL, ends in/api. - The server knows its public origin only as an optional value,
config.app.publicUrlfromPUBLIC_RESTAPI_URL(env.ts:17). It is an origin with no/api.
- The client base,
- The module has no
publicUrltoday.inittakes{ redis, logger }(module.ts:12-33,index.ts:157-160). The pipeline callsrunFallback(canonical, requestedUrl)(pipeline.ts:80-83). Module rule 3 forbids env reads inside the module (README.md:9). Rule 5 allows only additive wire changes within v1 (README.md:11). faviconis in the wire contract as an optional string (types.ts:17, OpenAPIcomponents.ts:200).API.mddoes not name it (API.md:1133).- No
Refererreaches the REST host. The webapp sendsReferrer-Policy: same-originon every path,/:path*(next.config.js:353-377). So the<img>elements need noreferrerPolicy. - The privacy page has no section on site icons (
privacy.tsx:53-60). Both legal pages shareLEGAL_UPDATED(legalMetadata.ts:1).
Probe for item 1 and Step 2 (laptop, plugin only, no view). The probe is a local probe, run with bun 1.4.3. It shows the Step 2 rule:
| Input | getSiteIconUrl result, with NEXT_PUBLIC_RESTAPI_URL=http://localhost:4000/api |
|---|---|
https://github.com/x, github.com/x |
http://localhost:4000/api/metadata/icon?origin=https%3A%2F%2Fgithub.com |
http://example.com:8080/a |
…?origin=http%3A%2F%2Fexample.com%3A8080 |
httpbin.org |
…?origin=https%3A%2F%2Fhttpbin.org (today undefined) |
mailto:a@b.com, tel:+15551234, sms:+1555 |
undefined |
slack://open?team=T1, zoommtg://zoom.us/join, ftp://x.org |
undefined |
javascript:alert(1), not a url, '' |
undefined |
| any input, with the variable unset | undefined |
Target behaviour
- No code in
apps/,extensions/orpackages/builds agoogle.com/s2URL. - Profile links and Settings links show the favicon from our route. A miss (
404) or a rate-limit answer (429) shows the existing fallback icon, with no broken image. - A preview of a fallback result shows the favicon from our route. The client builds that URL itself and never loads the server's
faviconvalue. Previews of other results keep today's images.mailto:,tel:and app-scheme previews keep their special icon. - Settings stores no
faviconvalue in a new profile link. - The privacy page says that our server fetches site icons for these surfaces.
Implementation steps
Step 0. Read first
- Run
git grep -n -e 's2/favicons' -e getGoogleFaviconUrl. Expect hits only inlink-helpers.ts,ProfileLinkRow.tsx,SocialLinks.tsx,fallback.tsandfallback.test.ts. If you find another, stop and comment here. - Confirm Add an icon route to link-metadata with an operator-set byte cap #515 is on
main. Undermake dev-local, run these two probes:curl -i "http://localhost:4000/api/metadata/icon?origin=https://github.com": expect200and an image type.curl -i "http://localhost:4000/api/metadata/icon?origin=https://tiptap.dev": expect an empty404.
- Check whether
apps/webapp/src/utils/apiBaseUrl.tsexists. Step 1 uses the answer. - Check whether
apps/webapp/src/pages/privacy.tsxhas a "Link icons" section. Step 6 uses the answer.
Step 1. Shared apiBaseUrl.ts and iconRouteUrl.ts
Follow #512 §Rules every child keeps, on apiBaseUrl.ts and iconRouteUrl.ts. If #516 landed first, check that items 1 to 4 already hold, and go on.
- If
apps/webapp/src/utils/apiBaseUrl.tsdoes not exist, create it with the body fromfetchMetadata.ts:61-65, exported asapiBaseUrl. - Delete the private copies in
fetchMetadata.tsandapi/users/fetchLinkMetadata.ts, and import@utils/apiBaseUrlin both. - If
apps/webapp/src/utils/iconRouteUrl.tsdoes not exist, create it per [Tracking] Inline link icons through a hyperlink mark view #512. It callsapiBaseUrl()only inside the function. - Add neither file to the
@utilsbarrel.
Step 2. getSiteIconUrl in utils/link-helpers.ts
-
Replace
getGoogleFaviconUrlwith this helper. Drop thesizeparameter.import { iconRouteUrl } from './iconRouteUrl' /** * Our REST icon route. A miss is an empty 404, so each <img> needs onError. * Credentials are refused because `mailto:a@b.com` parses as user `mailto` at `b.com`. */ export const getSiteIconUrl = (url: string): string | undefined => { try { const u = new URL(url.includes('://') ? url : `https://${url}`) if ((u.protocol !== 'http:' && u.protocol !== 'https:') || u.username || u.password) return undefined return iconRouteUrl(u.origin) } catch { return undefined // also covers apiBaseUrl() throwing when the variable is unset } }
-
apiBaseUrl()runs only inside the call. So importinglink-helpers.tsnever throws, in Jest too. -
In
sanitizeMetadata, changeicon: httpStr(raw.icon) || httpStr(raw.favicon)toicon: httpStr(raw.icon). A new Settings link then stores no fallback favicon. -
Check the probe table above with a throwaway bun script outside the repo. Do not commit a test, because none is ruled.
Step 3. Profile and Settings links
- In
ProfileLinkRow.tsx, callgetSiteIconUrl(link.url). - In
SocialLinks.tsx, makegetFaviconUrlreturngetSiteIconUrl(link.url). - Keep both
onErrorhandlers as they are. A404and a429both fireonerror, because neither body is an image. - Add no
referrerPolicy(Current behaviour item 10).
Step 4. Preview chains (Decision 1)
Stop until a Maintainer ruling comment exists on Decision 1. Alternative A: stop and comment here for the revised steps. Alternative B: do Step 4 only; skip Step 5. With the recommended answer:
- In
previewShared.tscreateMetadataContent, replacesafeImageSrc(data?.favicon)with(data?.favicon ? getSiteIconUrl(href) : undefined). Keep it in the same slot, beforeoembed.thumbnail.- Only the fallback sets
favicon(Current behaviour item 3). So the field's presence marks a fallback, and its value is never loaded. - Old cached responses then carry only a marker. They can no longer send the reader to Google.
renderMetadataIntobuilds data with nofaviconfrom mark attrs (previewShared.ts:160-182). This issue does not change that path. Stop link preview metadata writes from reaching every peer #519 Decision 1 may delete it.getSiteIconUrlreturnsundefinedformailto:,tel:and app schemes. So the special icon branch still runs for them.
- Only the fallback sets
- In
LinkPreviewSheet.tsx, changepickImageSrc(data)topickImageSrc(data, href), and make the samefaviconswap as item 1. - In
useLinkMetadata.ts, keepfaviconinLinkMetadataandtoLinkMetadata(useLinkMetadata.ts:11-19,:30-38). The sheet now reads it as the marker. - Update the comment at
fetchMetadata.ts:71-75from "hostname+favicon" to "hostname, with afaviconmarker".
Step 5. Server fallback (Decision 1)
Stop until a Maintainer ruling comment exists on Decision 1. With the recommended answer:
- Add
publicUrl?: string | nulltoInitDepsinmodule.ts, and default it tonullininit. It must stay optional, becausespecCoverage.test.ts:63callsinit({ redis, logger })and the hocuspocus typecheck covers that file. Pass it throughcreateRoutertoControllerDeps, and from the controller intorunPipelineas an optionalpublicUrlfield. Add an icon route to link-metadata with an operator-set byte cap #515 also editsInitDeps(iconMaxBytes), so merge with its change. - In
src/index.ts, passpublicUrl: config.app.publicUrltolinkMetadata.init. The module reads no env (rule 3). - In
fallback.ts, deletegoogleFaviconUrl. Add a third parameter,publicUrl: string | null = null. Setfaviconto${publicUrl}/api/metadata/icon?origin=${encodeURIComponent(parsed.origin)}when bothparsedandpublicUrlexist, elseundefined. Keep the path as the literal/api/metadata/icon. Importing the module index fromdomain/stages/fallback.tswould make an import cycle and bend module rule 1. - Keep
favicon?: stringinMetadataResponseand in the OpenAPI schema. The type does not change; only its value changes. - In
fallback.test.ts, rewrite the test at lines 22-27. Rename it to "emits the icon route URL when publicUrl is set". Pass'http://localhost:4000', and expecthttp://localhost:4000/api/metadata/icon?origin=https%3A%2F%2Fexample.com. Add no other test. Prove it: break the URL template, see the test fail, then restore it.
Step 6. Privacy text (Ruling 11)
- If Step 0.4 found the "Link icons" section, check it for the Show a link icon before every pad link in the webapp #516 Step 5.3 sentence about Google. It starts "Profile links and link previews load site icons from Google".
- If that sentence is there, replace it with: "Profile links, Settings links and link previews load site icons the same way."
- If it is not there, add that same sentence.
- If it did not, add a
LegalSectiontitled "Link icons" after "Who we share with", with this text:- "When you view profile links or a link preview, your browser asks our server for the linked website's icon. The request carries the site address, not the full link."
- "Our server then fetches that icon from the site."
- The log sentence follows Add an icon route to link-metadata with an operator-set byte cap #515 Decision 1. Stop until a Maintainer ruling comment exists there.
- If the Traefik query is dropped, add: "Our logs keep your IP address and the request path, not the site address."
- If it is kept, add: "Our logs keep your IP address. For icons that fail to load, our proxy logs also keep the site address."
- Show a link icon before every pad link in the webapp #516 Step 5 then extends this section, and does not create a second one. Name this at the Review checkpoint, so the maintainer can tell that issue's executor.
- Apply the Show a link icon before every pad link in the webapp #516 Decision 2 ruling to
LEGAL_UPDATEDinlegalMetadata.ts. Stop until that Maintainer ruling comment exists.- Do this after item 1 or item 2, because each one changes the privacy text.
- The recommended answer sets the shared date to the ship date, so the terms page shows the same date. The alternative is a separate privacy date constant. If Show a link icon before every pad link in the webapp #516 already added that constant, set it, not
LEGAL_UPDATED.
Step 7. CHANGELOGs
End each bullet with a link to this issue, ([#517](https://github.com/docs-plus/docs.plus/issues/517)). Route the prose through the tech-writer skill.
apps/webapp/CHANGELOG.md[Unreleased], new### Securityheading: profile links, Settings links and link previews load site icons from the docs.plus server, not from Google. Settings no longer stores a fallback favicon URL. Name link previews only if Step 4 shipped.apps/hocuspocus.server/CHANGELOG.md[Unreleased], the existing### Securityblock: the metadata fallback'sfaviconnow points at/api/metadata/iconwhenPUBLIC_RESTAPI_URLis set, and is absent otherwise. It is no longer a Google URL. Write this bullet only if Step 5 shipped, and match the ruled answer.- Add no root
CHANGELOG.mdline. The release step writes the product notes.
Review checkpoint
Show the touched files and the test output, then stop. Do not run git add, git commit, git push or git stash. Do not create a branch.
Decisions
Owner: the maintainer. The recommended answer comes first.
- Preview fallback: let the server set the fallback
faviconto our icon route URL, with a module rule 5 exception? Yes. The client uses the field only as a marker.- Rule 5 exception: this change is not additive. It changes the value of a typed v1 field.
- Cost: a self-host without
PUBLIC_RESTAPI_URLshows no icon on fallback previews. Today it shows a Google icon. The docs.plus deploy always sets the variable; its workflows refuse to deploy without it (ENV.md:198). The server change threadspublicUrlthroughinit,createRouter, the controller,runPipelineandrunFallback, for a value the webapp never loads. - Alternative A: the server drops the value, and the client adds our icon to every http(s) preview with no other image.
- Alternative B: ship only the client marker; other API consumers still get a Google URL.
- Needed by Steps 4 and 5.
Acceptance criteria
- With the recommended answer to Decision 1,
git grep -n -e 'google.com/s2' -e getGoogleFaviconUrl -- apps extensions packagesfinds nothing. Under Alternative B, the only hits arefallback.tsandfallback.test.ts. -
apps/webapp/src/utils/apiBaseUrl.tsholds the onlyapiBaseUrl()function in the webapp, and it is not in the@utilsbarrel. The local variable infetchDocument.ts:55-62stays. -
getSiteIconUrlgives the results in the probe table, includingundefinedwith the variable unset. - Profile links and Settings links load favicons from
/api/metadata/icon. Atiptap.devlink shows the fallback icon. - A fallback preview shows our favicon, or no image after a miss. The Network panel shows no request to
google.com. -
mailto:,tel:andslack://previews still show their special icon, on desktop and on the mobile sheet. - A Settings link added after this change stores no
metadata.iconfromfavicon. -
fallback.test.tspasses with the rewritten test, and it fails when the URL template is broken. - The privacy page has the "Link icons" text from Step 6, with one "Link icons" section only. The legal date follows the Show a link icon before every pad link in the webapp #516 Decision 2 ruling.
- Decision 1 has a Maintainer ruling comment.
Verification
git grep -n -e 'google.com/s2' -e getGoogleFaviconUrl -- apps extensions packages
bun run --filter @docs.plus/webapp typecheck
bun run --filter @docs.plus/webapp test
bun run --filter @docs.plus/hocuspocus typecheck
(cd apps/hocuspocus.server && bun test src/modules/link-metadata src/modules/openapi)
bun run lint
bun run formatbun run lintruns theno-restricted-importsrules that guard link-metadata module rules 1 and 4.
Browser walk. Use make dev-local. Filter the Network panel on google.com, then on metadata/icon.
- Open another user's profile dialog with web links. Include
https://tiptap.devas a miss. - Open Settings › Profile links. Add a new web link, then check its stored
metadatain the profile row. - Hover a link whose preview falls back. Try
https://httpbin.org/status/500?n=<random>; the random query skips the 10-minute Redis entry. Confirmpipeline_stage: 'fallback'in the server log line. Whether this URL reaches the fallback is UNVERIFIED; pick another if the log shows a hit. - Hover a
mailto:link and aslack://link. - Repeat items 3 and 4 on the mobile sheet.
Run the theme matrix (docsplus, docsplus-dark, docsplus-dark-hc) only on the profile icon fallback in item 1. Run the other legs once. Use desktop Chrome, iOS Safari and Android Chrome.
Docs to update
apps/webapp/src/utils/link-helpers.ts: the helper comment (Step 2).apps/webapp/src/components/TipTap/hyperlinkPopovers/fetchMetadata.ts: the comment at lines 71-75 (Step 4.4).apps/hocuspocus.server/API.mdline 1133: add one sentence after the success sentence, only with the recommended answer to Decision 1.- "A fallback result's
favicon, when present, is this server'sGET /api/metadata/iconURL for the site's origin."
- "A fallback result's
apps/webapp/src/pages/privacy.tsxandlegalMetadata.ts(Step 6).- Both CHANGELOGs (Step 7).
Out of scope
- Old profile rows with an s2 URL in
metadata.icon. Nothing renders that field (read from code, not measured), so the rows send no request. Leave them. A cleanup needs a separate maintainer ask. - The preview's other image sources:
icon, the publisher logo, the OG image and the oEmbed thumbnail. They load from the linked site, not from Google. - The
SocialIconbrand glyphs. They are local SVG. - The pad-link paragraph in the "Link icons" section. Show a link icon before every pad link in the webapp #516 owns that section; Step 6 adds or replaces one sentence only, unless this issue ships first.
- The
LEGAL_UPDATEDruling. Show a link icon before every pad link in the webapp #516 Decision 2 owns it; Step 6.3 applies it.
Read first
AGENTS.md, mainly §Test Policy, §Simplified English Mandate and §Workflow And Review Expectations.apps/webapp/CLAUDE.md.apps/hocuspocus.server/CLAUDE.md§HTTP Modules, andapps/hocuspocus.server/src/modules/link-metadata/README.md..cursor/skills/tech-writer/SKILL.md§Simplified English, for the CHANGELOG bullets, the JSDoc and the privacy text.
Activity
HMarzban commentedon Oct 18, 2026
Maintainer ruling, 2026-10-18
The maintainer answered these Decisions through a multiple-choice prompt in the agent session. Each answer below is the option the maintainer picked; the quoted label is the option text.
faviconto our icon route URL, as a recorded exception to link-metadata module rule 5. The client uses the field only as a marker.LEGAL_UPDATED, set to the ship date) are both yes. Add an icon route to link-metadata with an operator-set byte cap #515 Decision 1 (drop query strings from the Traefik log) is yes.Recorded on the tracker #512.