Spaces:
Sleeping
Sleeping
| """ | |
| DetruAI β Production Database Layer v1.0 | |
| Uses SQLite β works perfectly on Hugging Face Spaces with no external dependencies. | |
| Tables: | |
| users β accounts, roles, password hashes | |
| subscriptions β Stripe subscription state per user | |
| scans β rate-limit counter + full scan history | |
| api_keys β Pro API keys | |
| """ | |
| import os | |
| import sqlite3 | |
| import secrets | |
| import hashlib | |
| import time | |
| from datetime import datetime, date | |
| from contextlib import contextmanager | |
| # ββ DB file lives next to app.py βββββββββββββββββββββββββββββββββββββββββββββ | |
| DB_PATH = os.path.join(os.path.dirname(__file__), 'detruai.db') | |
| # ββ Tier limits βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| TIER_LIMITS = { | |
| 'free': {'image_daily': 10, 'video': False, 'audio': False, | |
| 'batch': False, 'heatmap': False, 'history_days': 1, | |
| 'api': False, 'max_image_mb': 5, 'watermark': True}, | |
| 'pro': {'image_daily': None, 'video': True, 'audio': True, | |
| 'batch': True, 'heatmap': True, 'history_days': 90, | |
| 'api': True, 'max_image_mb': 50, 'watermark': False}, | |
| 'enterprise': {'image_daily': None, 'video': True, 'audio': True, | |
| 'batch': True, 'heatmap': True, 'history_days': 9999, | |
| 'api': True, 'max_image_mb': 100,'watermark': False}, | |
| # guest = same as free but no history at all | |
| 'guest': {'image_daily': 3, 'video': False, 'audio': False, | |
| 'batch': False, 'heatmap': False, 'history_days': 0, | |
| 'api': False, 'max_image_mb': 2, 'watermark': True}, | |
| } | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # CONNECTION | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _db(): | |
| """Thread-safe SQLite connection with WAL mode for concurrent reads.""" | |
| conn = sqlite3.connect(DB_PATH, timeout=10, check_same_thread=False) | |
| conn.row_factory = sqlite3.Row | |
| conn.execute("PRAGMA journal_mode=WAL") | |
| conn.execute("PRAGMA foreign_keys=ON") | |
| try: | |
| yield conn | |
| conn.commit() | |
| except Exception: | |
| conn.rollback() | |
| raise | |
| finally: | |
| conn.close() | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SCHEMA | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def init_db(): | |
| """Create all tables if they don't exist. Safe to call on every startup.""" | |
| with _db() as conn: | |
| conn.executescript(""" | |
| CREATE TABLE IF NOT EXISTS users ( | |
| username TEXT PRIMARY KEY, | |
| email TEXT UNIQUE NOT NULL, | |
| password_hash TEXT NOT NULL, | |
| role TEXT NOT NULL DEFAULT 'free', | |
| created_at TEXT NOT NULL DEFAULT (datetime('now')), | |
| reset_token TEXT, | |
| reset_expires TEXT, | |
| last_login TEXT | |
| ); | |
| CREATE TABLE IF NOT EXISTS subscriptions ( | |
| username TEXT PRIMARY KEY REFERENCES users(username), | |
| stripe_customer_id TEXT, | |
| stripe_sub_id TEXT, | |
| plan TEXT NOT NULL DEFAULT 'free', | |
| status TEXT NOT NULL DEFAULT 'inactive', | |
| current_period_end TEXT, | |
| cancel_at_period_end INTEGER DEFAULT 0, | |
| updated_at TEXT NOT NULL DEFAULT (datetime('now')) | |
| ); | |
| CREATE TABLE IF NOT EXISTS daily_usage ( | |
| username TEXT NOT NULL, | |
| use_date TEXT NOT NULL, | |
| scan_count INTEGER NOT NULL DEFAULT 0, | |
| PRIMARY KEY (username, use_date), | |
| FOREIGN KEY (username) REFERENCES users(username) | |
| ); | |
| CREATE TABLE IF NOT EXISTS scans ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| username TEXT NOT NULL, | |
| scan_type TEXT NOT NULL, | |
| filename TEXT, | |
| label TEXT, | |
| confidence REAL, | |
| result_json TEXT, | |
| created_at TEXT NOT NULL DEFAULT (datetime('now')), | |
| FOREIGN KEY (username) REFERENCES users(username) | |
| ); | |
| CREATE TABLE IF NOT EXISTS api_keys ( | |
| key_hash TEXT PRIMARY KEY, | |
| username TEXT NOT NULL, | |
| label TEXT, | |
| created_at TEXT NOT NULL DEFAULT (datetime('now')), | |
| last_used TEXT, | |
| is_active INTEGER NOT NULL DEFAULT 1, | |
| FOREIGN KEY (username) REFERENCES users(username) | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_scans_user ON scans(username, created_at); | |
| CREATE INDEX IF NOT EXISTS idx_usage_user ON daily_usage(username, use_date); | |
| CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); | |
| """) | |
| print("[DB] Initialised β SQLite WAL mode active.") | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # USER MANAGEMENT | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def create_user(username: str, email: str, password_hash: str) -> dict: | |
| with _db() as conn: | |
| conn.execute( | |
| "INSERT INTO users (username, email, password_hash, role) VALUES (?,?,?,'free')", | |
| (username, email, password_hash) | |
| ) | |
| conn.execute( | |
| "INSERT INTO subscriptions (username, plan, status) VALUES (?, 'free', 'active')", | |
| (username,) | |
| ) | |
| return get_user(username) | |
| def get_user(username: str) -> dict | None: | |
| with _db() as conn: | |
| row = conn.execute( | |
| """SELECT u.*, COALESCE(s.plan,'free') AS plan, s.status AS sub_status, | |
| s.stripe_customer_id, s.stripe_sub_id, s.current_period_end, | |
| s.cancel_at_period_end | |
| FROM users u | |
| LEFT JOIN subscriptions s ON s.username = u.username | |
| WHERE u.username = ?""", | |
| (username,) | |
| ).fetchone() | |
| return dict(row) if row else None | |
| def get_user_by_email(email: str) -> dict | None: | |
| with _db() as conn: | |
| row = conn.execute( | |
| """SELECT u.*, COALESCE(s.plan,'free') AS plan | |
| FROM users u | |
| LEFT JOIN subscriptions s ON s.username = u.username | |
| WHERE u.email = ?""", | |
| (email,) | |
| ).fetchone() | |
| return dict(row) if row else None | |
| def update_last_login(username: str): | |
| with _db() as conn: | |
| conn.execute("UPDATE users SET last_login=datetime('now') WHERE username=?", (username,)) | |
| def set_reset_token(username: str, token: str, expires: str): | |
| with _db() as conn: | |
| conn.execute( | |
| "UPDATE users SET reset_token=?, reset_expires=? WHERE username=?", | |
| (token, expires, username) | |
| ) | |
| def consume_reset_token(token: str) -> dict | None: | |
| """Find user by token if not expired. Clears token on success.""" | |
| with _db() as conn: | |
| row = conn.execute( | |
| "SELECT * FROM users WHERE reset_token=? AND reset_expires > datetime('now')", | |
| (token,) | |
| ).fetchone() | |
| if not row: | |
| return None | |
| conn.execute( | |
| "UPDATE users SET reset_token=NULL, reset_expires=NULL WHERE username=?", | |
| (row['username'],) | |
| ) | |
| return dict(row) | |
| def update_password(username: str, password_hash: str): | |
| with _db() as conn: | |
| conn.execute( | |
| "UPDATE users SET password_hash=? WHERE username=?", | |
| (password_hash, username) | |
| ) | |
| def migrate_from_json(users_json_path: str): | |
| """ | |
| One-time migration: import existing users.json accounts into SQLite. | |
| Safe to call on startup β skips users that already exist. | |
| """ | |
| import json | |
| if not os.path.exists(users_json_path): | |
| return | |
| try: | |
| with open(users_json_path) as f: | |
| data = json.load(f) | |
| migrated = 0 | |
| with _db() as conn: | |
| for email_key, u in data.items(): | |
| uname = u.get('username', email_key) | |
| email = u.get('email', email_key) | |
| ph = u.get('password_hash', '') | |
| role = u.get('role', 'free') | |
| # Map old 'user' role to 'free' | |
| if role == 'user': | |
| role = 'free' | |
| try: | |
| conn.execute( | |
| "INSERT OR IGNORE INTO users (username,email,password_hash,role,created_at) VALUES (?,?,?,?,?)", | |
| (uname, email, ph, role, u.get('created','2024-01-01')) | |
| ) | |
| conn.execute( | |
| "INSERT OR IGNORE INTO subscriptions (username,plan,status) VALUES (?,?,?)", | |
| (uname, role, 'active') | |
| ) | |
| migrated += 1 | |
| except Exception as e: | |
| print(f" [Migrate] skip {uname}: {e}") | |
| print(f"[DB] Migrated {migrated} user(s) from users.json β SQLite") | |
| except Exception as e: | |
| print(f"[DB] Migration error: {e}") | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # RATE LIMITING | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_plan(username: str) -> str: | |
| """Return the user's current active plan ('free', 'pro', 'enterprise', 'guest').""" | |
| with _db() as conn: | |
| row = conn.execute( | |
| """SELECT s.plan, s.status, s.current_period_end | |
| FROM subscriptions s WHERE s.username=?""", | |
| (username,) | |
| ).fetchone() | |
| if not row: | |
| return 'free' | |
| plan = row['plan'] | |
| status = row['status'] | |
| # Check if subscription has expired | |
| if plan in ('pro', 'enterprise') and status == 'active': | |
| end = row['current_period_end'] | |
| if end and datetime.fromisoformat(end) < datetime.utcnow(): | |
| # Expired β downgrade | |
| _downgrade_expired(username) | |
| return 'free' | |
| return plan | |
| return 'free' | |
| def _downgrade_expired(username: str): | |
| with _db() as conn: | |
| conn.execute( | |
| "UPDATE subscriptions SET plan='free', status='expired' WHERE username=?", | |
| (username,) | |
| ) | |
| def get_tier_limits(plan: str) -> dict: | |
| return TIER_LIMITS.get(plan, TIER_LIMITS['free']) | |
| def check_and_increment_usage(username: str, scan_type: str = 'image') -> dict: | |
| """ | |
| Check if user can perform a scan. If allowed, increment counter. | |
| Returns: | |
| {'allowed': True/False, 'used': N, 'limit': N or None, 'remaining': N or None} | |
| """ | |
| plan = get_plan(username) | |
| limits = get_tier_limits(plan) | |
| today = date.today().isoformat() | |
| # Video/Audio require Pro | |
| if scan_type in ('video', 'audio') and not limits.get('video'): | |
| return {'allowed': False, 'reason': 'pro_required', | |
| 'used': 0, 'limit': 0, 'remaining': 0, | |
| 'plan': plan} | |
| # Batch requires Pro | |
| if scan_type == 'batch' and not limits.get('batch'): | |
| return {'allowed': False, 'reason': 'pro_required', | |
| 'used': 0, 'limit': 0, 'remaining': 0, | |
| 'plan': plan} | |
| daily_limit = limits.get('image_daily') | |
| # Unlimited plan | |
| if daily_limit is None: | |
| _record_usage(username, today) | |
| return {'allowed': True, 'used': None, 'limit': None, | |
| 'remaining': None, 'plan': plan} | |
| # Check and increment atomically | |
| with _db() as conn: | |
| row = conn.execute( | |
| "SELECT scan_count FROM daily_usage WHERE username=? AND use_date=?", | |
| (username, today) | |
| ).fetchone() | |
| used = row['scan_count'] if row else 0 | |
| if used >= daily_limit: | |
| return {'allowed': False, 'reason': 'daily_limit', | |
| 'used': used, 'limit': daily_limit, | |
| 'remaining': 0, 'plan': plan, | |
| 'resets': 'midnight UTC'} | |
| if row: | |
| conn.execute( | |
| "UPDATE daily_usage SET scan_count=scan_count+1 WHERE username=? AND use_date=?", | |
| (username, today) | |
| ) | |
| else: | |
| # Ensure user row exists before inserting into daily_usage. | |
| # Guards against guest sessions (guest_xxxx) that have no DB row, | |
| # which would cause a FOREIGN KEY constraint failure. | |
| conn.execute( | |
| """INSERT OR IGNORE INTO users (username, email, password_hash, role) | |
| VALUES (?, ?, '', 'guest')""", | |
| (username, username) | |
| ) | |
| conn.execute( | |
| """INSERT OR IGNORE INTO subscriptions (username, plan, status) | |
| VALUES (?, 'guest', 'active')""", | |
| (username,) | |
| ) | |
| conn.execute( | |
| "INSERT INTO daily_usage (username, use_date, scan_count) VALUES (?,?,1)", | |
| (username, today) | |
| ) | |
| used += 1 | |
| return {'allowed': True, 'used': used, 'limit': daily_limit, | |
| 'remaining': daily_limit - used, 'plan': plan} | |
| def _record_usage(username: str, today: str): | |
| """Record usage for unlimited-plan users (for analytics).""" | |
| with _db() as conn: | |
| # Ensure user exists (same FK guard as check_and_increment_usage) | |
| conn.execute( | |
| """INSERT OR IGNORE INTO users (username, email, password_hash, role) | |
| VALUES (?, ?, '', 'guest')""", | |
| (username, username) | |
| ) | |
| conn.execute( | |
| """INSERT OR IGNORE INTO subscriptions (username, plan, status) | |
| VALUES (?, 'guest', 'active')""", | |
| (username,) | |
| ) | |
| conn.execute(""" | |
| INSERT INTO daily_usage (username, use_date, scan_count) VALUES (?,?,1) | |
| ON CONFLICT(username, use_date) DO UPDATE SET scan_count=scan_count+1 | |
| """, (username, today)) | |
| def get_usage_today(username: str) -> dict: | |
| plan = get_plan(username) | |
| limits = get_tier_limits(plan) | |
| today = date.today().isoformat() | |
| with _db() as conn: | |
| row = conn.execute( | |
| "SELECT scan_count FROM daily_usage WHERE username=? AND use_date=?", | |
| (username, today) | |
| ).fetchone() | |
| used = row['scan_count'] if row else 0 | |
| daily_limit = limits.get('image_daily') | |
| return { | |
| 'plan': plan, | |
| 'used': used, | |
| 'limit': daily_limit, | |
| 'remaining': (daily_limit - used) if daily_limit is not None else None, | |
| 'features': {k: v for k, v in limits.items() if k not in ('image_daily',)} | |
| } | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SCAN HISTORY | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def save_scan(username: str, scan_type: str, filename: str, | |
| label: str, confidence: float, result: dict): | |
| """Save a scan result. Enforces history retention by plan.""" | |
| plan = get_plan(username) | |
| limits = get_tier_limits(plan) | |
| days = limits.get('history_days', 1) | |
| if days == 0: | |
| return # guest β no history | |
| import json | |
| with _db() as conn: | |
| conn.execute( | |
| """INSERT INTO scans (username, scan_type, filename, label, confidence, result_json) | |
| VALUES (?,?,?,?,?,?)""", | |
| (username, scan_type, filename, label, confidence, | |
| json.dumps(result, default=str)) | |
| ) | |
| # Prune old records beyond retention window | |
| if days < 9999: | |
| conn.execute( | |
| """DELETE FROM scans WHERE username=? | |
| AND created_at < datetime('now', ?)""", | |
| (username, f'-{days} days') | |
| ) | |
| # Cap to 500 most recent per user | |
| conn.execute( | |
| """DELETE FROM scans WHERE username=? AND id NOT IN ( | |
| SELECT id FROM scans WHERE username=? ORDER BY id DESC LIMIT 500 | |
| )""", | |
| (username, username) | |
| ) | |
| def get_history(username: str, limit: int = 100) -> list: | |
| plan = get_plan(username) | |
| limits = get_tier_limits(plan) | |
| if limits.get('history_days', 0) == 0: | |
| return [] | |
| import json | |
| with _db() as conn: | |
| rows = conn.execute( | |
| """SELECT id, scan_type, filename, label, confidence, result_json, created_at | |
| FROM scans WHERE username=? ORDER BY id DESC LIMIT ?""", | |
| (username, limit) | |
| ).fetchall() | |
| result = [] | |
| for r in rows: | |
| try: | |
| data = json.loads(r['result_json']) if r['result_json'] else {} | |
| except Exception: | |
| data = {} | |
| result.append({ | |
| 'id': r['id'], | |
| 'scan_type': r['scan_type'], | |
| 'filename': r['filename'], | |
| 'label': r['label'], | |
| 'confidence': r['confidence'], | |
| 'created_at': r['created_at'], | |
| **data, | |
| }) | |
| return result | |
| def clear_history(username: str): | |
| with _db() as conn: | |
| conn.execute("DELETE FROM scans WHERE username=?", (username,)) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SUBSCRIPTIONS (Stripe) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def upsert_subscription(username: str, stripe_customer_id: str, stripe_sub_id: str, | |
| plan: str, status: str, period_end: str, | |
| cancel_at_period_end: bool = False): | |
| with _db() as conn: | |
| conn.execute(""" | |
| INSERT INTO subscriptions | |
| (username, stripe_customer_id, stripe_sub_id, plan, status, | |
| current_period_end, cancel_at_period_end, updated_at) | |
| VALUES (?,?,?,?,?,?,?,datetime('now')) | |
| ON CONFLICT(username) DO UPDATE SET | |
| stripe_customer_id = excluded.stripe_customer_id, | |
| stripe_sub_id = excluded.stripe_sub_id, | |
| plan = excluded.plan, | |
| status = excluded.status, | |
| current_period_end = excluded.current_period_end, | |
| cancel_at_period_end= excluded.cancel_at_period_end, | |
| updated_at = datetime('now') | |
| """, (username, stripe_customer_id, stripe_sub_id, plan, status, | |
| period_end, int(cancel_at_period_end))) | |
| def get_subscription(username: str) -> dict | None: | |
| with _db() as conn: | |
| row = conn.execute( | |
| "SELECT * FROM subscriptions WHERE username=?", (username,) | |
| ).fetchone() | |
| return dict(row) if row else None | |
| def cancel_subscription(username: str): | |
| """Downgrade to free immediately (for webhook-triggered cancellations).""" | |
| with _db() as conn: | |
| conn.execute( | |
| "UPDATE subscriptions SET plan='free', status='cancelled', updated_at=datetime('now') WHERE username=?", | |
| (username,) | |
| ) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # API KEYS | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def create_api_key(username: str, label: str = 'Default') -> str: | |
| """Generate a new API key for a Pro user. Returns the raw key (shown once).""" | |
| raw_key = 'dkai_' + secrets.token_urlsafe(32) | |
| key_hash = hashlib.sha256(raw_key.encode()).hexdigest() | |
| with _db() as conn: | |
| conn.execute( | |
| "INSERT INTO api_keys (key_hash, username, label) VALUES (?,?,?)", | |
| (key_hash, username, label) | |
| ) | |
| return raw_key | |
| def validate_api_key(raw_key: str) -> dict | None: | |
| """Returns user dict if key is valid + active Pro/Enterprise user.""" | |
| key_hash = hashlib.sha256(raw_key.encode()).hexdigest() | |
| with _db() as conn: | |
| row = conn.execute( | |
| """SELECT ak.username, ak.label, u.role, COALESCE(s.plan,'free') as plan | |
| FROM api_keys ak | |
| JOIN users u ON u.username = ak.username | |
| LEFT JOIN subscriptions s ON s.username = ak.username | |
| WHERE ak.key_hash=? AND ak.is_active=1""", | |
| (key_hash,) | |
| ).fetchone() | |
| if not row: | |
| return None | |
| plan = row['plan'] | |
| if plan not in ('pro', 'enterprise'): | |
| return None | |
| conn.execute( | |
| "UPDATE api_keys SET last_used=datetime('now') WHERE key_hash=?", | |
| (key_hash,) | |
| ) | |
| return dict(row) | |
| def list_api_keys(username: str) -> list: | |
| with _db() as conn: | |
| rows = conn.execute( | |
| "SELECT label, created_at, last_used, is_active FROM api_keys WHERE username=? ORDER BY created_at DESC", | |
| (username,) | |
| ).fetchall() | |
| return [dict(r) for r in rows] | |
| def revoke_api_key(username: str, label: str): | |
| with _db() as conn: | |
| conn.execute( | |
| "UPDATE api_keys SET is_active=0 WHERE username=? AND label=?", | |
| (username, label) | |
| ) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # ADMIN ANALYTICS | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def admin_stats() -> dict: | |
| with _db() as conn: | |
| total_users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] | |
| pro_users = conn.execute( | |
| "SELECT COUNT(*) FROM subscriptions WHERE plan='pro' AND status='active'" | |
| ).fetchone()[0] | |
| scans_today = conn.execute( | |
| "SELECT COALESCE(SUM(scan_count),0) FROM daily_usage WHERE use_date=date('now')" | |
| ).fetchone()[0] | |
| total_scans = conn.execute("SELECT COUNT(*) FROM scans").fetchone()[0] | |
| fake_count = conn.execute("SELECT COUNT(*) FROM scans WHERE label='FAKE'").fetchone()[0] | |
| return { | |
| 'total_users': total_users, | |
| 'pro_users': pro_users, | |
| 'free_users': total_users - pro_users, | |
| 'scans_today': scans_today, | |
| 'total_scans': total_scans, | |
| 'fake_rate': round(fake_count / max(total_scans, 1) * 100, 1), | |
| } | |