176 lines
6.9 KiB
Python
176 lines
6.9 KiB
Python
"""Encrypted, account-scoped registry of explicitly followed Gitea issues."""
|
|
|
|
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_.-]+$")
|
|
_STATES = {"open", "closed"}
|
|
|
|
|
|
class FollowingStore:
|
|
def __init__(
|
|
self,
|
|
path: str | Path,
|
|
*,
|
|
limit: int = 50,
|
|
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="following",
|
|
)
|
|
self._initialize()
|
|
|
|
def _connect(self) -> sqlite3.Connection:
|
|
return connect_private_sqlite(self.path, timeout=self.timeout)
|
|
|
|
def _initialize(self) -> None:
|
|
with self._connect() as connection:
|
|
connection.execute("PRAGMA journal_mode=WAL")
|
|
connection.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS following_issues (
|
|
login TEXT PRIMARY KEY,
|
|
revision INTEGER NOT NULL,
|
|
items TEXT NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
|
|
@staticmethod
|
|
def _login(login: str) -> str:
|
|
normalized = str(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, "items": []}, False
|
|
items, legacy = self._cipher.open(row[1], binding=f"items:{login}")
|
|
if not isinstance(items, list):
|
|
raise PrivateStateEncryptionError("private state could not be decrypted")
|
|
return {"revision": int(row[0]), "items": items}, legacy
|
|
|
|
def _seal(self, login: str, items: list[dict]) -> str:
|
|
return self._cipher.seal(items, binding=f"items:{login}")
|
|
|
|
@staticmethod
|
|
def _normalize_item(raw: dict) -> dict:
|
|
if not isinstance(raw, dict):
|
|
raise ValueError("following item must be an object")
|
|
repository = raw.get("repository")
|
|
if not isinstance(repository, str) or not _REPOSITORY.fullmatch(repository):
|
|
raise ValueError("repository is invalid")
|
|
number = raw.get("number")
|
|
if not isinstance(number, int) or isinstance(number, bool) or number < 1:
|
|
raise ValueError("number is invalid")
|
|
title = raw.get("title")
|
|
if not isinstance(title, str) or not title.strip() or len(title.strip()) > 300:
|
|
raise ValueError("title is invalid")
|
|
state = raw.get("state")
|
|
if state not in _STATES:
|
|
raise ValueError("state is invalid")
|
|
updated_at = raw.get("updated_at")
|
|
if not isinstance(updated_at, str) or not updated_at or len(updated_at) > 64:
|
|
raise ValueError("updated_at is invalid")
|
|
url = raw.get("url")
|
|
if not isinstance(url, str) or not url.startswith(("http://", "https://")) or len(url) > 2048:
|
|
raise ValueError("url is invalid")
|
|
return {
|
|
"repository": repository,
|
|
"number": number,
|
|
"title": title.strip(),
|
|
"state": state,
|
|
"updated_at": updated_at,
|
|
"url": url,
|
|
}
|
|
|
|
@staticmethod
|
|
def _identity(item: dict) -> tuple[str, int]:
|
|
return item["repository"].lower(), item["number"]
|
|
|
|
def get(self, login: str) -> dict:
|
|
login = self._login(login)
|
|
with self._connect() as connection:
|
|
row = connection.execute(
|
|
"SELECT revision, items FROM following_issues WHERE login = ?", (login,)
|
|
).fetchone()
|
|
snapshot, legacy = self._snapshot(row, login)
|
|
if row is not None and legacy:
|
|
connection.execute(
|
|
"UPDATE following_issues SET items = ? WHERE login = ? AND items = ?",
|
|
(self._seal(login, snapshot["items"]), login, row[1]),
|
|
)
|
|
return snapshot
|
|
|
|
def preflight(self, login: str, raw_item: dict, watching: bool) -> dict:
|
|
"""Validate a requested change and capacity without mutating the registry."""
|
|
login = self._login(login)
|
|
item = self._normalize_item(raw_item)
|
|
if not isinstance(watching, bool):
|
|
raise ValueError("watching is invalid")
|
|
if not watching:
|
|
return item
|
|
identity = self._identity(item)
|
|
with self._connect() as connection:
|
|
row = connection.execute(
|
|
"SELECT revision, items FROM following_issues WHERE login = ?", (login,)
|
|
).fetchone()
|
|
current, _legacy = self._snapshot(row, login)
|
|
if (
|
|
len(current["items"]) >= self.limit
|
|
and not any(self._identity(candidate) == identity for candidate in current["items"])
|
|
):
|
|
raise ValueError(f"following is limited to {self.limit} issues")
|
|
return item
|
|
|
|
def set_watching(self, login: str, raw_item: dict, watching: bool) -> dict:
|
|
login = self._login(login)
|
|
item = self._normalize_item(raw_item)
|
|
if not isinstance(watching, bool):
|
|
raise ValueError("watching is invalid")
|
|
identity = self._identity(item)
|
|
with self._connect() as connection:
|
|
connection.execute("BEGIN IMMEDIATE")
|
|
row = connection.execute(
|
|
"SELECT revision, items FROM following_issues WHERE login = ?", (login,)
|
|
).fetchone()
|
|
current, _legacy = self._snapshot(row, login)
|
|
items = list(current["items"])
|
|
index = next(
|
|
(position for position, candidate in enumerate(items)
|
|
if self._identity(candidate) == identity),
|
|
None,
|
|
)
|
|
if watching:
|
|
if index is None:
|
|
if len(items) >= self.limit:
|
|
raise ValueError(f"following is limited to {self.limit} issues")
|
|
items.insert(0, item)
|
|
elif items[index] == item:
|
|
return current
|
|
else:
|
|
items.pop(index)
|
|
items.insert(0, item)
|
|
elif index is None:
|
|
return current
|
|
else:
|
|
items.pop(index)
|
|
revision = current["revision"] + 1
|
|
connection.execute(
|
|
"INSERT INTO following_issues(login, revision, items) VALUES (?, ?, ?) "
|
|
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, items=excluded.items",
|
|
(login, revision, self._seal(login, items)),
|
|
)
|
|
return {"revision": revision, "items": items}
|