server.py 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. #!/usr/bin/env python3
  2. import json
  3. import mimetypes
  4. import os
  5. import sqlite3
  6. from datetime import datetime, timezone
  7. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  8. from pathlib import Path
  9. from urllib.parse import urlparse
  10. ROOT = Path(__file__).resolve().parent
  11. DATA_DIR = Path(os.environ.get("BINDVAULT_DATA_DIR", ROOT / "data"))
  12. DB_PATH = Path(os.environ.get("BINDVAULT_DB", DATA_DIR / "bindvault.sqlite3"))
  13. HOST = os.environ.get("BINDVAULT_HOST", "0.0.0.0")
  14. PORT = int(os.environ.get("BINDVAULT_PORT", "8080"))
  15. COLLECTIONS = ("phones", "emails", "domains", "accounts", "bindings", "incidents")
  16. def now_iso():
  17. return datetime.now(timezone.utc).isoformat()
  18. def connect():
  19. DATA_DIR.mkdir(parents=True, exist_ok=True)
  20. conn = sqlite3.connect(DB_PATH)
  21. conn.row_factory = sqlite3.Row
  22. conn.execute("PRAGMA journal_mode=WAL")
  23. conn.execute("PRAGMA foreign_keys=ON")
  24. return conn
  25. def init_db():
  26. with connect() as conn:
  27. conn.executescript(
  28. """
  29. CREATE TABLE IF NOT EXISTS records (
  30. collection TEXT NOT NULL,
  31. id TEXT NOT NULL,
  32. payload TEXT NOT NULL,
  33. created_at TEXT NOT NULL,
  34. updated_at TEXT NOT NULL,
  35. PRIMARY KEY (collection, id)
  36. );
  37. CREATE INDEX IF NOT EXISTS idx_records_collection
  38. ON records(collection);
  39. """
  40. )
  41. def load_state():
  42. state = {name: [] for name in COLLECTIONS}
  43. with connect() as conn:
  44. rows = conn.execute(
  45. "SELECT collection, payload FROM records ORDER BY updated_at DESC"
  46. ).fetchall()
  47. for row in rows:
  48. if row["collection"] not in state:
  49. continue
  50. state[row["collection"]].append(json.loads(row["payload"]))
  51. return state
  52. def replace_state(state):
  53. timestamp = now_iso()
  54. with connect() as conn:
  55. conn.execute("BEGIN")
  56. conn.execute("DELETE FROM records")
  57. for collection in COLLECTIONS:
  58. for record in state.get(collection, []):
  59. record_id = str(record.get("id") or "").strip()
  60. if not record_id:
  61. continue
  62. created_at = str(record.get("created_at") or timestamp)
  63. updated_at = str(record.get("updated_at") or timestamp)
  64. conn.execute(
  65. """
  66. INSERT INTO records(collection, id, payload, created_at, updated_at)
  67. VALUES (?, ?, ?, ?, ?)
  68. """,
  69. (
  70. collection,
  71. record_id,
  72. json.dumps(record, ensure_ascii=False, separators=(",", ":")),
  73. created_at,
  74. updated_at,
  75. ),
  76. )
  77. conn.commit()
  78. class BindVaultHandler(BaseHTTPRequestHandler):
  79. server_version = "BindVaultMVP/0.1"
  80. def do_GET(self):
  81. path = urlparse(self.path).path
  82. if path == "/api/health":
  83. self.write_json({"ok": True, "database": str(DB_PATH)})
  84. return
  85. if path == "/api/state":
  86. self.write_json({"data": load_state(), "database": str(DB_PATH)})
  87. return
  88. self.serve_static(path)
  89. def do_PUT(self):
  90. path = urlparse(self.path).path
  91. if path != "/api/state":
  92. self.write_json({"error": "Not found"}, status=404)
  93. return
  94. try:
  95. body = self.read_json()
  96. incoming = body.get("data", body)
  97. state = {name: incoming.get(name, []) for name in COLLECTIONS}
  98. replace_state(state)
  99. self.write_json({"ok": True, "data": load_state()})
  100. except (json.JSONDecodeError, TypeError, ValueError) as exc:
  101. self.write_json({"error": f"Invalid JSON: {exc}"}, status=400)
  102. def do_OPTIONS(self):
  103. self.send_response(204)
  104. self.send_header("Access-Control-Allow-Origin", "*")
  105. self.send_header("Access-Control-Allow-Methods", "GET, PUT, OPTIONS")
  106. self.send_header("Access-Control-Allow-Headers", "Content-Type")
  107. self.end_headers()
  108. def read_json(self):
  109. length = int(self.headers.get("Content-Length", "0"))
  110. raw = self.rfile.read(length).decode("utf-8")
  111. return json.loads(raw or "{}")
  112. def write_json(self, payload, status=200):
  113. data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
  114. self.send_response(status)
  115. self.send_header("Content-Type", "application/json; charset=utf-8")
  116. self.send_header("Content-Length", str(len(data)))
  117. self.send_header("Access-Control-Allow-Origin", "*")
  118. self.end_headers()
  119. self.wfile.write(data)
  120. def serve_static(self, path):
  121. if path == "/":
  122. path = "/index.html"
  123. target = (ROOT / path.lstrip("/")).resolve()
  124. if ROOT not in target.parents and target != ROOT:
  125. self.write_json({"error": "Forbidden"}, status=403)
  126. return
  127. if not target.is_file():
  128. self.write_json({"error": "Not found"}, status=404)
  129. return
  130. content_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
  131. data = target.read_bytes()
  132. self.send_response(200)
  133. self.send_header("Content-Type", content_type)
  134. self.send_header("Content-Length", str(len(data)))
  135. self.end_headers()
  136. self.wfile.write(data)
  137. def log_message(self, fmt, *args):
  138. print(f"{self.address_string()} - {fmt % args}")
  139. if __name__ == "__main__":
  140. init_db()
  141. print(f"BindVault listening on http://{HOST}:{PORT}")
  142. print(f"SQLite database: {DB_PATH}")
  143. ThreadingHTTPServer((HOST, PORT), BindVaultHandler).serve_forever()