from __future__ import annotations import argparse import hashlib import json import os import secrets import sqlite3 import sys from datetime import datetime, timedelta, timezone from http.cookies import SimpleCookie from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import parse_qs, urlparse if __package__ in (None, ""): sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from app.domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, score_business else: from .domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, score_business ORGANIZATION_ID = "demo-tenant" SCHEMA = Path(__file__).resolve().parents[1] / "schema.sql" SESSION_DAYS = 7 PBKDF2_ITERATIONS = 300_000 # Development fallback: stdlib PBKDF2, not Argon2id. MUTATING_ROLES = {"owner", "admin", "researcher"} def hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]: salt = salt or secrets.token_bytes(16) digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PBKDF2_ITERATIONS) return digest.hex(), salt.hex() def verify_password(password: str, encoded_hash: str, encoded_salt: str) -> bool: try: digest = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(encoded_salt), PBKDF2_ITERATIONS).hex() return secrets.compare_digest(digest, encoded_hash) except (TypeError, ValueError): return False def connect(db_path: str) -> sqlite3.Connection: db = sqlite3.connect(db_path) db.row_factory = sqlite3.Row db.execute("PRAGMA foreign_keys = ON") db.executescript(SCHEMA.read_text()) db.execute("INSERT OR IGNORE INTO organizations (id, name) VALUES (?, ?)", (ORGANIZATION_ID, "Demo organization")) email, password = os.environ.get("BOOTSTRAP_ADMIN_EMAIL"), os.environ.get("BOOTSTRAP_ADMIN_PASSWORD") if email and password: existing = db.execute("SELECT id FROM users WHERE email = ?", (email.strip().lower(),)).fetchone() if not existing: password_hash, salt = hash_password(password) db.execute("INSERT INTO users (organization_id,email,password_hash,password_salt,role) VALUES (?,?,?,?,?)", (ORGANIZATION_ID, email.strip().lower(), password_hash, salt, "owner")) db.commit() return db def row_json(row: sqlite3.Row) -> dict: result = dict(row) result["score_factors"] = json.loads(result.pop("score_factors", "[]")) return result class ApiHandler(BaseHTTPRequestHandler): server_version = "ProspectPlatform/0.1" def send_json(self, status: int, payload: dict | list, extra_headers: dict[str, str] | None = None): body = json.dumps(payload, sort_keys=True).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Access-Control-Allow-Origin", os.environ.get("CORS_ORIGINS", "http://localhost:8080")) self.send_header("Access-Control-Allow-Credentials", "true") self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") self.send_header("Access-Control-Allow-Headers", "Content-Type") for key, value in (extra_headers or {}).items(): self.send_header(key, value) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def read_json(self) -> dict: try: length = int(self.headers.get("Content-Length", "0")) value = json.loads(self.rfile.read(length) or b"{}") return value if isinstance(value, dict) else {} except (ValueError, json.JSONDecodeError): return {} def db(self): return connect(getattr(self.server, "db_path")) def do_OPTIONS(self): self.send_response(204) self.send_header("Access-Control-Allow-Origin", os.environ.get("CORS_ORIGINS", "http://localhost:8080")) self.send_header("Access-Control-Allow-Credentials", "true") self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") self.send_header("Access-Control-Allow-Headers", "Content-Type") self.end_headers() def session_user(self, db: sqlite3.Connection): cookie = SimpleCookie(); cookie.load(self.headers.get("Cookie", "")) token = cookie.get("session") if not token: return None token_hash = hashlib.sha256(token.value.encode()).hexdigest() now = datetime.now(timezone.utc).replace(microsecond=0).isoformat() return db.execute("SELECT u.id, u.email, u.role, u.organization_id FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ? AND s.expires_at > ?", (token_hash, now)).fetchone() def require_auth(self, db): user = self.session_user(db) if not user: self.send_json(401, {"error": "unauthorized"}) return None return user def auth_cookie(self, token: str, max_age: int) -> str: return f"session={token}; Max-Age={max_age}; Path=/; HttpOnly; SameSite=Lax" def do_GET(self): parsed = urlparse(self.path); path = parsed.path.rstrip("/") if path == "/api/v1/health/live": return self.send_json(200, {"status": "ok", "organization_id": ORGANIZATION_ID}) db = self.db() try: user = self.require_auth(db) if not user: return org = user["organization_id"] if path == "/api/v1/auth/me": return self.send_json(200, {"id": user["id"], "email": user["email"], "role": user["role"], "organization_id": org}) if path == "/api/v1/admin/users": if user["role"] not in {"owner", "admin"}: return self.send_json(403, {"error": "forbidden"}) rows = db.execute("SELECT id,email,role,organization_id,created_at FROM users WHERE organization_id = ? ORDER BY id", (org,)).fetchall() return self.send_json(200, {"items": [dict(r) for r in rows]}) if path == "/api/v1/dashboard/summary": row = db.execute("SELECT COUNT(*) AS businesses, COALESCE(AVG(score), 0) AS average_score FROM businesses WHERE organization_id = ?", (org,)).fetchone() return self.send_json(200, {"organization_id": org, "businesses": row["businesses"], "average_score": round(row["average_score"], 2), "suppressed": db.execute("SELECT COUNT(*) FROM suppressions WHERE organization_id = ?", (org,)).fetchone()[0]}) if path == "/api/v1/businesses": query = parse_qs(parsed.query).get("q", [""])[0].strip(); params = [org] sql = "SELECT * FROM businesses WHERE organization_id = ?" if query: like = f"%{query}%"; sql += " AND (name LIKE ? OR website_domain LIKE ? OR email LIKE ?)"; params += [like] * 3 rows = db.execute(sql + " ORDER BY score DESC, id", params).fetchall() return self.send_json(200, {"organization_id": org, "items": [row_json(r) for r in rows]}) if path.startswith("/api/v1/businesses/"): ident = path.rsplit("/", 1)[1] if not ident.isdigit(): return self.send_json(404, {"error": "not_found"}) row = db.execute("SELECT * FROM businesses WHERE id = ? AND organization_id = ?", (int(ident), org)).fetchone() return self.send_json(200, row_json(row)) if row else self.send_json(404, {"error": "not_found"}) return self.send_json(404, {"error": "not_found"}) finally: db.close() def do_POST(self): path = urlparse(self.path).path.rstrip("/") if path == "/api/v1/auth/login": return self.login(self.read_json()) db = self.db() try: user = self.require_auth(db) if not user: return if path == "/api/v1/auth/logout": cookie = SimpleCookie(); cookie.load(self.headers.get("Cookie", "")); token = cookie.get("session") if token: db.execute("DELETE FROM sessions WHERE token_hash = ?", (hashlib.sha256(token.value.encode()).hexdigest(),)) db.execute("INSERT INTO audit_log (organization_id,user_id,action) VALUES (?,?,?)", (user["organization_id"], user["id"], "logout")); db.commit() return self.send_json(200, {"ok": True}, {"Set-Cookie": self.auth_cookie("", 0)}) if user["role"] not in MUTATING_ROLES: return self.send_json(403, {"error": "forbidden"}) payload = self.read_json() if path == "/api/v1/businesses": return self.create_business(payload, db, user["organization_id"]) if path == "/api/v1/suppressions": return self.create_suppression(payload, db, user["organization_id"]) if path == "/api/v1/imports/preview": return self.preview_import(payload, db, user["organization_id"]) return self.send_json(404, {"error": "not_found"}) finally: db.close() def login(self, payload): email = str(payload.get("email", "")).strip().lower(); password = str(payload.get("password", "")); db = self.db() try: user = db.execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone() if not user or not verify_password(password, user["password_hash"], user["password_salt"]): return self.send_json(401, {"error": "invalid_credentials"}) token = secrets.token_urlsafe(32); expires = datetime.now(timezone.utc) + timedelta(days=SESSION_DAYS) db.execute("INSERT INTO sessions (user_id,token_hash,expires_at) VALUES (?,?,?)", (user["id"], hashlib.sha256(token.encode()).hexdigest(), expires.replace(microsecond=0).isoformat())) db.execute("INSERT INTO audit_log (organization_id,user_id,action) VALUES (?,?,?)", (user["organization_id"], user["id"], "login")); db.commit() return self.send_json(200, {"id": user["id"], "email": user["email"], "role": user["role"], "organization_id": user["organization_id"]}, {"Set-Cookie": self.auth_cookie(token, int(timedelta(days=SESSION_DAYS).total_seconds()))}) finally: db.close() def create_business(self, payload, db, org): if not str(payload.get("name", "")).strip(): return self.send_json(400, {"error": "name_required"}) business = normalize_business(payload); suppressions = [dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id = ?", (org,))] if is_suppressed(business, suppressions): return self.send_json(409, {"error": "suppressed"}) fields = [(c, business[c]) for c in ("website_domain", "email", "phone") if business[c]] if fields and db.execute("SELECT id FROM businesses WHERE organization_id = ? AND (" + " OR ".join(f"{c} = ?" for c, _ in fields) + ")", [org] + [v for _, v in fields]).fetchone(): return self.send_json(409, {"error": "duplicate"}) scored = score_business(business); cur = db.execute("INSERT INTO businesses (organization_id,name,website,website_domain,email,phone,description,score,score_version,score_factors,website_class) VALUES (?,?,?,?,?,?,?,?,?,?,?)", (org,business["name"],business["website"],business["website_domain"],business["email"],business["phone"],str(business.get("description", "")),scored["score"],scored["score_version"],json.dumps(scored["factors"]),scored["website_class"])) db.commit(); return self.send_json(201, row_json(db.execute("SELECT * FROM businesses WHERE id = ? AND organization_id = ?", (cur.lastrowid, org)).fetchone())) def create_suppression(self, payload, db, org): kind, value = payload.get("kind"), str(payload.get("value", "")).strip().lower() if kind not in {"email", "domain", "phone"} or not value: return self.send_json(400, {"error": "invalid_suppression"}) try: db.execute("INSERT INTO suppressions (organization_id,kind,value) VALUES (?,?,?)", (org, kind, value)); db.commit() except sqlite3.IntegrityError: pass return self.send_json(201, dict(db.execute("SELECT * FROM suppressions WHERE organization_id = ? AND kind = ? AND value = ?", (org, kind, value)).fetchone())) def preview_import(self, payload, db, org): rows = payload.get("rows", []) if not isinstance(rows, list): return self.send_json(400, {"error": "rows_required"}) normalized = deduplicate_businesses([r for r in rows if isinstance(r, dict) and str(r.get("name", "")).strip()]) suppressions = [dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id = ?", (org,))]; existing = [row_json(r) for r in db.execute("SELECT * FROM businesses WHERE organization_id = ?", (org,))]; seen = set(); accepted, duplicate, suppressed = [], 0, 0 existing_keys = {deduplication_key(x) for x in existing} for b in normalized: key = deduplication_key(b) if is_suppressed(b, suppressions): suppressed += 1 elif key in existing_keys or key in seen: duplicate += 1 else: seen.add(key); accepted.append(b) return self.send_json(200, {"accepted": len(accepted), "duplicates": duplicate + len(rows) - len(normalized), "suppressed": suppressed, "rows": accepted}) def log_message(self, *_): pass def create_server(host="127.0.0.1", port=8000, db_path="prospects.db"): server = ThreadingHTTPServer((host, port), ApiHandler); setattr(server, "db_path", db_path); connect(db_path).close(); return server if __name__ == "__main__": parser = argparse.ArgumentParser(description="Prospect Platform API"); parser.add_argument("--host", default="127.0.0.1"); parser.add_argument("--port", type=int, default=int(os.environ.get("PROSPECT_API_PORT", "8000"))); parser.add_argument("--db", default=os.environ.get("PROSPECT_API_DB", "prospects.db")); args = parser.parse_args(); server = create_server(args.host, args.port, args.db); print(f"Prospect API listening on http://{args.host}:{args.port}", flush=True) try: server.serve_forever() except KeyboardInterrupt: pass finally: server.server_close()