stackchain-dashboard/src/saved_search_store.py
timmy 965a73ab49
All checks were successful
CI / lint (pull_request) Successful in 3m24s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m2s
CI / release-candidate (pull_request) Has been skipped
feat: rotate shared private-state keys (Closes #1237)
2026-08-21 21:12:54 +00:00

155 lines
6.1 KiB
Python

"""Durable, account-scoped saved Search views."""
import re
import sqlite3
from pathlib import Path
from src.private_state import connect_private_sqlite
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
_VIEW_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
class SavedSearchConflict(ValueError):
"""Raised when a client attempts to replace a stale collection."""
def __init__(self, snapshot: dict):
super().__init__("saved searches changed on another device")
self.snapshot = snapshot
class SavedSearchStore:
def __init__(
self,
path: str | Path,
*,
limit: int = 20,
timeout: float = 1.0,
encryption_key: bytes | None = None,
):
self.path = Path(path)
self.limit = limit
self.timeout = timeout
self._cipher = PrivateStateCipher(
encryption_key if encryption_key is not None else private_state_encryption_config(),
store="saved-searches",
)
self._initialize()
def _initialize(self) -> None:
with self._connect() as connection:
connection.execute("PRAGMA journal_mode=WAL")
connection.execute(
"""
CREATE TABLE IF NOT EXISTS saved_searches (
login TEXT PRIMARY KEY,
revision INTEGER NOT NULL,
views TEXT NOT NULL
)
"""
)
def _connect(self) -> sqlite3.Connection:
return connect_private_sqlite(self.path, timeout=self.timeout)
@staticmethod
def _login(login: str) -> str:
normalized = login.strip().lower()
if not normalized:
raise ValueError("login is required")
return normalized
def _snapshot(self, row, login: str) -> tuple[dict, bool]:
if row is None:
return {"revision": 0, "views": []}, False
views, legacy = self._cipher.open(row[1], binding=f"views:{login}")
if not isinstance(views, list):
raise PrivateStateEncryptionError("private state could not be decrypted")
return {"revision": int(row[0]), "views": views}, legacy
def _sealed_views(self, login: str, views: list[dict]) -> str:
return self._cipher.seal(views, binding=f"views:{login}")
def get(self, login: str) -> dict:
login = self._login(login)
with self._connect() as connection:
row = connection.execute(
"SELECT revision, views FROM saved_searches WHERE login = ?",
(login,),
).fetchone()
snapshot, legacy = self._snapshot(row, login)
if row is not None and legacy:
connection.execute(
"UPDATE saved_searches SET views = ? WHERE login = ? AND views = ?",
(self._sealed_views(login, snapshot["views"]), login, row[1]),
)
return snapshot
def _normalize(self, views: list[dict]) -> list[dict]:
if not isinstance(views, list):
raise ValueError("views must be a list")
if len(views) > self.limit:
raise ValueError(f"saved searches are limited to {self.limit}")
normalized = []
seen = set()
for raw in views:
if not isinstance(raw, dict):
raise ValueError("saved search must be an object")
view_id = raw.get("id")
if not isinstance(view_id, str) or not _VIEW_ID.fullmatch(view_id):
raise ValueError("id is invalid")
if view_id in seen:
raise ValueError("saved search ids must be unique")
name = raw.get("name")
if not isinstance(name, str) or not name.strip():
raise ValueError("name is required")
name = name.strip()
if len(name) > 60:
raise ValueError("name must be at most 60 characters")
query = raw.get("query")
if not isinstance(query, str) or not 2 <= len(query.strip()) <= 200:
raise ValueError("query must be between 2 and 200 characters")
kind = raw.get("kind", "all")
state = raw.get("state", "all")
if kind not in {"all", "issue", "pull"}:
raise ValueError("kind is invalid")
if state not in {"all", "open", "closed"}:
raise ValueError("state is invalid")
repository = raw.get("repository", "")
if not isinstance(repository, str) or (repository and not _REPOSITORY.fullmatch(repository)):
raise ValueError("repository is invalid")
seen.add(view_id)
normalized.append({
"id": view_id,
"name": name,
"query": query.strip(),
"kind": kind,
"state": state,
"repository": repository,
})
return normalized
def replace(self, login: str, expected_revision: int, views: list[dict]) -> dict:
login = self._login(login)
if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0:
raise ValueError("revision is invalid")
normalized = self._normalize(views)
serialized = self._sealed_views(login, normalized)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT revision, views FROM saved_searches WHERE login = ?", (login,)
).fetchone()
current, _legacy = self._snapshot(row, login)
if current["revision"] != expected_revision:
raise SavedSearchConflict(current)
revision = expected_revision + 1
connection.execute(
"INSERT INTO saved_searches(login, revision, views) VALUES (?, ?, ?) "
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, views=excluded.views",
(login, revision, serialized),
)
return {"revision": revision, "views": normalized}