File size: 10,552 Bytes
4e1325f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7837313
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4e1325f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
"""
postgres_store.py
Postgres-backed credits/API-key layer for HD Remover's HuggingFace backend.

Replaces the JSON-based credit/usage tracking in data_store.py (calls_today,
daily limit, users.json) with queries against the SAME Postgres database the
HDRemover website uses β€” see migrations/002_add_website_login_columns.sql
and migrations/003_add_dashboard_columns.sql in the website repo for the
exact schema this depends on.

WHY A SEPARATE FILE RATHER THAN EDITING data_store.py IN PLACE:
data_store.py's JSON system still owns things this file does NOT touch β€”
plans.json (admin-editable plan limits), the Whop-to-plan mapping, and the
webhook audit log. Those are being removed/replaced separately (Whop is
being dropped entirely in favor of Safepay, which bills through the
website, not this backend). Keeping this as a new file makes that later
cleanup a deletion of whole functions/files rather than a tangle of
half-migrated code in one already-large file.

WHAT THIS FILE OWNS:
  - API key verification (bcrypt hash comparison against api_keys table)
  - Credit balance checks and deduction (monthly_credits first, then
    lifetime_credits β€” matches the website dashboard's display order and
    the agreed business rule: subscription credits are "use it or lose
    it" each cycle, so they should be spent first)
  - last_used_at tracking on the api_keys row (surfaced in the website
    dashboard's API Keys tab)

WHAT THIS FILE DELIBERATELY DOES NOT DO:
  - No plan/daily-limit concept. There is no "free/starter/pro/master"
    plan tier here β€” a request is authorized purely by "does this API key
    belong to a user with credits > 0". Per-minute rate limiting (abuse
    prevention, not billing) still lives in app.py's in-memory
    _minute_windows β€” that's a different concern from credits and doesn't
    need to move to Postgres.
  - No user creation. Accounts are only ever created by the website
    (email/password, Google, or Facebook signup) β€” this backend only
    ever reads/updates an existing user's credit balance, never inserts
    a new user row.

Env var required: DATABASE_URL β€” same Postgres connection string used by
the website (Supabase Session Pooler connection string, NOT the direct
connection β€” see the website's lib/db.ts for why: this backend, like the
website, holds a long-lived connection pool rather than one-shot
connections per request).
"""

import os
import logging
import threading
from datetime import datetime, timezone

import bcrypt
import psycopg2
import psycopg2.pool
from psycopg2.extras import RealDictCursor

logger = logging.getLogger("hd_remover.postgres_store")

DATABASE_URL = os.environ.get("DATABASE_URL", "")

# A small connection pool (not a single global connection) so concurrent
# requests don't serialize on one connection β€” mirrors the website's own
# lib/db.ts, which uses a pg.Pool with max: 10 for the same reason.
_pool = None
_pool_lock = threading.Lock()


def _get_pool():
    global _pool
    if _pool is not None:
        return _pool
    with _pool_lock:
        if _pool is None:
            if not DATABASE_URL:
                raise RuntimeError(
                    "DATABASE_URL environment variable is not set. This backend "
                    "cannot verify API keys or check credits without it."
                )
            _pool = psycopg2.pool.ThreadedConnectionPool(minconn=1, maxconn=10, dsn=DATABASE_URL)
            logger.info("Postgres connection pool created")
    return _pool


class _PooledConnection:
    """Context manager that borrows a connection from the pool and always
    returns it, even if the query inside raises."""

    def __enter__(self):
        self._pool = _get_pool()
        self._conn = self._pool.getconn()
        return self._conn

    def __exit__(self, exc_type, exc_val, exc_tb):
        if exc_type is not None:
            # Roll back so a failed query doesn't leave the connection
            # mid-transaction when it's returned to the pool for reuse.
            self._conn.rollback()
        self._pool.putconn(self._conn)


def utcnow():
    return datetime.now(timezone.utc)


