Spaces:
Running
Running
Download postgres_store.py from hdremover/hd-background-remover: direct link, hf CLI and curl.
- Browser
- Download file 23.1 kB
-
https://huggingface.co/spaces/hdremover/hd-background-remover/resolve/main/postgres_store.py
- Command line
-
hf download hf://spaces/hdremover/hd-background-remover/postgres_store.py
-
curl -L -o postgres_store.py https://huggingface.co/spaces/hdremover/hd-background-remover/resolve/main/postgres_store.py
23.1 kB
| """ | |
| 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) and the webhook audit log. Those | |
| are being removed/replaced separately. 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, for BOTH key types the system now supports: | |
| 1. Sub-keys β user-visible, dashboard-generated (api_keys table, | |
| bcrypt hash comparison). This now covers BOTH paid and free | |
| registered users β a free user's key is a row in the exact | |
| same api_keys table as a paid user's, distinguished only by | |
| that uid's users.plan_id (== "free"). No separate table/flag | |
| was introduced for this (2026-09-02 decision) β see | |
| has_credits()/deduct_one_credit() below for how the extra | |
| api_credits pool this enables is actually spent. | |
| 2. Master keys β NEVER shown to the user, generated once | |
| server-side on a user's first-ever paid purchase (see the | |
| website's app/api/webhooks/whop/route.ts). Stored directly on | |
| the users row (master_key_hash / master_key_prefix columns) β | |
| not in api_keys β since a user has exactly one, unlike sub-keys | |
| which can be regenerated/rotated freely. Free users never have | |
| a master key β only ever a sub-key. | |
| Per the confirmed design (2026-08-26): a sub-key carries NO credits | |
| of its own β it exists purely to identify which user is calling. | |
| Whether a request arrives via a sub-key or directly via a master | |
| key, the credits/plan checked are always the SAME users row (that's | |
| why find_user_by_api_key() already joins to users for plan_id/ | |
| monthly_credits/lifetime_credits/api_credits regardless of which key | |
| type matched β no separate "look up the master key's balance" step | |
| is needed, since sub-keys and their owning master key both resolve | |
| to the same uid's row). | |
| - Credit balance checks and deduction, across THREE buckets on the | |
| users row (2026-09-02 β added api_credits, see below): | |
| 1. monthly_credits β paid subscription allowance, resets each | |
| billing cycle | |
| 2. lifetime_credits β paid one-time/rollover credits | |
| 3. api_credits β free registered users' API allowance (50/ | |
| month, confirmed 2026-09-02). Deliberately | |
| a SEPARATE pool from a free user's normal | |
| dashboard free_credits (the 100/month used | |
| for the website's own upload flow) β a free | |
| user generating an API key does not eat | |
| into their dashboard allowance or vice | |
| versa. Initial grant of 50 happens on the | |
| website (app/api/dashboard/api-keys/route.ts, | |
| on a free user's first-ever key). The | |
| MONTHLY RESET, unlike paid monthly_credits, | |
| has no billing webhook to hang off of (a | |
| free user has no subscription event) β so | |
| this backend does it lazily, inline, in | |
| find_user_by_api_key() below, see | |
| _maybe_reset_api_credits() (2026-09-03). | |
| There is no cron job or scheduled task | |
| anywhere in this system for this β deliberate, | |
| since the user is hosted on Hostinger with no | |
| convenient cron infra, and a lazy per-request | |
| check costs nothing extra on the (overwhelming | |
| majority of) requests where no reset is due. | |
| Deduction order confirmed 2026-09-02: monthly_credits first, then | |
| lifetime_credits, and ONLY THEN api_credits β this applies even to a | |
| paid user who still happens to have api_credits left over (edge | |
| case: a user was free, accumulated api_credits, then upgraded to | |
| paid β their paid buckets are still spent first, matching the | |
| existing "paid credits first" business rule rather than carving out | |
| a special case for how api_credits got there). | |
| - last_used_at tracking on the api_keys row (surfaced in the website | |
| dashboard's API Keys tab) β master-key authentication does not touch | |
| this, since there is no api_keys row for a master key to update. | |
| WHAT THIS FILE DELIBERATELY DOES NOT DO: | |
| - No plan/daily-limit concept beyond the three credit buckets above. | |
| 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, and no api_keys row creation. Accounts are only | |
| ever created by the website (email/password, Google, or Facebook | |
| signup), and API keys (both free and paid) are only ever generated | |
| by the website's dashboard β this backend only ever reads/updates an | |
| existing user's credit balance and an existing key's last_used_at, | |
| never inserts a new user or key row. Master keys are likewise only | |
| ever generated by the website's webhook, never by this backend. | |
| Resetting api_credits to 50 each month is also the website's job | |
| (cron), not this file's. | |
| 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) | |
| # Free registered users' monthly API-credit allowance β separate pool | |
| # from their normal dashboard/upload free_credits, confirmed 2026-09-02. | |
| FREE_MONTHLY_API_CREDITS = 50 | |
| def _current_month_start(): | |
| now = utcnow() | |
| return now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) | |
| def _maybe_reset_api_credits(uid: str, api_credits_reset_at): | |
| """ | |
| Lazy monthly reset for a free user's api_credits pool (2026-09-03 β | |
| see postgres_store.py's module docstring). There is no cron/webhook | |
| that renews api_credits the way Whop's payment.succeeded webhook | |
| renews monthly_credits for paid plans, since a free user never has a | |
| billing event to hang a renewal off of. Instead, this runs inline on | |
| every sub-key lookup and only issues an UPDATE when a reset is | |
| actually due (api_credits_reset_at is NULL, meaning it was never | |
| granted/reset yet, or falls before the start of the current calendar | |
| month) β for every other request this is a single in-memory | |
| datetime comparison against the row already fetched by the caller's | |
| query, no extra round trip. | |
| Only ever called for a SUB-KEY match in find_user_by_api_key() β not | |
| for master keys, since master keys are paid-only and never touch | |
| api_credits at all. | |
| Resets to 50 (FREE_MONTHLY_API_CREDITS below) rather than adding β | |
| matches the "replace, don't carry forward" rule already used for | |
| paid monthly_credits (see the website's Whop webhook), so unused | |
| api_credits from last month don't stack. | |
| Returns the credit value the caller should use for this request (the | |
| freshly-reset 50 if a reset just happened, otherwise the value | |
| passed in unchanged) β callers must use this return value instead of | |
| their own stale api_credits read when one is available. | |
| """ | |
| now = utcnow() | |
| due = api_credits_reset_at is None or api_credits_reset_at < _current_month_start() | |
| if not due: | |
| return None # no reset happened β caller keeps its existing value | |
| with _PooledConnection() as conn: | |
| with conn.cursor() as cur: | |
| cur.execute( | |
| "UPDATE users SET api_credits = %s, api_credits_reset_at = %s WHERE uid = %s", | |
| (FREE_MONTHLY_API_CREDITS, now, uid), | |
| ) | |
| conn.commit() | |
| return FREE_MONTHLY_API_CREDITS | |
| # ββ API key verification ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def find_user_by_api_key(raw_api_key: str): | |
| """ | |
| Verifies a raw API key against EITHER the api_keys table (sub-keys β | |
| now used by BOTH free and paid registered users, see module | |
| docstring) OR the users table's master_key_hash column (master keys, | |
| paid-only), and returns the owning user's credit info β or None if | |
| the key matches neither. | |
| Both key types share the same "hdrm_" + 64-hex-char format and the | |
| same 13-char prefix-first-then-bcrypt lookup strategy (see the | |
| docstring note on why prefix narrowing is needed β bcrypt hashes | |
| can't be indexed/equality-matched directly). Checking sub-keys first | |
| is an arbitrary ordering choice, not a security requirement: a given | |
| raw key can only ever match one of the two tables, since each is | |
| generated independently and collision is cryptographically | |
| negligible. | |
| Callers (app.py) do not need to know or care which table actually | |
| matched, or whether the owning user is free or paid β the returned | |
| dict shape is identical in every case, and now always includes | |
| api_credits alongside monthly_credits/lifetime_credits so callers | |
| can check/spend all three buckets uniformly. The only difference | |
| "under the hood" is that a sub-key match also carries an | |
| api_key_id (used elsewhere for last_used_at tracking), while a | |
| master-key match does not, since there is no api_keys row involved. | |
| """ | |
| 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. Master keys use the exact | |
| # same format (see generateMasterKey() in the website's | |
| # app/api/webhooks/whop/route.ts), so the same prefix slice applies. | |
| key_prefix = raw_api_key[:13] | |
| # ββ Try sub-keys first (api_keys table β free or paid owner) βββββ | |
| 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, u.api_credits, | |
| u.api_credits_reset_at | |
| FROM api_keys ak | |
| JOIN users u ON u.uid = ak.uid | |
| WHERE ak.key_prefix = %s | |
| """, | |
| (key_prefix,), | |
| ) | |
| sub_key_candidates = cur.fetchall() | |
| for row in sub_key_candidates: | |
| stored_hash = row["key_hash"] | |
| if bcrypt.checkpw(raw_api_key.encode("utf-8"), stored_hash.encode("utf-8")): | |
| api_credits = row["api_credits"] | |
| # Lazy monthly reset β only free-plan rows ever accumulate | |
| # api_credits_reset_at drift worth checking; skipped for paid | |
| # users as a cheap no-op guard (a paid user's api_credits is | |
| # always 0 and irrelevant to routing/deduction anyway). | |
| if row["plan_id"] == "free": | |
| reset_value = _maybe_reset_api_credits(row["uid"], row["api_credits_reset_at"]) | |
| if reset_value is not None: | |
| api_credits = reset_value | |
| 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"], | |
| "api_credits": api_credits, | |
| } | |
| # ββ Not a sub-key β try master keys (users.master_key_hash) ββββββ | |
| # Paid-only path: free users never have a master key, so api_credits | |
| # is still selected here for shape-consistency but will only ever be | |
| # relevant to a sub-key match in practice. | |
| with _PooledConnection() as conn: | |
| with conn.cursor(cursor_factory=RealDictCursor) as cur: | |
| cur.execute( | |
| """ | |
| SELECT uid, master_key_hash, plan_id, monthly_credits, lifetime_credits, api_credits | |
| FROM users | |
| WHERE master_key_prefix = %s | |
| """, | |
| (key_prefix,), | |
| ) | |
| master_key_candidates = cur.fetchall() | |
| for row in master_key_candidates: | |
| stored_hash = row["master_key_hash"] | |
| if stored_hash and bcrypt.checkpw(raw_api_key.encode("utf-8"), stored_hash.encode("utf-8")): | |
| return { | |
| "api_key_id": None, # no api_keys row exists for a master-key match | |
| "uid": row["uid"], | |
| "plan_id": row["plan_id"], | |
| "monthly_credits": row["monthly_credits"], | |
| "lifetime_credits": row["lifetime_credits"], | |
| "api_credits": row["api_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 | |
| ANY of the three buckets combined: monthly_credits, lifetime_credits | |
| (both paid), or api_credits (free registered users' separate 50/ | |
| month API allowance, added 2026-09-02 β see module docstring).""" | |
| return ( | |
| user_info.get("monthly_credits", 0) | |
| + user_info.get("lifetime_credits", 0) | |
| + user_info.get("api_credits", 0) | |
| ) > 0 | |
| def has_paid_credits(user_info: dict) -> bool: | |
| """ | |
| Returns True only if the user has paid credits (monthly or | |
| lifetime) β deliberately EXCLUDES api_credits. Used by app.py to | |
| decide model/GPU routing: paid credits -> best model on Modal GPU, | |
| otherwise (including a free user spending api_credits) -> quality | |
| model on local HF CPU. Kept as its own function rather than inlined | |
| in app.py so the "what counts as paid" definition lives in one place | |
| alongside has_credits() and deduct_one_credit(), which must all | |
| agree on it. | |
| """ | |
| 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, trying | |
| each bucket in order: monthly_credits, then lifetime_credits, then | |
| api_credits (2026-09-02 addition β see module docstring for the | |
| confirmed priority: paid buckets are always spent first, even for a | |
| user who has api_credits left over, so a free-turned-paid user's | |
| leftover api_credits are only touched once both paid buckets are at | |
| zero). | |
| Uses a single UPDATE with CASE expressions 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 | |
| a negative balance. The database performs the check-and-decrement as | |
| one atomic operation instead. | |
| Returns True if a credit was actually deducted from any bucket, | |
| False if the user had zero credits across all three (nothing was | |
| deducted β the row is matched by the WHERE clause only when there's | |
| something to spend). | |
| Note: this operates on `uid` regardless of whether the caller | |
| authenticated via a sub-key or a master key, and regardless of which | |
| bucket ends up being spent β both key types resolve to the same | |
| users row. | |
| """ | |
| 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 | |
| WHEN lifetime_credits > 0 THEN lifetime_credits - 1 | |
| ELSE lifetime_credits | |
| END, | |
| api_credits = CASE | |
| WHEN monthly_credits > 0 THEN api_credits | |
| WHEN lifetime_credits > 0 THEN api_credits | |
| ELSE api_credits - 1 | |
| END | |
| WHERE uid = %s | |
| AND (monthly_credits > 0 OR lifetime_credits > 0 OR api_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). | |
| api_key_id may be None here (a master-key-authenticated request has | |
| no api_keys row) β callers should pass whatever find_user_by_api_key() | |
| returned for that field, including None, without special-casing it. | |
| 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). Applies equally to free and paid users' sub-keys. | |
| Callers MUST skip calling this when api_key_id is None (a master-key | |
| match) β there is no api_keys row to update in that case. This | |
| function itself does not guard against a None id being passed | |
| (an UPDATE ... WHERE id = NULL simply matches zero rows and is a | |
| silent no-op either way), but callers should still check explicitly | |
| rather than relying on that, since generating a spurious query on | |
| every master-key request is needless overhead. | |
| 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}") | |
| def get_user_by_uid(uid: str): | |
| """ | |
| Direct uid lookup β no API-key/bcrypt involved. Used ONLY by the | |
| trusted internal-service auth path in app.py (a website server-to- | |
| server call authenticated by a shared internal secret, not by a | |
| per-user API key). Returns the same dict shape as | |
| find_user_by_api_key() so callers can treat both paths identically, | |
| with api_key_id always None. Returns None if the uid doesn't exist. | |
| """ | |
| with _PooledConnection() as conn: | |
| with conn.cursor(cursor_factory=RealDictCursor) as cur: | |
| cur.execute( | |
| "SELECT uid, plan_id, monthly_credits, lifetime_credits, api_credits FROM users WHERE uid = %s", | |
| (uid,), | |
| ) | |
| row = cur.fetchone() | |
| if not row: | |
| return None | |
| return { | |
| "api_key_id": None, | |
| "uid": row["uid"], | |
| "plan_id": row["plan_id"], | |
| "monthly_credits": row["monthly_credits"], | |
| "lifetime_credits": row["lifetime_credits"], | |
| "api_credits": row["api_credits"], | |
| } |