Track grading spend per user

Attribution comes from the proxy's basic-auth username, which nginx already
forwards. Recorded as a ledger of billed calls rather than a column on the
grade: a regrade overwrites the grade row, so a per-row cost would forget
what earlier runs cost, and deleting a card would erase that it was ever
paid for. Splits server-key spend (money the host actually owes) from
own-key spend (billed to the visitor) — the two must never be summed.
Attribution only, never authorization: the app is reachable directly on the
LAN, so these headers are not trustworthy for access control.
This commit is contained in:
Barely Removable 2026-08-22 17:17:14 -07:00
parent 762b394c8a
commit 9d5c68b093
4 changed files with 186 additions and 0 deletions

45
app.py
View file

@ -234,6 +234,34 @@ class Handler(BaseHTTPRequestHandler):
self.close_connection = True
self._json({"error": message}, 413)
def _username(self):
"""Who the reverse proxy authenticated, or None.
Two sources, because which one is available depends on the proxy's
config: an explicit X-Auth-User (nginx's $remote_user) if it's been
set, otherwise the Basic credentials nginx forwards upstream by
default. Only the username is ever read the password half is
discarded without being looked at, since the proxy already validated
it and this app has no business re-checking it.
For ATTRIBUTION ONLY. Anything on the LAN can reach this app
directly (see docker-compose.yml) and set either header freely, so
this must never gate access to anything it answers "who ran up
this bill", not "who is allowed in".
"""
header = (self.headers.get("X-Auth-User") or "").strip()
if header:
return header[:64]
auth = (self.headers.get("Authorization") or "").strip()
if auth.lower().startswith("basic "):
try:
decoded = base64.b64decode(auth[6:], validate=True).decode("utf-8", "replace")
except Exception:
return None
name = decoded.split(":", 1)[0].strip()
return name[:64] or None
return None
def _route(self):
path = urllib.parse.urlparse(self.path).path
# nginx forwards the full "/cards/..." URI through unchanged (same
@ -283,6 +311,10 @@ class Handler(BaseHTTPRequestHandler):
return self._json(_public_settings())
if route == "/api/vision-models":
return self._json(vision.price_guide())
if route == "/api/usage":
data = store.usage_by_user()
data["you"] = self._username()
return self._json(data)
if route == "/api/history":
return self._json({"grades": store.list_grades()})
if route.startswith("/api/history/"):
@ -385,6 +417,10 @@ class Handler(BaseHTTPRequestHandler):
)
print("[grade] done in {:.1f}s".format(time.time() - t0), flush=True)
# Surfaced so the caller can log who paid — the whole point of the
# split in the spend ledger.
self._last_used_own_key = bool(caller_key)
return {
"image_count": len(images),
"closeups": result["closeups"],
@ -433,6 +469,12 @@ class Handler(BaseHTTPRequestHandler):
grade_id = store.save_grade(grade, thumbnail=thumbnail, label=body.get("label"),
source_images=images)
# Logged even when save=false: the call was still billed, and a
# ledger that only counted saved cards would under-report spend.
store.log_grade_event(grade, username=self._username(), grade_id=grade_id,
own_key=getattr(self, "_last_used_own_key", False),
kind="grade")
return self._json({"grade": grade, "grade_id": grade_id}, 201)
def _regrade_card(self, grade_id, body):
@ -471,6 +513,9 @@ class Handler(BaseHTTPRequestHandler):
return self._error(str(exc), 502)
thumbnail = _make_thumbnail(images[0][0])
store.log_grade_event(grade, username=self._username(), grade_id=grade_id,
own_key=getattr(self, "_last_used_own_key", False),
kind="regrade")
grade = store.update_grade_result(
grade_id, grade, thumbnail=thumbnail,
source_images=images if newly_supplied else None)