# ── API key verification ────────────────────────────────────────────────────
def find_user_by_api_key(raw_api_key: str):
    """
    Verifies a raw API key against the api_keys table's bcrypt hashes and
    returns the owning user's credit info, or None if the key is invalid.

    IMPORTANT β€” this is O(number of API keys in the system), not O(1):
    bcrypt hashes can't be looked up by an indexed equality match (the
    whole point of bcrypt is that the same input never produces the same
    hash twice, via its per-hash salt), so there is no "SELECT ... WHERE
    key_hash = ?" shortcut. Every active key's hash must be checked with
    bcrypt.checkpw() until one matches.

    This uses the key_prefix column (see migrations/003) to narrow the
    candidate set first β€” key_prefix is derived deterministically from
    the raw key (not salted), so it CAN be indexed and equality-matched,
    cutting the bcrypt comparison down to (usually) a single row instead
    of scanning every key in the table.
    """
    if not raw_api_key or not raw_api_key.startswith("hdrm_"):
        return None

    # Matches the website's generateRawKey() in
    # app/api/dashboard/api-keys/route.ts: prefix is "hdrm_" + first 8 hex
    # chars of the random part, 13 chars total.
    key_prefix = raw_api_key[:13]

    with _PooledConnection() as conn:
        with conn.cursor(cursor_factory=RealDictCursor) as cur:
            cur.execute(
                """
                SELECT ak.id AS api_key_id, ak.key_hash, ak.uid,
                       u.plan_id, u.monthly_credits, u.lifetime_credits
                FROM api_keys ak
                JOIN users u ON u.uid = ak.uid
                WHERE ak.key_prefix = %s
                """,
                (key_prefix,),
            )
            candidates = cur.fetchall()

    for row in candidates:
        stored_hash = row["key_hash"]
        if bcrypt.checkpw(raw_api_key.encode("utf-8"), stored_hash.encode("utf-8")):
            return {
                "api_key_id": row["api_key_id"],
                "uid": row["uid"],
                "plan_id": row["plan_id"],
                "monthly_credits": row["monthly_credits"],
                "lifetime_credits": row["lifetime_credits"],
            }

    return None


# ── Credit checking + deduction ─────────────────────────────────────────────
def has_credits(user_info: dict) -> bool:
    """Returns True if the user has at least 1 credit available, from
    either bucket combined."""
    return (user_info.get("monthly_credits", 0) + user_info.get("lifetime_credits", 0)) > 0


def deduct_one_credit(uid: str) -> bool:
    """
    Atomically deducts exactly 1 credit from a user's balance β€” monthly_credits
    first, falling back to lifetime_credits only once monthly is at 0 (see
    the module docstring for why this order was chosen).

    Uses a single UPDATE with a CASE expression rather than a
    read-then-write (SELECT balance, check in Python, UPDATE) β€” this
    avoids a race condition where two concurrent requests both read
    "1 credit left" before either writes, and both proceed, resulting in
    -1 credits. The database performs the check-and-decrement as one
    atomic operation instead.

    Returns True if a credit was actually deducted, False if the user had
    zero credits in both buckets (nothing was deducted β€” the row is
    matched by the WHERE clause only when there's something to spend).
    """
    with _PooledConnection() as conn:
        with conn.cursor() as cur:
            cur.execute(
                """
                UPDATE users
                SET monthly_credits = CASE WHEN monthly_credits > 0 THEN monthly_credits - 1 ELSE monthly_credits END,
                    lifetime_credits = CASE WHEN monthly_credits > 0 THEN lifetime_credits ELSE lifetime_credits - 1 END
                WHERE uid = %s
                  AND (monthly_credits > 0 OR lifetime_credits > 0)
                """,
                (uid,),
            )
            deducted = cur.rowcount > 0
        conn.commit()
    return deducted


def log_request(uid: str, api_key_id: str, feature: str, model_key: str, plan_id: str,
                 credits_charged: int, gpu: str = None):
    """
    Records one row in request_logs for the admin dashboard's analytics
    (per-model breakdown, daily counts, free-vs-paid volume). Added
    2026-08-23 β€” see migrations/004_add_request_logs.sql for the table.

    Fire-and-forget in spirit, same as touch_api_key_last_used: this must
    NEVER block or fail an actual image-processing request that already
    succeeded. Any exception here is logged, not raised.
    """
    try:
        with _PooledConnection() as conn:
            with conn.cursor() as cur:
                cur.execute(
                    """
                    INSERT INTO request_logs
                        (uid, api_key_id, feature, model_key, plan_id_at_request, is_free, credits_charged, gpu)
                    VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
                    """,
                    (uid, api_key_id, feature, model_key, plan_id, plan_id == "free", credits_charged, gpu),
                )
            conn.commit()
    except Exception as e:
        logger.warning(f"log_request failed for uid={uid} feature={feature}: {e}")


def touch_api_key_last_used(api_key_id: str):
    """
    Updates last_used_at on the api_keys row β€” surfaced in the website
    dashboard's API Keys tab so a person can tell which of their keys is
    actually in use (e.g. to safely identify an old/unused key before
    revoking it).

    Fire-and-forget in spirit (failure here should never block an actual
    image-processing request that already succeeded) β€” called after the
    real work is done, and any exception is logged, not raised.
    """
    try:
        with _PooledConnection() as conn:
            with conn.cursor() as cur:
                cur.execute(
                    "UPDATE api_keys SET last_used_at = %s WHERE id = %s",
                    (utcnow(), api_key_id),
                )
            conn.commit()
    except Exception as e:
        logger.warning(f"touch_api_key_last_used failed for {api_key_id}: {e}")