Three isolated services (alert scraper, apply HTTP worker, web UI+DB) with argon2 auth, signed cookies, CSRF, rate-limited login, kill switch, apply circuit breaker, audit log, and strict CSP. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
219 lines
6.6 KiB
Python
219 lines
6.6 KiB
Python
import json
|
|
import sqlite3
|
|
import threading
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Iterable
|
|
|
|
from settings import DB_PATH
|
|
|
|
_lock = threading.Lock()
|
|
|
|
|
|
def _connect() -> sqlite3.Connection:
|
|
conn = sqlite3.connect(DB_PATH, isolation_level=None, check_same_thread=False)
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.execute("PRAGMA foreign_keys=ON")
|
|
return conn
|
|
|
|
|
|
_conn: sqlite3.Connection = _connect()
|
|
|
|
|
|
SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS flats (
|
|
id TEXT PRIMARY KEY,
|
|
link TEXT NOT NULL,
|
|
address TEXT,
|
|
rooms REAL,
|
|
size REAL,
|
|
total_rent REAL,
|
|
sqm_price REAL,
|
|
year_built TEXT,
|
|
wbs TEXT,
|
|
connectivity_morning_time REAL,
|
|
connectivity_night_time REAL,
|
|
address_link_gmaps TEXT,
|
|
payload_json TEXT NOT NULL,
|
|
matched_criteria INTEGER NOT NULL DEFAULT 0,
|
|
discovered_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_flats_discovered ON flats(discovered_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_flats_matched ON flats(matched_criteria);
|
|
|
|
CREATE TABLE IF NOT EXISTS applications (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
flat_id TEXT NOT NULL,
|
|
url TEXT NOT NULL,
|
|
triggered_by TEXT NOT NULL, -- 'user' | 'auto'
|
|
started_at TEXT NOT NULL,
|
|
finished_at TEXT,
|
|
success INTEGER,
|
|
message TEXT,
|
|
FOREIGN KEY (flat_id) REFERENCES flats(id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_applications_flat ON applications(flat_id);
|
|
CREATE INDEX IF NOT EXISTS idx_applications_started ON applications(started_at DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS state (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS audit_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
timestamp TEXT NOT NULL,
|
|
actor TEXT NOT NULL,
|
|
action TEXT NOT NULL,
|
|
details TEXT,
|
|
ip TEXT
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp DESC);
|
|
"""
|
|
|
|
DEFAULTS = {
|
|
"mode": "manual", # 'manual' | 'auto'
|
|
"kill_switch": "0", # '1' = apply disabled
|
|
"apply_circuit_open": "0", # '1' = opened by circuit breaker
|
|
"apply_recent_failures": "0",
|
|
"last_alert_heartbeat": "",
|
|
"last_apply_heartbeat": "",
|
|
}
|
|
|
|
|
|
def init_db() -> None:
|
|
with _lock:
|
|
_conn.executescript(SCHEMA)
|
|
for k, v in DEFAULTS.items():
|
|
_conn.execute("INSERT OR IGNORE INTO state(key, value) VALUES (?, ?)", (k, v))
|
|
|
|
|
|
def now_iso() -> str:
|
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
|
|
|
|
def get_state(key: str) -> str | None:
|
|
row = _conn.execute("SELECT value FROM state WHERE key = ?", (key,)).fetchone()
|
|
return row["value"] if row else None
|
|
|
|
|
|
def set_state(key: str, value: str) -> None:
|
|
with _lock:
|
|
_conn.execute(
|
|
"INSERT INTO state(key, value) VALUES (?, ?) "
|
|
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
(key, value),
|
|
)
|
|
|
|
|
|
def upsert_flat(payload: dict, matched: bool) -> bool:
|
|
"""Returns True if this flat is new."""
|
|
flat_id = str(payload["id"])
|
|
conn_info = payload.get("connectivity") or {}
|
|
with _lock:
|
|
existing = _conn.execute("SELECT id FROM flats WHERE id = ?", (flat_id,)).fetchone()
|
|
if existing:
|
|
return False
|
|
_conn.execute(
|
|
"""
|
|
INSERT INTO flats(
|
|
id, link, address, rooms, size, total_rent, sqm_price, year_built, wbs,
|
|
connectivity_morning_time, connectivity_night_time, address_link_gmaps,
|
|
payload_json, matched_criteria, discovered_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
flat_id,
|
|
payload.get("link", ""),
|
|
payload.get("address", ""),
|
|
payload.get("rooms"),
|
|
payload.get("size"),
|
|
payload.get("total_rent"),
|
|
payload.get("sqm_price"),
|
|
str(payload.get("year_built", "")),
|
|
str(payload.get("wbs", "")),
|
|
conn_info.get("morning_time"),
|
|
conn_info.get("night_time"),
|
|
payload.get("address_link_gmaps"),
|
|
json.dumps(payload, default=str),
|
|
1 if matched else 0,
|
|
now_iso(),
|
|
),
|
|
)
|
|
return True
|
|
|
|
|
|
def recent_flats(limit: int = 50) -> list[sqlite3.Row]:
|
|
return list(
|
|
_conn.execute(
|
|
"""
|
|
SELECT f.*,
|
|
(SELECT success FROM applications a WHERE a.flat_id = f.id
|
|
ORDER BY a.started_at DESC LIMIT 1) AS last_application_success,
|
|
(SELECT message FROM applications a WHERE a.flat_id = f.id
|
|
ORDER BY a.started_at DESC LIMIT 1) AS last_application_message,
|
|
(SELECT started_at FROM applications a WHERE a.flat_id = f.id
|
|
ORDER BY a.started_at DESC LIMIT 1) AS last_application_at
|
|
FROM flats f
|
|
ORDER BY f.discovered_at DESC
|
|
LIMIT ?
|
|
""",
|
|
(limit,),
|
|
).fetchall()
|
|
)
|
|
|
|
|
|
def get_flat(flat_id: str) -> sqlite3.Row | None:
|
|
return _conn.execute("SELECT * FROM flats WHERE id = ?", (flat_id,)).fetchone()
|
|
|
|
|
|
def start_application(flat_id: str, url: str, triggered_by: str) -> int:
|
|
with _lock:
|
|
cur = _conn.execute(
|
|
"INSERT INTO applications(flat_id, url, triggered_by, started_at) VALUES (?, ?, ?, ?)",
|
|
(flat_id, url, triggered_by, now_iso()),
|
|
)
|
|
return cur.lastrowid
|
|
|
|
|
|
def finish_application(app_id: int, success: bool, message: str) -> None:
|
|
with _lock:
|
|
_conn.execute(
|
|
"UPDATE applications SET finished_at = ?, success = ?, message = ? WHERE id = ?",
|
|
(now_iso(), 1 if success else 0, message, app_id),
|
|
)
|
|
|
|
|
|
def recent_applications(limit: int = 20) -> list[sqlite3.Row]:
|
|
return list(
|
|
_conn.execute(
|
|
"""
|
|
SELECT a.*, f.address, f.link
|
|
FROM applications a
|
|
JOIN flats f ON f.id = a.flat_id
|
|
ORDER BY a.started_at DESC
|
|
LIMIT ?
|
|
""",
|
|
(limit,),
|
|
).fetchall()
|
|
)
|
|
|
|
|
|
def log_audit(actor: str, action: str, details: str = "", ip: str = "") -> None:
|
|
with _lock:
|
|
_conn.execute(
|
|
"INSERT INTO audit_log(timestamp, actor, action, details, ip) VALUES (?, ?, ?, ?, ?)",
|
|
(now_iso(), actor, action, details, ip),
|
|
)
|
|
|
|
|
|
def recent_audit(limit: int = 30) -> list[sqlite3.Row]:
|
|
return list(
|
|
_conn.execute(
|
|
"SELECT * FROM audit_log ORDER BY timestamp DESC LIMIT ?",
|
|
(limit,),
|
|
).fetchall()
|
|
)
|