diff --git a/app/main.py b/app/main.py index 3df7369..e721b13 100644 --- a/app/main.py +++ b/app/main.py @@ -6,10 +6,17 @@ import time from contextlib import asynccontextmanager from pathlib import Path +from xml.sax.saxutils import escape from fastapi import FastAPI, Form, HTTPException, Request from fastapi import Path as PathParam -from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse +from fastapi.responses import ( + HTMLResponse, + JSONResponse, + PlainTextResponse, + RedirectResponse, + Response, +) from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from slowapi import _rate_limit_exceeded_handler @@ -275,8 +282,26 @@ async def _browser_icon_redirect(): @app.get("/robots.txt", include_in_schema=False) -async def robots_txt(): - return PlainTextResponse(_ROBOTS_TXT) +async def robots_txt(request: Request): + # Sitemap must be an absolute URL (robots.txt spec) and is emitted per + # request so self-hosters on any domain get a correct one without config. + return PlainTextResponse(f"{_ROBOTS_TXT}\nSitemap: {_abs_url(request, '/sitemap.xml')}\n") + + +@app.get("/sitemap.xml", include_in_schema=False) +async def sitemap_xml(request: Request): + # The landing page is the only indexable URL this app serves. Paste pages + # are unguessable capability URLs (see the noindex in paste.html) and would + # be a privacy leak if listed, so the sitemap is deliberately a single + # entry rather than a crawl of storage. + url = escape(_abs_url(request, "/")) + body = ( + '\n' + '\n' + f" {url}monthly1.0\n" + "\n" + ) + return Response(content=body, media_type="application/xml") app.include_router(api_router) diff --git a/templates/base.html b/templates/base.html index c53bdf3..07f33ac 100644 --- a/templates/base.html +++ b/templates/base.html @@ -18,6 +18,12 @@ })(); {% block title %}Ghostbit — Encrypted paste, zero knowledge{% endblock %} + {# Indexing directives live in their own block so paste and error pages can + opt out of the index without having to restate every og:/twitter: tag. #} + {% block seo %} + + + {% endblock %} {% block meta %} diff --git a/templates/error.html b/templates/error.html index b5dd949..248ebb6 100644 --- a/templates/error.html +++ b/templates/error.html @@ -2,6 +2,12 @@ {% block title %}{{ code }} — Ghostbit{% endblock %} +{% block seo %} +{# Error pages carry no canonical: they are served under whatever path failed, + including expired paste IDs, and none of that belongs in an index. #} + +{% endblock %} + {% block header_actions %}{% endblock %} {% block page %} diff --git a/templates/paste.html b/templates/paste.html index c8ca866..7b1aab1 100644 --- a/templates/paste.html +++ b/templates/paste.html @@ -2,6 +2,16 @@ {% block title %}{{ paste.id }} — Ghostbit{% endblock %} +{% block seo %} +{# A paste URL is a capability: whoever holds it can fetch the ciphertext, and + the decryption key lives in the fragment. Letting one into a search index + leaks the capability to everyone, so paste pages are noindex and carry no + canonical. Link-preview unfurls (block meta) still work — those bots read the + page directly rather than going through the index. #} + + +{% endblock %} + {% block meta %} {# Paste pages deliberately omit og:image. An encrypted paste has nothing to preview, so link unfurls (iMessage, Slack, …) fall back to the compact card diff --git a/templates/raw.html b/templates/raw.html index c59bedd..760e640 100644 --- a/templates/raw.html +++ b/templates/raw.html @@ -3,6 +3,7 @@ + {{ paste.id }} — raw