Files changed (1) hide show
  1. limiter.py +0 -276
limiter.py DELETED
@@ -1,276 +0,0 @@
1
- """
2
- Per-day talk-time budget for the speech-to-speech demo.
3
-
4
- Our server isn't in the audio path (the browser dials the compute WebSocket
5
- directly), so it can't cut a live stream. What it *can* do is meter time with a
6
- server-clock, chunked reservation:
7
-
8
- - At grant we reserve the first chunk (CHUNK_SEC) and debit it from the day's
9
- budget. A parallel grant therefore sees the budget already spent.
10
- - The client heartbeats; each heartbeat extends the reservation one chunk at a
11
- time until the daily budget runs out, then we report `expired` so the client
12
- tears down.
13
- - On a clean end (sendBeacon) we reconcile to the real elapsed time and refund
14
- the unused chunk. A crash (no end, no heartbeats) is reaped by a sweep and
15
- forfeits at most one chunk.
16
-
17
- All time is the server's clock. Budgets are per UTC day; a new day is simply a
18
- new row (no explicit reset). Logged-in users are keyed by a hashed HF `sub`;
19
- anonymous users by BOTH a hashed IP and a hashed signed-cookie id, OR-matched
20
- (spent = max of the two) so clearing one identifier doesn't reset the budget.
21
-
22
- Storage is SQLite at $USAGE_DB_PATH, else /data (persistent Spaces storage),
23
- else a /tmp fallback. On /tmp the budget is only per-uptime — flagged in logs.
24
- """
25
-
26
- import hashlib
27
- import hmac
28
- import logging
29
- import math
30
- import os
31
- import sqlite3
32
- import tempfile
33
- import threading
34
- import time
35
- from datetime import datetime, timezone
36
- from pathlib import Path
37
-
38
- logger = logging.getLogger("s2s.limiter")
39
-
40
- # ── Tunables (env-overridable) ───────────────────────────────────────────────
41
- ANON_SEC = int(os.environ.get("LIMIT_ANON_SEC", "300")) # 5 min/day, not signed in
42
- FREE_SEC = int(os.environ.get("LIMIT_FREE_SEC", "600")) # 10 min/day, signed in, no PRO
43
- CHUNK_SEC = int(os.environ.get("RESERVE_CHUNK_SEC", "10")) # reservation granularity
44
- HEARTBEAT_SEC = int(os.environ.get("HEARTBEAT_SEC", "5")) # advertised client cadence
45
- REAP_AFTER_SEC = int(os.environ.get("SESSION_REAP_SEC", "15")) # silence before sweep
46
-
47
- # Stable across restarts or the hashed keys (and signed cookies) rotate and the
48
- # budget effectively resets. Set it as a Space secret. Falls back to a per-boot
49
- # random value (keys then only hold within one uptime).
50
- _HASH_SECRET = (os.environ.get("USAGE_HASH_SECRET", "").strip() or os.urandom(32).hex()).encode()
51
-
52
- _lock = threading.Lock()
53
- _db_path: "Path | None" = None
54
-
55
-
56
- def budget_for(tier: str) -> "int | None":
57
- """Daily second-budget for a tier, or None for unlimited.
58
-
59
- Unlimited tiers: 'pro' (paying PRO members) and 'org' (members of an
60
- allow-listed organisation, see UNLIMITED_ORGS in auth.py)."""
61
- if tier in ("pro", "org"):
62
- return None
63
- if tier == "free":
64
- return FREE_SEC
65
- return ANON_SEC
66
-
67
-
68
- def hash_key(raw: str) -> str:
69
- """HMAC a raw identifier (sub / ip / cookie id) into an opaque storage key."""
70
- digest = hmac.new(_HASH_SECRET, raw.encode("utf-8"), hashlib.sha256).hexdigest()
71
- return f"k_{digest}"
72
-
73
-
74
- def sign_cookie(value: str) -> str:
75
- """`<id>.<sig>` so a forged anon-cookie id is rejected on read."""
76
- sig = hmac.new(_HASH_SECRET, value.encode("utf-8"), hashlib.sha256).hexdigest()[:32]
77
- return f"{value}.{sig}"
78
-
79
-
80
- def verify_cookie(signed: str) -> "str | None":
81
- """Return the id if the signature checks out, else None."""
82
- if not signed or "." not in signed:
83
- return None
84
- value, _, sig = signed.rpartition(".")
85
- want = hmac.new(_HASH_SECRET, value.encode("utf-8"), hashlib.sha256).hexdigest()[:32]
86
- return value if hmac.compare_digest(sig, want) else None
87
-
88
-
89
- def _today() -> str:
90
- return datetime.now(timezone.utc).date().isoformat()
91
-
92
-
93
- def _resolve_db_path() -> Path:
94
- explicit = os.environ.get("USAGE_DB_PATH", "").strip()
95
- if explicit:
96
- return Path(explicit)
97
- data = Path("/data")
98
- if data.is_dir() and os.access(data, os.W_OK):
99
- return data / "s2s-usage.sqlite3"
100
- logger.warning("No persistent /data — usage budget falls back to /tmp (per-uptime only).")
101
- return Path(tempfile.gettempdir()) / "s2s-usage.sqlite3"
102
-
103
-
104
- def _connect() -> sqlite3.Connection:
105
- con = sqlite3.connect(_db_path, timeout=5.0)
106
- con.execute("PRAGMA journal_mode=WAL")
107
- con.execute("PRAGMA busy_timeout=5000")
108
- return con
109
-
110
-
111
- def init() -> None:
112
- """Create the schema. Call once at startup."""
113
- global _db_path
114
- _db_path = _resolve_db_path()
115
- with _lock, _connect() as con:
116
- con.execute(
117
- """CREATE TABLE IF NOT EXISTS usage_daily (
118
- user_key TEXT NOT NULL,
119
- day TEXT NOT NULL,
120
- spent_sec INTEGER NOT NULL DEFAULT 0,
121
- updated_at INTEGER NOT NULL,
122
- PRIMARY KEY (user_key, day)
123
- )"""
124
- )
125
- con.execute(
126
- """CREATE TABLE IF NOT EXISTS sessions (
127
- session_id TEXT PRIMARY KEY,
128
- keys TEXT NOT NULL, -- comma-joined usage_daily keys to debit
129
- day TEXT NOT NULL,
130
- tier TEXT NOT NULL,
131
- grant_ts REAL NOT NULL,
132
- last_seen_ts REAL NOT NULL,
133
- reserved_sec INTEGER NOT NULL,
134
- ended INTEGER NOT NULL DEFAULT 0
135
- )"""
136
- )
137
- logger.info("Usage limiter ready at %s (anon=%ss free=%ss chunk=%ss)", _db_path, ANON_SEC, FREE_SEC, CHUNK_SEC)
138
-
139
-
140
- # ── Internal helpers (call under _lock) ───────────────────────────────────────
141
-
142
- def _spent(con, key: str, day: str) -> int:
143
- row = con.execute(
144
- "SELECT spent_sec FROM usage_daily WHERE user_key=? AND day=?", (key, day)
145
- ).fetchone()
146
- return int(row[0]) if row else 0
147
-
148
-
149
- def _spent_max(con, keys, day: str) -> int:
150
- """OR-match: the most-spent identifier governs."""
151
- return max((_spent(con, k, day) for k in keys), default=0)
152
-
153
-
154
- def _add(con, keys, day: str, delta: int) -> None:
155
- now = int(time.time())
156
- for k in keys:
157
- cur = _spent(con, k, day)
158
- nxt = max(0, cur + delta)
159
- con.execute(
160
- """INSERT INTO usage_daily (user_key, day, spent_sec, updated_at)
161
- VALUES (?, ?, ?, ?)
162
- ON CONFLICT(user_key, day) DO UPDATE SET
163
- spent_sec = excluded.spent_sec, updated_at = excluded.updated_at""",
164
- (k, day, nxt, now),
165
- )
166
-
167
-
168
- # ── Public API ────────────────────────────────────────────────────────────────
169
-
170
- def remaining(keys, tier: str) -> "int | None":
171
- """Seconds left today for these keys (None = unlimited). No mutation."""
172
- budget = budget_for(tier)
173
- if budget is None:
174
- return None
175
- with _lock, _connect() as con:
176
- return max(0, budget - _spent_max(con, keys, _today()))
177
-
178
-
179
- def begin(session_id: str, keys, tier: str) -> int:
180
- """Reserve the first chunk for a new session and record it. Returns the
181
- chunk reserved (0 if the budget is already exhausted — the first heartbeat
182
- will then expire it). PRO (unlimited) is never tracked; don't call it here."""
183
- day = _today()
184
- budget = budget_for(tier)
185
- now = time.time()
186
- with _lock, _connect() as con:
187
- avail = budget - _spent_max(con, keys, day) if budget is not None else CHUNK_SEC
188
- chunk = max(0, min(CHUNK_SEC, avail))
189
- if chunk:
190
- _add(con, keys, day, chunk)
191
- con.execute(
192
- """INSERT OR REPLACE INTO sessions
193
- (session_id, keys, day, tier, grant_ts, last_seen_ts, reserved_sec, ended)
194
- VALUES (?, ?, ?, ?, ?, ?, ?, 0)""",
195
- (session_id, ",".join(keys), day, tier, now, now, chunk),
196
- )
197
- return chunk
198
-
199
-
200
- def heartbeat(session_id: str) -> bool:
201
- """Keep a session alive: extend the reservation toward `elapsed + 1 chunk`,
202
- debiting the budget chunk by chunk. Returns True while alive, False once the
203
- budget is spent (caller should tear down) or the session is unknown/ended."""
204
- now = time.time()
205
- with _lock, _connect() as con:
206
- row = con.execute(
207
- "SELECT keys, day, tier, grant_ts, reserved_sec, ended FROM sessions WHERE session_id=?",
208
- (session_id,),
209
- ).fetchone()
210
- if not row or row[5]:
211
- return False
212
- keys = row[0].split(",")
213
- day, tier, grant_ts, reserved = row[1], row[2], row[3], int(row[4])
214
- budget = budget_for(tier)
215
- elapsed = now - grant_ts
216
-
217
- # Grow the reservation one chunk at a time until it covers elapsed + a
218
- # one-chunk lookahead, or the budget runs dry.
219
- while reserved < elapsed + CHUNK_SEC:
220
- if budget is not None and _spent_max(con, keys, day) >= budget:
221
- break
222
- _add(con, keys, day, CHUNK_SEC)
223
- reserved += CHUNK_SEC
224
-
225
- alive = reserved > elapsed # could we cover the time already elapsed?
226
- con.execute(
227
- "UPDATE sessions SET last_seen_ts=?, reserved_sec=?, ended=? WHERE session_id=?",
228
- (now, reserved, 0 if alive else 1, session_id),
229
- )
230
- if not alive:
231
- _reconcile(con, keys, day, grant_ts, reserved, end_ts=now)
232
- return alive
233
-
234
-
235
- def end(session_id: str) -> None:
236
- """Clean teardown: reconcile to actual elapsed time and refund the unused
237
- reservation. Idempotent."""
238
- now = time.time()
239
- with _lock, _connect() as con:
240
- row = con.execute(
241
- "SELECT keys, day, grant_ts, reserved_sec, ended FROM sessions WHERE session_id=?",
242
- (session_id,),
243
- ).fetchone()
244
- if not row or row[4]:
245
- return
246
- keys, day, grant_ts, reserved = row[0].split(","), row[1], row[2], int(row[3])
247
- _reconcile(con, keys, day, grant_ts, reserved, end_ts=now)
248
- con.execute("UPDATE sessions SET ended=1, last_seen_ts=? WHERE session_id=?", (now, session_id))
249
-
250
-
251
- def sweep() -> None:
252
- """Reap sessions that went silent (crash / closed without a beacon): bill
253
- their elapsed time, refund the rest, mark ended. Forfeits ≤ one chunk."""
254
- now = time.time()
255
- cutoff = now - REAP_AFTER_SEC
256
- with _lock, _connect() as con:
257
- stale = con.execute(
258
- "SELECT session_id, keys, day, grant_ts, last_seen_ts, reserved_sec FROM sessions "
259
- "WHERE ended=0 AND last_seen_ts < ?",
260
- (cutoff,),
261
- ).fetchall()
262
- for session_id, keys_s, day, grant_ts, last_seen, reserved in stale:
263
- _reconcile(con, keys_s.split(","), day, grant_ts, int(reserved), end_ts=last_seen)
264
- con.execute("UPDATE sessions SET ended=1 WHERE session_id=?", (session_id,))
265
- if stale:
266
- logger.debug("swept %d stale session(s)", len(stale))
267
-
268
-
269
- def _reconcile(con, keys, day: str, grant_ts: float, reserved: int, end_ts: float) -> None:
270
- """Refund reserved-but-unused time. Bill elapsed rounded up to a chunk,
271
- capped at what was reserved."""
272
- elapsed = max(0.0, end_ts - grant_ts)
273
- billed = min(reserved, int(math.ceil(elapsed / CHUNK_SEC) * CHUNK_SEC))
274
- refund = reserved - billed
275
- if refund > 0:
276
- _add(con, keys, day, -refund)