459 lines
18 KiB
Python
459 lines
18 KiB
Python
import hashlib
|
||
import sqlite3
|
||
import sys
|
||
import time
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
DEVICES_PER_LICENSE_FLOOR = 2
|
||
|
||
|
||
def _runtime_base_dir() -> Path:
|
||
if getattr(sys, "frozen", False):
|
||
return Path(getattr(sys, "_MEIPASS", Path(sys.executable).resolve().parent))
|
||
return Path(__file__).resolve().parent
|
||
|
||
|
||
def _resolve_db_path() -> str:
|
||
if getattr(sys, "frozen", False):
|
||
import os as _os
|
||
try:
|
||
from aza_config import get_writable_data_dir
|
||
writable = Path(get_writable_data_dir()) / "data"
|
||
except Exception:
|
||
writable = Path(_os.environ.get("APPDATA", "")) / "AZA Desktop" / "data"
|
||
writable.mkdir(parents=True, exist_ok=True)
|
||
return str(writable / "stripe_webhook.sqlite")
|
||
return str(_runtime_base_dir() / "data" / "stripe_webhook.sqlite")
|
||
|
||
|
||
DB_PATH = _resolve_db_path()
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class DeviceDecision:
|
||
allowed: bool
|
||
reason: str # "ok"|"missing_device_id"|"license_not_found"|"device_limit_reached"|"user_limit_reached"
|
||
devices_used: int
|
||
devices_allowed: int
|
||
users_used: int
|
||
users_allowed: int
|
||
license_active: bool = False
|
||
license_count: int = 0
|
||
|
||
|
||
def _hash_device_id(device_id: str) -> str:
|
||
return hashlib.sha256(device_id.encode("utf-8")).hexdigest()
|
||
|
||
|
||
def ensure_device_table(conn: sqlite3.Connection) -> None:
|
||
conn.execute(
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS device_bindings (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
customer_email TEXT NOT NULL,
|
||
user_key TEXT NOT NULL,
|
||
device_hash TEXT NOT NULL,
|
||
first_seen_at INTEGER NOT NULL,
|
||
last_seen_at INTEGER NOT NULL,
|
||
UNIQUE(customer_email, user_key, device_hash)
|
||
);
|
||
"""
|
||
)
|
||
cols = [row[1] for row in conn.execute("PRAGMA table_info(device_bindings)").fetchall()]
|
||
if "device_name" not in cols:
|
||
conn.execute("ALTER TABLE device_bindings ADD COLUMN device_name TEXT DEFAULT ''")
|
||
if "is_active" not in cols:
|
||
conn.execute("ALTER TABLE device_bindings ADD COLUMN is_active INTEGER DEFAULT 1")
|
||
if "app_version" not in cols:
|
||
conn.execute("ALTER TABLE device_bindings ADD COLUMN app_version TEXT DEFAULT ''")
|
||
if "device_fingerprint" not in cols:
|
||
conn.execute("ALTER TABLE device_bindings ADD COLUMN device_fingerprint TEXT DEFAULT ''")
|
||
if "device_scope" not in cols:
|
||
conn.execute(
|
||
"ALTER TABLE device_bindings ADD COLUMN device_scope TEXT DEFAULT 'office'"
|
||
)
|
||
conn.commit()
|
||
|
||
|
||
def _office_scope_clause() -> str:
|
||
return "lower(coalesce(device_scope, 'office')) = 'office'"
|
||
|
||
|
||
def _count_active_licenses(conn: sqlite3.Connection, customer_email: str) -> Tuple[int, int]:
|
||
"""Returns (n_active_licenses, total_allowed_devices).
|
||
|
||
Multiple active licenses for the same email stack:
|
||
1 license = 2 devices, 2 licenses = 4 devices, etc.
|
||
"""
|
||
cur = conn.execute(
|
||
"""SELECT COUNT(*),
|
||
COALESCE(SUM(
|
||
CASE WHEN devices_per_user IS NULL OR devices_per_user < ?
|
||
THEN ? ELSE devices_per_user END
|
||
), 0)
|
||
FROM licenses
|
||
WHERE lower(customer_email) = lower(?)
|
||
AND status = 'active'""",
|
||
(DEVICES_PER_LICENSE_FLOOR, DEVICES_PER_LICENSE_FLOOR, customer_email),
|
||
)
|
||
row = cur.fetchone()
|
||
if not row or int(row[0]) == 0:
|
||
return (0, 0)
|
||
return (int(row[0]), int(row[1]))
|
||
|
||
|
||
def _get_license_row(conn: sqlite3.Connection, customer_email: str) -> Optional[Tuple[int, int]]:
|
||
"""Backward-compat wrapper. Returns (allowed_users, total_devices)."""
|
||
n_licenses, total_devices = _count_active_licenses(conn, customer_email)
|
||
if n_licenses == 0:
|
||
return None
|
||
return (1, total_devices)
|
||
|
||
|
||
def enforce_and_touch_device(
|
||
customer_email: str,
|
||
user_key: str,
|
||
device_id: Optional[str],
|
||
db_path: Optional[str] = None,
|
||
device_name: str = "",
|
||
app_version: str = "",
|
||
device_fingerprint: str = "",
|
||
) -> DeviceDecision:
|
||
"""Enforce device limit for a customer email.
|
||
|
||
Rules (in order):
|
||
1. device_hash match -> allow, update last_seen + store fingerprint
|
||
2. device_fingerprint match -> rebind hash (same HW, new install)
|
||
3. Legacy hostname match -> rebind hash + store fingerprint (migration)
|
||
4. Under limit -> register new device
|
||
5. At/over limit -> deny
|
||
"""
|
||
if not device_id:
|
||
print("[DEVICE-ENFORCE] REJECT: missing device_id")
|
||
return DeviceDecision(
|
||
allowed=False, reason="missing_device_id",
|
||
devices_used=0, devices_allowed=0,
|
||
users_used=0, users_allowed=0,
|
||
)
|
||
|
||
device_hash = _hash_device_id(device_id)
|
||
now = int(time.time())
|
||
print(f"[DEVICE-ENFORCE] enforce email={customer_email} "
|
||
f"hash={device_hash[:12]}... name={device_name} "
|
||
f"fp={device_fingerprint[:12] + '...' if device_fingerprint else 'none'}")
|
||
|
||
conn = sqlite3.connect(db_path or DB_PATH)
|
||
try:
|
||
ensure_device_table(conn)
|
||
|
||
n_licenses, total_devices = _count_active_licenses(conn, customer_email)
|
||
if n_licenses == 0:
|
||
print(f"[DEVICE-ENFORCE] no active license for {customer_email}")
|
||
return DeviceDecision(
|
||
allowed=False, reason="license_not_found",
|
||
devices_used=0, devices_allowed=0,
|
||
users_used=0, users_allowed=0,
|
||
license_active=False, license_count=0,
|
||
)
|
||
|
||
cur_devices = conn.execute(
|
||
f"""SELECT COUNT(*) FROM device_bindings
|
||
WHERE lower(customer_email) = lower(?) AND user_key = ?
|
||
AND COALESCE(is_active, 1) = 1
|
||
AND {_office_scope_clause()}""",
|
||
(customer_email, user_key),
|
||
)
|
||
used_devices = int(cur_devices.fetchone()[0])
|
||
|
||
cur_users = conn.execute(
|
||
"SELECT COUNT(DISTINCT user_key) FROM device_bindings WHERE lower(customer_email) = lower(?)",
|
||
(customer_email,),
|
||
)
|
||
users_used = int(cur_users.fetchone()[0])
|
||
|
||
_ok = DeviceDecision(
|
||
allowed=True, reason="ok",
|
||
devices_used=used_devices, devices_allowed=total_devices,
|
||
users_used=users_used, users_allowed=1,
|
||
license_active=True, license_count=n_licenses,
|
||
)
|
||
|
||
# --- Step 1: exact device_hash match (normal case) ---
|
||
cur = conn.execute(
|
||
"""SELECT id FROM device_bindings
|
||
WHERE lower(customer_email) = lower(?) AND user_key = ? AND device_hash = ?
|
||
LIMIT 1""",
|
||
(customer_email, user_key, device_hash),
|
||
)
|
||
existing = cur.fetchone()
|
||
if existing:
|
||
conn.execute(
|
||
"""UPDATE device_bindings
|
||
SET last_seen_at = ?,
|
||
device_name = COALESCE(NULLIF(?, ''), device_name),
|
||
app_version = COALESCE(NULLIF(?, ''), app_version),
|
||
device_fingerprint = COALESCE(NULLIF(?, ''), device_fingerprint),
|
||
is_active = 1
|
||
WHERE id = ?""",
|
||
(now, device_name, app_version, device_fingerprint, int(existing[0])),
|
||
)
|
||
conn.commit()
|
||
print(f"[DEVICE-ENFORCE] step1 hash-match id={existing[0]} -> allowed")
|
||
return _ok
|
||
|
||
# --- Step 2: fingerprint match (reinstall, new device_id, same HW) ---
|
||
if device_fingerprint:
|
||
fp_row = conn.execute(
|
||
"""SELECT id FROM device_bindings
|
||
WHERE lower(customer_email) = lower(?) AND user_key = ?
|
||
AND device_fingerprint = ? AND device_fingerprint != ''
|
||
AND COALESCE(is_active, 1) = 1
|
||
LIMIT 1""",
|
||
(customer_email, user_key, device_fingerprint),
|
||
).fetchone()
|
||
if fp_row:
|
||
conn.execute(
|
||
"""UPDATE device_bindings
|
||
SET device_hash = ?, last_seen_at = ?,
|
||
device_name = COALESCE(NULLIF(?, ''), device_name),
|
||
app_version = COALESCE(NULLIF(?, ''), app_version),
|
||
is_active = 1
|
||
WHERE id = ?""",
|
||
(device_hash, now, device_name, app_version, int(fp_row[0])),
|
||
)
|
||
conn.commit()
|
||
print(f"[DEVICE-ENFORCE] step2 fingerprint-rebind id={fp_row[0]} -> allowed")
|
||
return _ok
|
||
|
||
# --- Step 3: legacy hostname match (old entry without fingerprint) ---
|
||
if device_fingerprint and device_name:
|
||
legacy_row = conn.execute(
|
||
"""SELECT id, device_hash FROM device_bindings
|
||
WHERE lower(customer_email) = lower(?) AND user_key = ?
|
||
AND (device_fingerprint IS NULL OR device_fingerprint = '')
|
||
AND device_name = ?
|
||
AND COALESCE(is_active, 1) = 1
|
||
LIMIT 1""",
|
||
(customer_email, user_key, device_name),
|
||
).fetchone()
|
||
if legacy_row:
|
||
rebind_id = int(legacy_row[0])
|
||
conn.execute(
|
||
"""UPDATE device_bindings
|
||
SET device_hash = ?, device_fingerprint = ?,
|
||
last_seen_at = ?,
|
||
app_version = COALESCE(NULLIF(?, ''), app_version),
|
||
is_active = 1
|
||
WHERE id = ?""",
|
||
(device_hash, device_fingerprint, now, app_version, rebind_id),
|
||
)
|
||
stale = conn.execute(
|
||
"""UPDATE device_bindings SET is_active = 0
|
||
WHERE lower(customer_email) = lower(?) AND user_key = ?
|
||
AND device_name = ?
|
||
AND (device_fingerprint IS NULL OR device_fingerprint = '')
|
||
AND id != ?
|
||
AND COALESCE(is_active, 1) = 1""",
|
||
(customer_email, user_key, device_name, rebind_id),
|
||
)
|
||
stale_count = stale.rowcount
|
||
conn.commit()
|
||
used_after = used_devices - stale_count
|
||
print(f"[DEVICE-ENFORCE] step3 legacy-hostname-rebind id={rebind_id} "
|
||
f"old_hash={legacy_row[1][:12]}... stale_deactivated={stale_count} "
|
||
f"slots={used_after}/{total_devices} -> allowed")
|
||
return DeviceDecision(
|
||
allowed=True, reason="ok",
|
||
devices_used=max(1, used_after),
|
||
devices_allowed=total_devices,
|
||
users_used=users_used, users_allowed=1,
|
||
license_active=True, license_count=n_licenses,
|
||
)
|
||
|
||
# --- Step 4: new device -- check limit ---
|
||
if used_devices >= total_devices:
|
||
print(f"[DEVICE-ENFORCE] step4 limit-reached {used_devices}/{total_devices} "
|
||
f"name={device_name} fp={device_fingerprint[:12] if device_fingerprint else 'none'}")
|
||
return DeviceDecision(
|
||
allowed=False, reason="device_limit_reached",
|
||
devices_used=used_devices, devices_allowed=total_devices,
|
||
users_used=users_used, users_allowed=1,
|
||
license_active=True, license_count=n_licenses,
|
||
)
|
||
|
||
# --- Step 5: register new device ---
|
||
conn.execute(
|
||
"""INSERT INTO device_bindings
|
||
(customer_email, user_key, device_hash, first_seen_at, last_seen_at,
|
||
device_name, is_active, app_version, device_fingerprint, device_scope)
|
||
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, 'office')""",
|
||
(customer_email, user_key, device_hash, now, now,
|
||
device_name, app_version, device_fingerprint),
|
||
)
|
||
conn.commit()
|
||
print(f"[DEVICE-ENFORCE] step5 new-device registered name={device_name} "
|
||
f"{used_devices + 1}/{total_devices}")
|
||
|
||
return DeviceDecision(
|
||
allowed=True, reason="ok",
|
||
devices_used=used_devices + 1, devices_allowed=total_devices,
|
||
users_used=users_used if used_devices > 0 else users_used + 1,
|
||
users_allowed=1,
|
||
license_active=True, license_count=n_licenses,
|
||
)
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
def list_devices_for_email(
|
||
customer_email: str, db_path: Optional[str] = None,
|
||
) -> Dict[str, Any]:
|
||
"""Admin/debug: list all registered devices and license info for an email."""
|
||
conn = sqlite3.connect(db_path or DB_PATH)
|
||
try:
|
||
ensure_device_table(conn)
|
||
n_licenses, total_devices = _count_active_licenses(conn, customer_email)
|
||
|
||
rows = conn.execute(
|
||
"""SELECT device_hash, device_name, COALESCE(is_active, 1),
|
||
COALESCE(app_version, ''), first_seen_at, last_seen_at
|
||
FROM device_bindings
|
||
WHERE lower(customer_email) = lower(?)
|
||
ORDER BY last_seen_at DESC""",
|
||
(customer_email,),
|
||
).fetchall()
|
||
|
||
devices: List[Dict[str, Any]] = []
|
||
for r in rows:
|
||
devices.append({
|
||
"device_hash_short": (r[0] or "")[:12] + "…",
|
||
"device_name": r[1] or "",
|
||
"is_active": bool(r[2]),
|
||
"app_version": r[3] or "",
|
||
"first_seen": r[4],
|
||
"last_seen": r[5],
|
||
})
|
||
|
||
return {
|
||
"email": customer_email,
|
||
"active_licenses": n_licenses,
|
||
"allowed_devices": total_devices,
|
||
"registered_devices": len(devices),
|
||
"active_devices": sum(1 for d in devices if d["is_active"]),
|
||
"devices": devices,
|
||
}
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
def enforce_and_touch_chat_device(
|
||
practice_id: str,
|
||
device_id: Optional[str],
|
||
db_path: Optional[str] = None,
|
||
customer_email: str = "",
|
||
device_name: str = "",
|
||
app_version: str = "",
|
||
device_fingerprint: str = "",
|
||
) -> DeviceDecision:
|
||
"""Chat-Geräte-Limit pro Praxis: 5 × aktive Office-Lizenzen (getrennt von Office-Geräten)."""
|
||
from aza_chat_device_capacity import (
|
||
CHAT_DEVICES_PER_OFFICE_LICENSE,
|
||
count_active_office_licenses_sqlite,
|
||
)
|
||
|
||
pid = (practice_id or "").strip()
|
||
if not pid:
|
||
return DeviceDecision(
|
||
allowed=False, reason="missing_practice_id",
|
||
devices_used=0, devices_allowed=0,
|
||
users_used=0, users_allowed=0,
|
||
)
|
||
if not device_id:
|
||
return DeviceDecision(
|
||
allowed=False, reason="missing_device_id",
|
||
devices_used=0, devices_allowed=0,
|
||
users_used=0, users_allowed=0,
|
||
)
|
||
|
||
device_hash = _hash_device_id(device_id)
|
||
now = int(time.time())
|
||
conn = sqlite3.connect(db_path or DB_PATH)
|
||
try:
|
||
ensure_device_table(conn)
|
||
n_licenses = count_active_office_licenses_sqlite(conn, pid)
|
||
total_chat = n_licenses * CHAT_DEVICES_PER_OFFICE_LICENSE
|
||
if n_licenses == 0:
|
||
return DeviceDecision(
|
||
allowed=False, reason="license_not_found",
|
||
devices_used=0, devices_allowed=0,
|
||
users_used=0, users_allowed=0,
|
||
license_active=False, license_count=0,
|
||
)
|
||
|
||
cur_used = conn.execute(
|
||
"""SELECT COUNT(*) FROM device_bindings
|
||
WHERE user_key = ?
|
||
AND lower(coalesce(device_scope, 'office')) = 'chat'
|
||
AND COALESCE(is_active, 1) = 1""",
|
||
(pid,),
|
||
)
|
||
used_devices = int(cur_used.fetchone()[0])
|
||
|
||
_ok = DeviceDecision(
|
||
allowed=True, reason="ok",
|
||
devices_used=used_devices, devices_allowed=total_chat,
|
||
users_used=0, users_allowed=0,
|
||
license_active=True, license_count=n_licenses,
|
||
)
|
||
|
||
cur = conn.execute(
|
||
"""SELECT id FROM device_bindings
|
||
WHERE user_key = ? AND device_hash = ?
|
||
AND lower(coalesce(device_scope, 'office')) = 'chat'
|
||
LIMIT 1""",
|
||
(pid, device_hash),
|
||
)
|
||
existing = cur.fetchone()
|
||
if existing:
|
||
conn.execute(
|
||
"""UPDATE device_bindings
|
||
SET last_seen_at = ?,
|
||
device_name = COALESCE(NULLIF(?, ''), device_name),
|
||
app_version = COALESCE(NULLIF(?, ''), app_version),
|
||
device_fingerprint = COALESCE(NULLIF(?, ''), device_fingerprint),
|
||
is_active = 1,
|
||
customer_email = COALESCE(NULLIF(?, ''), customer_email)
|
||
WHERE id = ?""",
|
||
(now, device_name, app_version, device_fingerprint, customer_email, int(existing[0])),
|
||
)
|
||
conn.commit()
|
||
return _ok
|
||
|
||
if used_devices >= total_chat:
|
||
return DeviceDecision(
|
||
allowed=False, reason="device_limit_reached",
|
||
devices_used=used_devices, devices_allowed=total_chat,
|
||
users_used=0, users_allowed=0,
|
||
license_active=True, license_count=n_licenses,
|
||
)
|
||
|
||
conn.execute(
|
||
"""INSERT INTO device_bindings
|
||
(customer_email, user_key, device_hash, first_seen_at, last_seen_at,
|
||
device_name, is_active, app_version, device_fingerprint, device_scope)
|
||
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, 'chat')""",
|
||
(customer_email or "", pid, device_hash, now, now,
|
||
device_name, app_version, device_fingerprint),
|
||
)
|
||
conn.commit()
|
||
return DeviceDecision(
|
||
allowed=True, reason="ok",
|
||
devices_used=used_devices + 1, devices_allowed=total_chat,
|
||
users_used=0, users_allowed=0,
|
||
license_active=True, license_count=n_licenses,
|
||
)
|
||
finally:
|
||
conn.close()
|