hdremover commited on
Commit
4e1325f
Β·
verified Β·
1 Parent(s): 969c296

Create postgres_store.py

Browse files
Files changed (1) hide show
  1. postgres_store.py +222 -0
postgres_store.py ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ postgres_store.py
3
+ Postgres-backed credits/API-key layer for HD Remover's HuggingFace backend.
4
+
5
+ Replaces the JSON-based credit/usage tracking in data_store.py (calls_today,
6
+ daily limit, users.json) with queries against the SAME Postgres database the
7
+ HDRemover website uses β€” see migrations/002_add_website_login_columns.sql
8
+ and migrations/003_add_dashboard_columns.sql in the website repo for the
9
+ exact schema this depends on.
10
+
11
+ WHY A SEPARATE FILE RATHER THAN EDITING data_store.py IN PLACE:
12
+ data_store.py's JSON system still owns things this file does NOT touch β€”
13
+ plans.json (admin-editable plan limits), the Whop-to-plan mapping, and the
14
+ webhook audit log. Those are being removed/replaced separately (Whop is
15
+ being dropped entirely in favor of Safepay, which bills through the
16
+ website, not this backend). Keeping this as a new file makes that later
17
+ cleanup a deletion of whole functions/files rather than a tangle of
18
+ half-migrated code in one already-large file.
19
+
20
+ WHAT THIS FILE OWNS:
21
+ - API key verification (bcrypt hash comparison against api_keys table)
22
+ - Credit balance checks and deduction (monthly_credits first, then
23
+ lifetime_credits β€” matches the website dashboard's display order and
24
+ the agreed business rule: subscription credits are "use it or lose
25
+ it" each cycle, so they should be spent first)
26
+ - last_used_at tracking on the api_keys row (surfaced in the website
27
+ dashboard's API Keys tab)
28
+
29
+ WHAT THIS FILE DELIBERATELY DOES NOT DO:
30
+ - No plan/daily-limit concept. There is no "free/starter/pro/master"
31
+ plan tier here β€” a request is authorized purely by "does this API key
32
+ belong to a user with credits > 0". Per-minute rate limiting (abuse
33
+ prevention, not billing) still lives in app.py's in-memory
34
+ _minute_windows β€” that's a different concern from credits and doesn't
35
+ need to move to Postgres.
36
+ - No user creation. Accounts are only ever created by the website
37
+ (email/password, Google, or Facebook signup) β€” this backend only
38
+ ever reads/updates an existing user's credit balance, never inserts
39
+ a new user row.
40
+
41
+ Env var required: DATABASE_URL β€” same Postgres connection string used by
42
+ the website (Supabase Session Pooler connection string, NOT the direct
43
+ connection β€” see the website's lib/db.ts for why: this backend, like the
44
+ website, holds a long-lived connection pool rather than one-shot
45
+ connections per request).
46
+ """
47
+
48
+ import os
49
+ import logging
50
+ import threading
51
+ from datetime import datetime, timezone
52
+
53
+ import bcrypt
54
+ import psycopg2
55
+ import psycopg2.pool
56
+ from psycopg2.extras import RealDictCursor
57
+
58
+ logger = logging.getLogger("hd_remover.postgres_store")
59
+
60
+ DATABASE_URL = os.environ.get("DATABASE_URL", "")
61
+
62
+ # A small connection pool (not a single global connection) so concurrent
63
+ # requests don't serialize on one connection β€” mirrors the website's own
64
+ # lib/db.ts, which uses a pg.Pool with max: 10 for the same reason.
65
+ _pool = None
66
+ _pool_lock = threading.Lock()
67
+
68
+
69
+ def _get_pool():
70
+ global _pool
71
+ if _pool is not None:
72
+ return _pool
73
+ with _pool_lock:
74
+ if _pool is None:
75
+ if not DATABASE_URL:
76
+ raise RuntimeError(
77
+ "DATABASE_URL environment variable is not set. This backend "
78
+ "cannot verify API keys or check credits without it."
79
+ )
80
+ _pool = psycopg2.pool.ThreadedConnectionPool(minconn=1, maxconn=10, dsn=DATABASE_URL)
81
+ logger.info("Postgres connection pool created")
82
+ return _pool
83
+
84
+
85
+ class _PooledConnection:
86
+ """Context manager that borrows a connection from the pool and always
87
+ returns it, even if the query inside raises."""
88
+
89
+ def __enter__(self):
90
+ self._pool = _get_pool()
91
+ self._conn = self._pool.getconn()
92
+ return self._conn
93
+
94
+ def __exit__(self, exc_type, exc_val, exc_tb):
95
+ if exc_type is not None:
96
+ # Roll back so a failed query doesn't leave the connection
97
+ # mid-transaction when it's returned to the pool for reuse.
98
+ self._conn.rollback()
99
+ self._pool.putconn(self._conn)
100
+
101
+
102
+ def utcnow():
103
+ return datetime.now(timezone.utc)
104
+
105
+
106
+ # ── API key verification ────────────────────────────────────────────────────
107
+ def find_user_by_api_key(raw_api_key: str):
108
+ """
109
+ Verifies a raw API key against the api_keys table's bcrypt hashes and
110
+ returns the owning user's credit info, or None if the key is invalid.
111
+
112
+ IMPORTANT β€” this is O(number of API keys in the system), not O(1):
113
+ bcrypt hashes can't be looked up by an indexed equality match (the
114
+ whole point of bcrypt is that the same input never produces the same
115
+ hash twice, via its per-hash salt), so there is no "SELECT ... WHERE
116
+ key_hash = ?" shortcut. Every active key's hash must be checked with
117
+ bcrypt.checkpw() until one matches.
118
+
119
+ This uses the key_prefix column (see migrations/003) to narrow the
120
+ candidate set first β€” key_prefix is derived deterministically from
121
+ the raw key (not salted), so it CAN be indexed and equality-matched,
122
+ cutting the bcrypt comparison down to (usually) a single row instead
123
+ of scanning every key in the table.
124
+ """
125
+ if not raw_api_key or not raw_api_key.startswith("hdrm_"):
126
+ return None
127
+
128
+ # Matches the website's generateRawKey() in
129
+ # app/api/dashboard/api-keys/route.ts: prefix is "hdrm_" + first 8 hex
130
+ # chars of the random part, 13 chars total.
131
+ key_prefix = raw_api_key[:13]
132
+
133
+ with _PooledConnection() as conn:
134
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
135
+ cur.execute(
136
+ """
137
+ SELECT ak.id AS api_key_id, ak.key_hash, ak.uid,
138
+ u.plan_id, u.monthly_credits, u.lifetime_credits
139
+ FROM api_keys ak
140
+ JOIN users u ON u.uid = ak.uid
141
+ WHERE ak.key_prefix = %s
142
+ """,
143
+ (key_prefix,),
144
+ )
145
+ candidates = cur.fetchall()
146
+
147
+ for row in candidates:
148
+ stored_hash = row["key_hash"]
149
+ if bcrypt.checkpw(raw_api_key.encode("utf-8"), stored_hash.encode("utf-8")):
150
+ return {
151
+ "api_key_id": row["api_key_id"],
152
+ "uid": row["uid"],
153
+ "plan_id": row["plan_id"],
154
+ "monthly_credits": row["monthly_credits"],
155
+ "lifetime_credits": row["lifetime_credits"],
156
+ }
157
+
158
+ return None
159
+
160
+
161
+ # ── Credit checking + deduction ─────────────────────────────────────────────
162
+ def has_credits(user_info: dict) -> bool:
163
+ """Returns True if the user has at least 1 credit available, from
164
+ either bucket combined."""
165
+ return (user_info.get("monthly_credits", 0) + user_info.get("lifetime_credits", 0)) > 0
166
+
167
+
168
+ def deduct_one_credit(uid: str) -> bool:
169
+ """
170
+ Atomically deducts exactly 1 credit from a user's balance β€” monthly_credits
171
+ first, falling back to lifetime_credits only once monthly is at 0 (see
172
+ the module docstring for why this order was chosen).
173
+
174
+ Uses a single UPDATE with a CASE expression rather than a
175
+ read-then-write (SELECT balance, check in Python, UPDATE) β€” this
176
+ avoids a race condition where two concurrent requests both read
177
+ "1 credit left" before either writes, and both proceed, resulting in
178
+ -1 credits. The database performs the check-and-decrement as one
179
+ atomic operation instead.
180
+
181
+ Returns True if a credit was actually deducted, False if the user had
182
+ zero credits in both buckets (nothing was deducted β€” the row is
183
+ matched by the WHERE clause only when there's something to spend).
184
+ """
185
+ with _PooledConnection() as conn:
186
+ with conn.cursor() as cur:
187
+ cur.execute(
188
+ """
189
+ UPDATE users
190
+ SET monthly_credits = CASE WHEN monthly_credits > 0 THEN monthly_credits - 1 ELSE monthly_credits END,
191
+ lifetime_credits = CASE WHEN monthly_credits > 0 THEN lifetime_credits ELSE lifetime_credits - 1 END
192
+ WHERE uid = %s
193
+ AND (monthly_credits > 0 OR lifetime_credits > 0)
194
+ """,
195
+ (uid,),
196
+ )
197
+ deducted = cur.rowcount > 0
198
+ conn.commit()
199
+ return deducted
200
+
201
+
202
+ def touch_api_key_last_used(api_key_id: str):
203
+ """
204
+ Updates last_used_at on the api_keys row β€” surfaced in the website
205
+ dashboard's API Keys tab so a person can tell which of their keys is
206
+ actually in use (e.g. to safely identify an old/unused key before
207
+ revoking it).
208
+
209
+ Fire-and-forget in spirit (failure here should never block an actual
210
+ image-processing request that already succeeded) β€” called after the
211
+ real work is done, and any exception is logged, not raised.
212
+ """
213
+ try:
214
+ with _PooledConnection() as conn:
215
+ with conn.cursor() as cur:
216
+ cur.execute(
217
+ "UPDATE api_keys SET last_used_at = %s WHERE id = %s",
218
+ (utcnow(), api_key_id),
219
+ )
220
+ conn.commit()
221
+ except Exception as e:
222
+ logger.warning(f"touch_api_key_last_used failed for {api_key_id}: {e}")