Detruai / database.py
MHamza9111's picture
Upload 2 files
e90d02d verified
Raw
History Blame Contribute Delete
24.8 kB
"""
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
# ══════════════════════════════════════════════════════════════════════════════
@contextmanager
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),
}