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