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:
parent
762b394c8a
commit
9d5c68b093
4 changed files with 186 additions and 0 deletions
45
app.py
45
app.py
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue