Files
MarketingTool/apps/api/app/main.py
T

220 lines
14 KiB
Python
Raw Normal View History

2026-09-02 17:38:50 +02:00
from __future__ import annotations
import argparse
2026-09-02 17:45:57 +02:00
import hashlib
2026-09-02 17:38:50 +02:00
import json
import os
2026-09-02 17:45:57 +02:00
import secrets
2026-09-02 17:38:50 +02:00
import sqlite3
import sys
2026-09-02 17:45:57 +02:00
from datetime import datetime, timedelta, timezone
from http.cookies import SimpleCookie
2026-09-02 17:38:50 +02:00
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"
2026-09-02 17:45:57 +02:00
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
2026-09-02 17:38:50 +02:00
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())
2026-09-02 17:45:57 +02:00
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()
2026-09-02 17:38:50 +02:00
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"
2026-09-02 17:45:57 +02:00
def send_json(self, status: int, payload: dict | list, extra_headers: dict[str, str] | None = None):
2026-09-02 17:38:50 +02:00
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"))
2026-09-02 17:45:57 +02:00
self.send_header("Access-Control-Allow-Credentials", "true")
2026-09-02 17:38:50 +02:00
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
2026-09-02 17:45:57 +02:00
for key, value in (extra_headers or {}).items(): self.send_header(key, value)
2026-09-02 17:38:50 +02:00
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 {}
2026-09-02 17:45:57 +02:00
def db(self): return connect(getattr(self.server, "db_path"))
2026-09-02 17:38:50 +02:00
def do_OPTIONS(self):
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", os.environ.get("CORS_ORIGINS", "http://localhost:8080"))
2026-09-02 17:45:57 +02:00
self.send_header("Access-Control-Allow-Credentials", "true")
2026-09-02 17:38:50 +02:00
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
2026-09-02 17:45:57 +02:00
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"
2026-09-02 17:38:50 +02:00
def do_GET(self):
2026-09-02 17:45:57 +02:00
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})
2026-09-02 17:38:50 +02:00
db = self.db()
try:
2026-09-02 17:45:57 +02:00
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]})
2026-09-02 17:38:50 +02:00
if path == "/api/v1/dashboard/summary":
2026-09-02 17:45:57 +02:00
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]})
2026-09-02 17:38:50 +02:00
if path == "/api/v1/businesses":
2026-09-02 17:45:57 +02:00
query = parse_qs(parsed.query).get("q", [""])[0].strip(); params = [org]
sql = "SELECT * FROM businesses WHERE organization_id = ?"
2026-09-02 17:38:50 +02:00
if query:
2026-09-02 17:45:57 +02:00
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]})
2026-09-02 17:38:50 +02:00
if path.startswith("/api/v1/businesses/"):
ident = path.rsplit("/", 1)[1]
if not ident.isdigit(): return self.send_json(404, {"error": "not_found"})
2026-09-02 17:45:57 +02:00
row = db.execute("SELECT * FROM businesses WHERE id = ? AND organization_id = ?", (int(ident), org)).fetchone()
2026-09-02 17:38:50 +02:00
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"})
2026-09-02 17:45:57 +02:00
finally: db.close()
2026-09-02 17:38:50 +02:00
def do_POST(self):
path = urlparse(self.path).path.rstrip("/")
2026-09-02 17:45:57 +02:00
if path == "/api/v1/auth/login": return self.login(self.read_json())
2026-09-02 17:38:50 +02:00
db = self.db()
try:
2026-09-02 17:45:57 +02:00
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"})
2026-09-02 17:38:50 +02:00
finally: db.close()
2026-09-02 17:45:57 +02:00
def login(self, payload):
email = str(payload.get("email", "")).strip().lower(); password = str(payload.get("password", "")); db = self.db()
2026-09-02 17:38:50 +02:00
try:
2026-09-02 17:45:57 +02:00
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()))})
2026-09-02 17:38:50 +02:00
finally: db.close()
2026-09-02 17:45:57 +02:00
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):
2026-09-02 17:38:50 +02:00
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()])
2026-09-02 17:45:57 +02:00
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})
2026-09-02 17:38:50 +02:00
def log_message(self, *_): pass
def create_server(host="127.0.0.1", port=8000, db_path="prospects.db"):
2026-09-02 17:45:57 +02:00
server = ThreadingHTTPServer((host, port), ApiHandler); setattr(server, "db_path", db_path); connect(db_path).close(); return server
2026-09-02 17:38:50 +02:00
if __name__ == "__main__":
2026-09-02 17:45:57 +02:00
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)
2026-09-02 17:38:50 +02:00
try: server.serve_forever()
except KeyboardInterrupt: pass
finally: server.server_close()