diff --git a/.env.example b/.env.example
index 7b222f4..c32f046 100644
--- a/.env.example
+++ b/.env.example
@@ -43,5 +43,10 @@ TRUST_PROXY_HEADERS=false
# otherwise make the app advertise http:// URLs. A bad value fails fast at startup.
# BASE_URL=
+# Repo whose stargazer count the footer shows, fetched server-side once an hour.
+# Leave empty to disable the outbound call entirely (air-gapped deployments) —
+# the footer then just links to GitHub without a number.
+# GITHUB_REPO=stackopshq/ghostbit
+
# Server port (default: 8000)
PORT=8000
diff --git a/app/config.py b/app/config.py
index 7a0a0d1..80099ab 100644
--- a/app/config.py
+++ b/app/config.py
@@ -34,6 +34,12 @@ class Settings(BaseSettings):
# a TLS-terminating proxy would otherwise leave the app advertising http://.
base_url: str = ""
+ # Repo whose stargazer count the footer shows, fetched server-side once an
+ # hour. Set to "" to disable the outbound call entirely — the footer then
+ # omits the number. Never fetched from the visitor's browser: that would
+ # disclose every visitor's IP to GitHub, paste readers included.
+ github_repo: str = "stackopshq/ghostbit"
+
# Ignore extra env vars (e.g. a stale ENCRYPTION_KEY from pre-E2E setups)
# instead of failing at startup.
model_config = {"env_file": ".env", "extra": "ignore"}
diff --git a/app/main.py b/app/main.py
index e721b13..f462f8a 100644
--- a/app/main.py
+++ b/app/main.py
@@ -23,7 +23,7 @@
from slowapi.errors import RateLimitExceeded
from starlette.middleware.base import BaseHTTPMiddleware
-from . import __version__, metrics
+from . import __version__, metrics, stars
from .api import router as api_router
from .config import settings
from .languages import codemirror_mode_map, extension_map, slugs
@@ -45,7 +45,9 @@
# practical impact is limited (no script execution via CSS in current
# browsers, modulo already-broken setups).
#
-# api.github.com is whitelisted for the footer star counter.
+# connect-src is 'self' only: nothing on these pages talks to a third party.
+# The footer star count is fetched server-side (app/stars.py) precisely so the
+# visitor's browser never contacts GitHub.
def _build_csp(nonce: str) -> str:
# 'wasm-unsafe-eval' lets hash-wasm instantiate its embedded Argon2id
# WebAssembly module. Modern (Chrome 92+, Firefox 122+) browsers honor
@@ -58,7 +60,7 @@ def _build_csp(nonce: str) -> str:
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"font-src 'self'; "
- "connect-src 'self' https://api.github.com; "
+ "connect-src 'self'; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"form-action 'self'; "
@@ -167,7 +169,11 @@ async def dispatch(self, request: Request, call_next):
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.storage = await get_storage()
+ app.state.background_tasks = []
+ stars.start(app.state.background_tasks)
yield
+ for task in app.state.background_tasks:
+ task.cancel()
await app.state.storage.close()
@@ -343,6 +349,7 @@ def _sri(path: str) -> str:
templates.env.globals["sri"] = _sri
+templates.env.globals["star_count"] = stars.get_count
def _abs_url(request: Request, path: str) -> str:
diff --git a/app/stars.py b/app/stars.py
new file mode 100644
index 0000000..4e0bc65
--- /dev/null
+++ b/app/stars.py
@@ -0,0 +1,71 @@
+"""Server-side stargazer count for the footer.
+
+The footer used to fetch api.github.com from the visitor's browser. On a
+zero-knowledge paste service that is the wrong shape: it disclosed the IP of
+every visitor to GitHub, including someone opening a secret paste link, and it
+cost a third-party connection on the critical path of every page load.
+
+The server now fetches the count at most once an hour and renders it into the
+page. Deployments with no egress, or self-hosters who want no outbound calls at
+all, set GITHUB_REPO="" and the footer simply omits the number.
+
+Uses urllib rather than httpx: httpx is a test-only dependency here, and
+webhook.py already establishes stdlib HTTP as the convention for outbound calls.
+"""
+
+import asyncio
+import json
+import logging
+import urllib.error
+import urllib.request
+
+from .config import settings
+
+log = logging.getLogger(__name__)
+
+_REFRESH_INTERVAL = 3600 # GitHub allows 60 unauthenticated calls/hour/IP
+_TIMEOUT = 5
+
+# None means "no number to show" — either never fetched, or every attempt
+# failed. The template omits the count rather than rendering a misleading 0.
+_count: int | None = None
+
+
+def get_count() -> int | None:
+ """Last known stargazer count, or None if unavailable."""
+ return _count
+
+
+def _fetch_blocking(repo: str) -> int | None:
+ req = urllib.request.Request(
+ f"https://api.github.com/repos/{repo}",
+ headers={"Accept": "application/vnd.github+json", "User-Agent": "ghostbit"},
+ )
+ with urllib.request.urlopen(req, timeout=_TIMEOUT) as r: # noqa: S310 - fixed https host
+ data = json.load(r)
+ count = data.get("stargazers_count")
+ return int(count) if isinstance(count, int) else None
+
+
+async def _refresh_loop() -> None:
+ global _count
+ while True:
+ try:
+ # to_thread so a slow or hanging GitHub never blocks the event loop.
+ fetched = await asyncio.to_thread(_fetch_blocking, settings.github_repo)
+ if fetched is not None:
+ _count = fetched
+ except (urllib.error.URLError, TimeoutError, ValueError, OSError) as e:
+ # Keep the previous value: a stale count beats a disappearing one,
+ # and this must never affect serving pastes.
+ log.warning("stargazer count refresh failed: %s", e)
+ await asyncio.sleep(_REFRESH_INTERVAL)
+
+
+def start(loop_task_holder: list) -> None:
+ """Start the background refresher unless GITHUB_REPO is empty."""
+ if not settings.github_repo:
+ return
+ # Keep a strong reference: bare asyncio tasks can be garbage-collected
+ # mid-flight (same reason webhook.py holds its delivery tasks).
+ loop_task_holder.append(asyncio.create_task(_refresh_loop()))
diff --git a/static/footer.js b/static/footer.js
deleted file mode 100644
index 52ee38b..0000000
--- a/static/footer.js
+++ /dev/null
@@ -1,32 +0,0 @@
-/**
- * Footer star counter — reads the GitHub repo stargazer count once per
- * hour per browser and caches it in localStorage to stay polite with
- * the API (unauthenticated rate limit is 60/h/IP).
- */
-(function () {
- const el = document.getElementById('footerStarCount');
- const key = 'gb_stars';
- const tsKey = 'gb_stars_ts';
- const TTL = 3600 * 1000;
-
- function show(n) {
- if (el && n != null) el.textContent = n >= 1000 ? (n / 1000).toFixed(1) + 'k' : n;
- }
-
- const cached = localStorage.getItem(key);
- const ts = parseInt(localStorage.getItem(tsKey) || '0');
- if (cached && Date.now() - ts < TTL) {
- show(parseInt(cached));
- return;
- }
-
- fetch('https://api.github.com/repos/stackopshq/ghostbit')
- .then(r => r.json())
- .then(d => {
- if (d.stargazers_count == null) return;
- localStorage.setItem(key, d.stargazers_count);
- localStorage.setItem(tsKey, Date.now());
- show(d.stargazers_count);
- })
- .catch(() => {});
-})();
diff --git a/templates/base.html b/templates/base.html
index 07f33ac..8ef9421 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -82,6 +82,5 @@
{% block scripts %}{% endblock %}
-