diff --git a/core/customer_db.py b/core/customer_db.py new file mode 100644 index 0000000..fa93b08 --- /dev/null +++ b/core/customer_db.py @@ -0,0 +1,189 @@ +""" +NetworkBuster Software - Customer Database +Stores new customer records ingested from log entries. +""" + +import json +import uuid +import threading +from pathlib import Path +from dataclasses import dataclass, field, asdict +from datetime import datetime +from typing import Any, Dict, List, Optional + + +@dataclass +class Customer: + """Represents a customer record ingested from logs.""" + id: str + name: str + source: str # log file path or manual + log_content: str # original log line that triggered creation + created_at: str = field(default_factory=lambda: datetime.now().isoformat()) + metadata: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict: + return asdict(self) + + @classmethod + def from_dict(cls, d: dict) -> "Customer": + return cls( + id=d["id"], + name=d["name"], + source=d["source"], + log_content=d["log_content"], + created_at=d.get("created_at", datetime.now().isoformat()), + metadata=d.get("metadata", {}), + ) + + +class CustomerDatabase: + """ + Lightweight JSON-backed database for customer records. + Customers are created from log entries produced by LogMonitor. + """ + + def __init__(self, storage_path: Optional[Path] = None): + self.storage_path = storage_path or Path("data/customers") + self.storage_path.mkdir(parents=True, exist_ok=True) + self._db_file = self.storage_path / "customers.json" + self._records: Dict[str, Customer] = {} + self._lock = threading.Lock() + self._load() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def add( + self, + name: str, + source: str, + log_content: str, + metadata: Optional[Dict[str, Any]] = None, + ) -> Customer: + """Add a new customer record and persist it.""" + customer_id = self._generate_id(name, source) + customer = Customer( + id=customer_id, + name=name, + source=source, + log_content=log_content, + metadata=metadata or {}, + ) + with self._lock: + self._records[customer_id] = customer + self._save() + return customer + + def ingest_log_entry(self, entry: Any) -> Customer: + """ + Convert a LogEntry (from core.log_monitor) into a Customer record + and store it in the database. + + The log line is used as the customer name when no explicit name can be + parsed; callers can pass a richer *entry* by sub-classing or simply by + populating ``entry.content``. + """ + name = _extract_name_from_log(entry.content) + metadata = { + "line_number": entry.line_number, + "level": entry.level, + "log_timestamp": entry.timestamp.isoformat() if hasattr(entry.timestamp, "isoformat") else str(entry.timestamp), + } + return self.add( + name=name, + source=entry.filepath, + log_content=entry.content, + metadata=metadata, + ) + + def get(self, customer_id: str) -> Optional[Customer]: + """Return a customer by ID, or None if not found.""" + with self._lock: + return self._records.get(customer_id) + + def list_all(self, limit: int = 100) -> List[Customer]: + """Return all customers, newest first.""" + with self._lock: + records = list(self._records.values()) + records.sort(key=lambda c: c.created_at, reverse=True) + return records[:limit] + + def count(self) -> int: + """Return total number of stored customers.""" + with self._lock: + return len(self._records) + + def delete(self, customer_id: str) -> bool: + """Remove a customer record. Returns True if deleted.""" + with self._lock: + if customer_id not in self._records: + return False + del self._records[customer_id] + self._save() + return True + + def clear(self): + """Remove all customer records.""" + with self._lock: + self._records.clear() + self._save() + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + @staticmethod + def _generate_id(name: str, source: str) -> str: + ts = datetime.now().strftime("%Y%m%d%H%M%S") + unique = uuid.uuid4().hex[:12] + return f"cust_{ts}_{unique}" + + def _save(self): + """Persist all records to disk.""" + try: + with self._lock: + data = [r.to_dict() for r in self._records.values()] + self._db_file.write_text( + json.dumps(data, indent=2, default=str), encoding="utf-8" + ) + except Exception: + pass + + def _load(self): + """Load records from disk on startup.""" + try: + if self._db_file.exists(): + raw = json.loads(self._db_file.read_text(encoding="utf-8")) + for item in raw: + c = Customer.from_dict(item) + self._records[c.id] = c + except Exception: + pass + + +# ------------------------------------------------------------------ +# Helpers +# ------------------------------------------------------------------ + +def _extract_name_from_log(content: str) -> str: + """ + Best-effort extraction of a customer identifier from a log line. + + Looks for common patterns such as: + - ``user=alice`` / ``customer=alice`` / ``name=alice`` + - ``User: alice`` / ``Customer: alice`` + + Falls back to a truncated version of the log line itself. + """ + import re + patterns = [ + r"(?:user|customer|client|name)[=:\s]+([A-Za-z0-9_@.\-]+)", + ] + for pat in patterns: + m = re.search(pat, content, re.IGNORECASE) + if m: + return m.group(1) + # Fallback: first 60 chars of the log line + return content[:60].strip() or "unknown" diff --git a/core/log_monitor.py b/core/log_monitor.py index 3491c08..a0f4659 100644 --- a/core/log_monitor.py +++ b/core/log_monitor.py @@ -14,10 +14,11 @@ try: from watchdog.observers import Observer - from watchdog.events import FileSystemEventHandler, FileModifiedEvent, FileCreatedEvent + from watchdog.events import FileSystemEventHandler, FileCreatedEvent, FileModifiedEvent WATCHDOG_AVAILABLE = True except ImportError: WATCHDOG_AVAILABLE = False + FileSystemEventHandler = object # stub so class definition below is valid @dataclass diff --git a/tests/test_customer_db.py b/tests/test_customer_db.py new file mode 100644 index 0000000..a8fa8c4 --- /dev/null +++ b/tests/test_customer_db.py @@ -0,0 +1,219 @@ +""" +Tests for core/customer_db.py – CustomerDatabase and log ingestion. +""" +import sys +import os +from datetime import datetime +from pathlib import Path + +# Ensure repo root is on the path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from core.customer_db import Customer, CustomerDatabase, _extract_name_from_log +from core.log_monitor import LogEntry + + +# --------------------------------------------------------------------------- +# _extract_name_from_log +# --------------------------------------------------------------------------- + +def test_extract_name_user_equals(): + assert _extract_name_from_log("user=alice logged in") == "alice" + + +def test_extract_name_customer_colon(): + assert _extract_name_from_log("Customer: bob connected") == "bob" + + +def test_extract_name_fallback(): + line = "some random log line without a name" + result = _extract_name_from_log(line) + assert result == line[:60].strip() + + +def test_extract_name_empty(): + assert _extract_name_from_log("") == "unknown" + + +# --------------------------------------------------------------------------- +# CustomerDatabase – basic CRUD +# --------------------------------------------------------------------------- + +def test_add_and_get(tmp_path): + db = CustomerDatabase(tmp_path / "db") + c = db.add(name="Alice", source="/var/log/app.log", log_content="user=Alice joined") + assert c.id.startswith("cust_") + assert c.name == "Alice" + fetched = db.get(c.id) + assert fetched is not None + assert fetched.name == "Alice" + + +def test_list_all(tmp_path): + db = CustomerDatabase(tmp_path / "db") + db.add(name="Alice", source="log1.log", log_content="user=Alice") + db.add(name="Bob", source="log2.log", log_content="user=Bob") + records = db.list_all() + assert len(records) == 2 + + +def test_count(tmp_path): + db = CustomerDatabase(tmp_path / "db") + assert db.count() == 0 + db.add(name="Alice", source="x.log", log_content="user=Alice") + assert db.count() == 1 + + +def test_delete(tmp_path): + db = CustomerDatabase(tmp_path / "db") + c = db.add(name="Alice", source="x.log", log_content="user=Alice") + assert db.delete(c.id) is True + assert db.get(c.id) is None + assert db.delete(c.id) is False # already gone + + +def test_clear(tmp_path): + db = CustomerDatabase(tmp_path / "db") + db.add(name="Alice", source="x.log", log_content="user=Alice") + db.clear() + assert db.count() == 0 + + +# --------------------------------------------------------------------------- +# CustomerDatabase – persistence across instances +# --------------------------------------------------------------------------- + +def test_persist_to_disk(tmp_path): + storage = tmp_path / "db" + db1 = CustomerDatabase(storage) + db1.add(name="Alice", source="x.log", log_content="user=Alice") + + db2 = CustomerDatabase(storage) # second instance reads from disk + assert db2.count() == 1 + records = db2.list_all() + assert records[0].name == "Alice" + + +# --------------------------------------------------------------------------- +# CustomerDatabase – ingest_log_entry +# --------------------------------------------------------------------------- + +def test_ingest_log_entry(tmp_path): + db = CustomerDatabase(tmp_path / "db") + entry = LogEntry( + filepath="/var/log/app.log", + line_number=42, + content="user=charlie connected successfully", + timestamp=datetime.now(), + level="info", + ) + customer = db.ingest_log_entry(entry) + assert customer.name == "charlie" + assert customer.source == "/var/log/app.log" + assert customer.log_content == "user=charlie connected successfully" + assert customer.metadata["level"] == "info" + assert customer.metadata["line_number"] == 42 + assert db.count() == 1 + + +def test_ingest_log_entry_no_name(tmp_path): + db = CustomerDatabase(tmp_path / "db") + entry = LogEntry( + filepath="app.log", + line_number=1, + content="generic startup message", + timestamp=datetime.now(), + level=None, + ) + customer = db.ingest_log_entry(entry) + assert customer.name == "generic startup message" + + +# --------------------------------------------------------------------------- +# Customer.to_dict / from_dict roundtrip +# --------------------------------------------------------------------------- + +def test_customer_serialisation(): + c = Customer( + id="cust_test_001", + name="TestUser", + source="test.log", + log_content="user=TestUser", + metadata={"level": "info"}, + ) + d = c.to_dict() + c2 = Customer.from_dict(d) + assert c2.id == c.id + assert c2.name == c.name + assert c2.metadata == c.metadata + + +# --------------------------------------------------------------------------- +# Webapp routes (Flask test client) +# --------------------------------------------------------------------------- + +def _make_app(tmp_path): + """Return a Flask test client with a fresh database.""" + import importlib, types + + # Import the app module with a patched storage path + import webapp.app as app_module + # Override the singleton DB with a fresh one pointing at tmp_path + app_module._customer_db = CustomerDatabase(tmp_path / "customers") + app_module.app.config["TESTING"] = True + return app_module.app.test_client(), app_module._customer_db + + +def test_route_list_customers_empty(tmp_path): + client, _ = _make_app(tmp_path) + resp = client.get("/customers") + assert resp.status_code == 200 + data = resp.get_json() + assert data["total"] == 0 + assert data["customers"] == [] + + +def test_route_add_customer(tmp_path): + client, db = _make_app(tmp_path) + resp = client.post( + "/customers/add", + json={"name": "Alice", "source": "test.log", "log_content": "user=Alice"}, + ) + assert resp.status_code == 201 + data = resp.get_json() + assert data["status"] == "success" + assert data["customer"]["name"] == "Alice" + + +def test_route_add_customer_missing_name(tmp_path): + client, _ = _make_app(tmp_path) + resp = client.post("/customers/add", json={"source": "test.log"}) + assert resp.status_code == 400 + + +def test_route_ingest_log(tmp_path): + client, db = _make_app(tmp_path) + resp = client.post( + "/customers/ingest", + json={"content": "user=bob joined the platform", "filepath": "app.log", "level": "info"}, + ) + assert resp.status_code == 201 + data = resp.get_json() + assert data["status"] == "success" + assert data["customer"]["name"] == "bob" + assert db.count() == 1 + + +def test_route_ingest_log_missing_content(tmp_path): + client, _ = _make_app(tmp_path) + resp = client.post("/customers/ingest", json={"filepath": "app.log"}) + assert resp.status_code == 400 + + +def test_route_customers_after_ingest(tmp_path): + client, _ = _make_app(tmp_path) + client.post("/customers/ingest", json={"content": "user=dave signup", "filepath": "a.log"}) + resp = client.get("/customers") + data = resp.get_json() + assert data["total"] == 1 + assert data["customers"][0]["name"] == "dave" diff --git a/webapp/app.py b/webapp/app.py index 0d64ec6..30b47d3 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -4,7 +4,12 @@ import sys from pathlib import Path +# Customer database – path is resolved relative to repo root +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) +from core.customer_db import CustomerDatabase + app = Flask(__name__) +_customer_db = CustomerDatabase(Path(__file__).parent.parent / "data" / "customers") # Flash Commands Definition FLASH_COMMANDS = { @@ -92,6 +97,68 @@ def distro_info(): return jsonify({"status": "success", "distros": sorted(distros, key=lambda x: x['created'], reverse=True)}) +@app.route('/customers', methods=['GET']) +def list_customers(): + """Return all customer records as JSON.""" + limit = int(request.args.get('limit', 100)) + customers = _customer_db.list_all(limit=limit) + return jsonify({ + "status": "success", + "total": _customer_db.count(), + "customers": [c.to_dict() for c in customers], + }) + + +@app.route('/customers/add', methods=['POST']) +def add_customer(): + """ + Manually add a customer record. + Expected JSON body: { "name": "...", "source": "...", "log_content": "...", "metadata": {} } + """ + body = request.get_json(force=True, silent=True) or {} + name = (body.get("name") or "").strip() + if not name: + return jsonify({"status": "error", "message": "name is required"}), 400 + customer = _customer_db.add( + name=name, + source=body.get("source", "manual"), + log_content=body.get("log_content", ""), + metadata=body.get("metadata", {}), + ) + return jsonify({"status": "success", "customer": customer.to_dict()}), 201 + + +@app.route('/customers/ingest', methods=['POST']) +def ingest_log(): + """ + Ingest a raw log line as a new customer record. + Expected JSON body: { "filepath": "...", "line_number": 1, "content": "...", "level": "info", "timestamp": "..." } + """ + body = request.get_json(force=True, silent=True) or {} + content = (body.get("content") or "").strip() + if not content: + return jsonify({"status": "error", "message": "content is required"}), 400 + + from datetime import datetime as _dt + from core.log_monitor import LogEntry + + ts_raw = body.get("timestamp") + try: + ts = _dt.fromisoformat(ts_raw) if ts_raw else _dt.now() + except (ValueError, TypeError): + ts = _dt.now() + + entry = LogEntry( + filepath=body.get("filepath", "manual"), + line_number=int(body.get("line_number", 0)), + content=content, + timestamp=ts, + level=body.get("level"), + ) + customer = _customer_db.ingest_log_entry(entry) + return jsonify({"status": "success", "customer": customer.to_dict()}), 201 + + if __name__ == "__main__": # Unified Certificate Configuration cert_path = os.path.join(os.path.dirname(__file__), "..", "certs", "unified_certificate.pem") diff --git a/webapp/templates/index.html b/webapp/templates/index.html index fd99f3e..bc1159b 100644 --- a/webapp/templates/index.html +++ b/webapp/templates/index.html @@ -6,21 +6,43 @@ NetworkBuster FlashCommands

🚀 NetworkBuster WebApp

Flash Commands Control Center

- +
{% for cmd_id, info in commands.items() %}
@@ -35,26 +57,150 @@

{{ info.name }}

Execution Output:

Ready...
+ + +
+

👤 Customer Database

+

Loading…

+ + +
+ + + + + + + + + + + + + + +
IDNameSourceLog ContentLevelCreated
Loading…
+
+ + +
+ Ingest Log Entry as New Customer +

+ + + + + + + +
+
+
+