Serve /favicon.ico as a redirect to the configured site icon

This commit is contained in:
2026-09-04 14:59:38 +00:00
parent 33a4a76364
commit 986e28c220
5 changed files with 26 additions and 10 deletions
+1 -1
View File
@@ -64,6 +64,6 @@ Server run by CLI entry point `uv run pagerite` (no auto reloads, build needed).
## Conventions ## Conventions
- Keep dependencies minimal; add via `uv add` and mention it. - Keep dependencies minimal; add via `uv add` and mention it.
- The public URL space belongs to content (pretty slugs at root). Reserve only `/_` for the machinery (`/_api/`, `/_f/`, `/_assets/`), plus `/favicon.ico` from the build. Slugs are lowercase ASCII letters, digits, hyphens and underscores `[a-z0-9_-]` (the site editor filters input live via `slugify.js`, built on the `transliteration` npm package — unicode folds to ASCII, spaces become hyphens; an empty slug on a new page is derived from its title), may not begin with `_` or `.`, and such URLs are never looked up as content. - The public URL space belongs to content (pretty slugs at root). Reserve only `/_` for the machinery (`/_api/`, `/_f/`, `/_assets/`), plus `/favicon.ico` (backend redirect to the configured site icon). Slugs are lowercase ASCII letters, digits, hyphens and underscores `[a-z0-9_-]` (the site editor filters input live via `slugify.js`, built on the `transliteration` npm package — unicode folds to ASCII, spaces become hyphens; an empty slug on a new page is derived from its title), may not begin with `_` or `.`, and such URLs are never looked up as content.
- No auth in core code; the SSO/reverse proxy gates all of `/_api` (forward-auth) and owns `/auth/` (login/logout, session validation). Pages render identically for everyone; pagerite.js adds the editing UI only after the auth server validates the session. The one keyed exception is `/_translate/{key}` (translator service; `Data.translate_keys`, see docs/localization.md). - No auth in core code; the SSO/reverse proxy gates all of `/_api` (forward-auth) and owns `/auth/` (login/logout, session validation). Pages render identically for everyone; pagerite.js adds the editing UI only after the auth server validates the session. The one keyed exception is `/_translate/{key}` (translator service; `Data.translate_keys`, see docs/localization.md).
- Update the relevant MarkDown files when architecture, tooling, or conventions change. - Update the relevant MarkDown files when architecture, tooling, or conventions change.
+2 -2
View File
@@ -48,7 +48,7 @@ logger = logging.getLogger(__name__)
# Vue build served at the site root, no SPA catch-all (assets only). The # Vue build served at the site root, no SPA catch-all (assets only). The
# build mirrors the URL space: hashed, immutable files live under # build mirrors the URL space: hashed, immutable files live under
# /_assets/ (assetsDir: '_/assets'), the favicon at /favicon.ico. # /_assets/ (assetsDir: '_/assets').
frontend = Frontend( frontend = Frontend(
Path(__file__).with_name("frontend-build"), spa=False, cached="/_assets/" Path(__file__).with_name("frontend-build"), spa=False, cached="/_assets/"
) )
@@ -124,7 +124,7 @@ app.include_router(tracking.router)
app.include_router(files.router) app.include_router(files.router)
# Vue build asset routes are inserted at this position during load(): the # Vue build asset routes are inserted at this position during load(): the
# build mirrors the URL space (/_assets/*, /favicon.ico at the root). # build mirrors the URL space (/_assets/*).
frontend.route(app, "/") frontend.route(app, "/")
# The content catch-all goes last: built assets win over content slugs, # The content catch-all goes last: built assets win over content slugs,
+2 -2
View File
@@ -98,8 +98,8 @@ class Data(msgspec.Struct):
#: Trusted author content; not sanitized. #: Trusted author content; not sanitized.
custom_css: str = "" custom_css: str = ""
#: Favicon: content-addressed file name (served at "/_f/{name}"), #: Favicon: content-addressed file name (served at "/_f/{name}"),
#: linked as <link rel="icon"> on every page. Empty = the build's #: linked as <link rel="icon"> on every page; /favicon.ico redirects
#: /favicon.ico. #: to it. Empty = no icon (and /favicon.ico 404s).
favicon: str = "" favicon: str = ""
#: API keys gating the translator service WebSocket (/_translate/{key}; #: API keys gating the translator service WebSocket (/_translate/{key};
#: the external forward-auth does not cover that route): key -> display #: the external forward-auth does not cover that route): key -> display
+19 -3
View File
@@ -6,7 +6,8 @@ when compression shrinks the body), served immutable at ``/_f/``. Raster
images and SVGs are recompressed into AVIF/WebP/JPEG derivatives images and SVGs are recompressed into AVIF/WebP/JPEG derivatives
(``store_image`` and helpers); the untouched original is kept alongside as (``store_image`` and helpers); the untouched original is kept alongside as
``<hash>.orig<ext>`` (never served). Routes: upload/delete under ``<hash>.orig<ext>`` (never served). Routes: upload/delete under
``/_api/files``, the favicon settings endpoints, the ``/_f/`` server with ``/_api/files``, the favicon settings endpoints, the /favicon.ico
redirect to the configured icon, the ``/_f/`` server with
Accept-negotiated formats, and the user assets (``/_themes/``, ``/_fonts/``). Accept-negotiated formats, and the user assets (``/_themes/``, ``/_fonts/``).
""" """
@@ -19,7 +20,7 @@ from pathlib import Path
import blake3 import blake3
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import Response from fastapi.responses import RedirectResponse, Response
from mediapreview import dispatch from mediapreview import dispatch
from pagerite import views from pagerite import views
@@ -252,6 +253,20 @@ async def delete_file(name: str) -> None:
file_store.delete(name) file_store.delete(name)
@router.get("/favicon.ico", include_in_schema=False)
async def favicon_ico() -> Response:
"""The conventional /favicon.ico: redirect to the configured site icon.
Browsers request this path on their own (tabs, bookmarks, feeds and
other non-HTML contexts) regardless of the <link rel="icon"> pages
carry. Redirect to the icon's store URL, which negotiates the format
and caches immutably; 404 when no custom icon is configured.
"""
if not data.favicon:
raise HTTPException(404)
return RedirectResponse(f"/_f/{data.favicon}")
@router.put("/_api/settings/favicon") @router.put("/_api/settings/favicon")
async def put_favicon(request: Request) -> dict[str, str]: async def put_favicon(request: Request) -> dict[str, str]:
"""Upload a favicon into the content-addressed store and activate it. """Upload a favicon into the content-addressed store and activate it.
@@ -276,7 +291,8 @@ async def put_favicon(request: Request) -> dict[str, str]:
@router.delete("/_api/settings/favicon", status_code=204) @router.delete("/_api/settings/favicon", status_code=204)
async def delete_favicon(request: Request) -> None: async def delete_favicon(request: Request) -> None:
"""Clear the custom favicon (back to the build's /favicon.ico). """Clear the custom favicon (/favicon.ico goes back to 404, pages drop
the <link rel="icon">).
The blob stays in the content-addressed store; only the reference goes. The blob stays in the content-addressed store; only the reference goes.
""" """
+2 -2
View File
@@ -372,8 +372,8 @@ def _layout(
doc.meta(property=key, content=value) doc.meta(property=key, content=value)
else: else:
doc.meta(name=key, content=value) doc.meta(name=key, content=value)
# A custom favicon (from the site editor) is linked explicitly; without # A custom favicon (from the site editor) is linked explicitly;
# one, browsers fall back to the build's /favicon.ico by convention. # /favicon.ico redirects to the same store file for non-HTML contexts.
if favicon: if favicon:
doc.link(rel="icon", href=f"/_f/{favicon}", id="pagerite-favicon") doc.link(rel="icon", href=f"/_f/{favicon}", id="pagerite-favicon")
# Asset URLs for the on-demand bundles (editor, analytics) for # Asset URLs for the on-demand bundles (editor, analytics) for