Spaces:
Running
Running
Delete _page_feedback
Browse files- _page_feedback/__init__.py +0 -47
- _page_feedback/_contracts.py +0 -560
- _page_feedback/_service/__init__.py +0 -20
- _page_feedback/_service/_config.py +0 -775
- _page_feedback/_service/_core.py +0 -307
- _page_feedback/_service/_github.py +0 -714
- _page_feedback/_service/_sqlite.py +0 -218
- _page_feedback/_service/app.py +0 -690
_page_feedback/__init__.py
DELETED
|
@@ -1,47 +0,0 @@
|
|
| 1 |
-
# scikitplot/_externals/_sphinx_ext/_sphinx_feedback/__init__.py
|
| 2 |
-
#
|
| 3 |
-
# Authors: The scikit-plots developers
|
| 4 |
-
# SPDX-License-Identifier: BSD-3-Clause
|
| 5 |
-
|
| 6 |
-
"""
|
| 7 |
-
Privacy-minimal page feedback for Sphinx and static HTML.
|
| 8 |
-
|
| 9 |
-
The dependency-free helpers in this package can be imported without Sphinx.
|
| 10 |
-
Sphinx-specific integration is imported lazily by :func:`setup`.
|
| 11 |
-
"""
|
| 12 |
-
|
| 13 |
-
from __future__ import annotations
|
| 14 |
-
|
| 15 |
-
from ._contracts import (
|
| 16 |
-
FeedbackConflictError,
|
| 17 |
-
FeedbackValidationError,
|
| 18 |
-
build_feedback_event,
|
| 19 |
-
canonical_feedback_bytes,
|
| 20 |
-
decode_feedback_request,
|
| 21 |
-
feedback_event_request_hash,
|
| 22 |
-
feedback_request_from_event,
|
| 23 |
-
feedback_request_hash,
|
| 24 |
-
parse_feedback_request,
|
| 25 |
-
)
|
| 26 |
-
|
| 27 |
-
__version__ = "0.6.1"
|
| 28 |
-
|
| 29 |
-
__all__ = [
|
| 30 |
-
"FeedbackConflictError",
|
| 31 |
-
"FeedbackValidationError",
|
| 32 |
-
"build_feedback_event",
|
| 33 |
-
"canonical_feedback_bytes",
|
| 34 |
-
"decode_feedback_request",
|
| 35 |
-
"feedback_event_request_hash",
|
| 36 |
-
"feedback_request_from_event",
|
| 37 |
-
"feedback_request_hash",
|
| 38 |
-
"parse_feedback_request",
|
| 39 |
-
"setup",
|
| 40 |
-
]
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
def setup(app):
|
| 44 |
-
"""Register the Sphinx adapter lazily so helpers remain dependency-free."""
|
| 45 |
-
from ._sphinx import setup_extension # noqa: PLC0415 -- Sphinx is optional
|
| 46 |
-
|
| 47 |
-
return setup_extension(app)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_page_feedback/_contracts.py
DELETED
|
@@ -1,560 +0,0 @@
|
|
| 1 |
-
"""Strict, privacy-minimal contracts for generic page feedback."""
|
| 2 |
-
|
| 3 |
-
from __future__ import annotations
|
| 4 |
-
|
| 5 |
-
import hashlib
|
| 6 |
-
import json
|
| 7 |
-
import re
|
| 8 |
-
import unicodedata
|
| 9 |
-
from typing import Any
|
| 10 |
-
|
| 11 |
-
REQUEST_CONTRACT = "page.feedback-request.v1"
|
| 12 |
-
EVENT_CONTRACT = "page.feedback-event.v1"
|
| 13 |
-
RECEIPT_CONTRACT = "page.feedback-receipt.v1"
|
| 14 |
-
AGGREGATE_CONTRACT = "page.feedback-aggregate.v3"
|
| 15 |
-
|
| 16 |
-
# A 2,000-code-point Unicode comment can exceed 8 KiB once encoded. Keep the
|
| 17 |
-
# canonical envelope and transport limits aligned so the documented comment
|
| 18 |
-
# limit remains usable for non-ASCII text as well as ASCII prose.
|
| 19 |
-
MAX_REQUEST_BYTES = 16 * 1024
|
| 20 |
-
MAX_AGGREGATE_BYTES = 2 * 1024 * 1024
|
| 21 |
-
MAX_SITE_ID = 96
|
| 22 |
-
MAX_PAGE_ID = 512
|
| 23 |
-
MAX_PAGE_REVISION = 128
|
| 24 |
-
MAX_COMMENT = 2000
|
| 25 |
-
MAX_CONTRIBUTOR = 80
|
| 26 |
-
|
| 27 |
-
_SITE_ID_RE = re.compile(r"[A-Za-z0-9](?:[A-Za-z0-9._-]{0,94}[A-Za-z0-9])?\Z")
|
| 28 |
-
_FEEDBACK_ID_RE = re.compile(r"feedback-[0-9a-f]{48}\Z")
|
| 29 |
-
_REQUEST_HASH_RE = re.compile(r"[0-9a-f]{64}\Z")
|
| 30 |
-
_ALLOWED_TOP = frozenset(
|
| 31 |
-
{
|
| 32 |
-
"contract",
|
| 33 |
-
"action",
|
| 34 |
-
"site_id",
|
| 35 |
-
"page_id",
|
| 36 |
-
"page_revision",
|
| 37 |
-
"feedback_id",
|
| 38 |
-
"rating",
|
| 39 |
-
"mode",
|
| 40 |
-
"comment",
|
| 41 |
-
"contributor",
|
| 42 |
-
}
|
| 43 |
-
)
|
| 44 |
-
_ALLOWED_CONTRIBUTOR = frozenset({"display_name"})
|
| 45 |
-
_ALLOWED_EVENT_TOP = frozenset(
|
| 46 |
-
{
|
| 47 |
-
"contract",
|
| 48 |
-
"site_id",
|
| 49 |
-
"page_id",
|
| 50 |
-
"page_revision",
|
| 51 |
-
"feedback",
|
| 52 |
-
},
|
| 53 |
-
)
|
| 54 |
-
_ALLOWED_EVENT_FEEDBACK = frozenset(
|
| 55 |
-
{
|
| 56 |
-
"id",
|
| 57 |
-
"rating",
|
| 58 |
-
"mode",
|
| 59 |
-
"comment",
|
| 60 |
-
"contributor",
|
| 61 |
-
},
|
| 62 |
-
)
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
class FeedbackValidationError(ValueError):
|
| 66 |
-
"""Raised when a public feedback envelope violates the contract."""
|
| 67 |
-
|
| 68 |
-
def __init__(self, message: str, *, code: str = "invalid_feedback") -> None:
|
| 69 |
-
super().__init__(message)
|
| 70 |
-
self.code = code
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
class FeedbackConflictError(RuntimeError):
|
| 74 |
-
"""Raised when an idempotency key is replayed with different content."""
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
def _reject_unknown_keys(
|
| 78 |
-
value: dict[str, Any],
|
| 79 |
-
allowed: frozenset[str],
|
| 80 |
-
path: str,
|
| 81 |
-
) -> None:
|
| 82 |
-
extras = sorted(str(key) for key in value if key not in allowed)
|
| 83 |
-
if extras:
|
| 84 |
-
raise FeedbackValidationError(
|
| 85 |
-
f"{path}: unsupported field(s): {', '.join(extras)}",
|
| 86 |
-
code="unexpected_field",
|
| 87 |
-
)
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
def _nfc_text(value: Any, *, path: str, limit: int, allow_empty: bool) -> str:
|
| 91 |
-
if not isinstance(value, str):
|
| 92 |
-
raise FeedbackValidationError(f"{path}: must be a string")
|
| 93 |
-
try:
|
| 94 |
-
text = unicodedata.normalize("NFC", value)
|
| 95 |
-
text.encode("utf-8", "strict")
|
| 96 |
-
except (UnicodeError, ValueError) as exc:
|
| 97 |
-
raise FeedbackValidationError(f"{path}: invalid Unicode text") from exc
|
| 98 |
-
if len(text) > limit:
|
| 99 |
-
raise FeedbackValidationError(f"{path}: exceeds {limit} characters")
|
| 100 |
-
if not allow_empty and not text:
|
| 101 |
-
raise FeedbackValidationError(f"{path}: must not be empty")
|
| 102 |
-
return text
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
def _public_credit(value: Any) -> str:
|
| 106 |
-
text = _nfc_text(
|
| 107 |
-
value,
|
| 108 |
-
path="contributor.display_name",
|
| 109 |
-
limit=MAX_CONTRIBUTOR,
|
| 110 |
-
allow_empty=True,
|
| 111 |
-
)
|
| 112 |
-
if any(
|
| 113 |
-
ord(ch) < 32 or ord(ch) == 127 # ruff: ignore[magic-value-comparison]
|
| 114 |
-
for ch in text
|
| 115 |
-
):
|
| 116 |
-
raise FeedbackValidationError(
|
| 117 |
-
"contributor.display_name: control characters are not allowed"
|
| 118 |
-
)
|
| 119 |
-
text = re.sub(r" +", " ", text.strip(" "))
|
| 120 |
-
if len(text) > MAX_CONTRIBUTOR:
|
| 121 |
-
raise FeedbackValidationError(
|
| 122 |
-
f"contributor.display_name: exceeds {MAX_CONTRIBUTOR} characters"
|
| 123 |
-
)
|
| 124 |
-
return text
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
def _comment(value: Any) -> str:
|
| 128 |
-
text = _nfc_text(
|
| 129 |
-
value,
|
| 130 |
-
path="comment",
|
| 131 |
-
limit=MAX_COMMENT,
|
| 132 |
-
allow_empty=True,
|
| 133 |
-
).strip(" \t\n\r")
|
| 134 |
-
# Preserve line breaks and tabs in deliberate prose, but reject the rest of
|
| 135 |
-
# C0/DEL. This mirrors the AI Learn reviewed-comment boundary.
|
| 136 |
-
for ch in text:
|
| 137 |
-
code = ord(ch)
|
| 138 |
-
if code == 127 or ( # ruff: ignore[magic-value-comparison]
|
| 139 |
-
code < 32 and ch not in "\t\n\r" # ruff: ignore[magic-value-comparison]
|
| 140 |
-
):
|
| 141 |
-
raise FeedbackValidationError("comment: unsupported control characters")
|
| 142 |
-
return text
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
def normalize_page_id(value: Any) -> str:
|
| 146 |
-
"""Validate a canonical page identity without interpreting it as a URL/path."""
|
| 147 |
-
text = _nfc_text(
|
| 148 |
-
value, path="page_id", limit=MAX_PAGE_ID, allow_empty=False
|
| 149 |
-
).strip()
|
| 150 |
-
if (
|
| 151 |
-
text.startswith("/")
|
| 152 |
-
or text.endswith("/")
|
| 153 |
-
or "\\" in text
|
| 154 |
-
or "?" in text
|
| 155 |
-
or "#" in text
|
| 156 |
-
or any(
|
| 157 |
-
ord(ch) < 32 or ord(ch) == 127 # ruff: ignore[magic-value-comparison]
|
| 158 |
-
for ch in text
|
| 159 |
-
)
|
| 160 |
-
):
|
| 161 |
-
raise FeedbackValidationError("page_id: invalid canonical page name")
|
| 162 |
-
parts = text.split("/")
|
| 163 |
-
if any(part in {"", ".", ".."} for part in parts):
|
| 164 |
-
raise FeedbackValidationError("page_id: invalid path segment")
|
| 165 |
-
return text
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
def normalize_page_revision(value: Any) -> str:
|
| 169 |
-
if value is None or value == "":
|
| 170 |
-
return ""
|
| 171 |
-
text = _nfc_text(
|
| 172 |
-
value,
|
| 173 |
-
path="page_revision",
|
| 174 |
-
limit=MAX_PAGE_REVISION,
|
| 175 |
-
allow_empty=True,
|
| 176 |
-
).strip()
|
| 177 |
-
if any(
|
| 178 |
-
ord(ch) < 32 or ord(ch) == 127 # ruff: ignore[magic-value-comparison]
|
| 179 |
-
for ch in text
|
| 180 |
-
):
|
| 181 |
-
raise FeedbackValidationError(
|
| 182 |
-
"page_revision: control characters are not allowed",
|
| 183 |
-
)
|
| 184 |
-
return text
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
def _site_id(value: Any) -> str:
|
| 188 |
-
site_id = _nfc_text(
|
| 189 |
-
value, path="site_id", limit=MAX_SITE_ID, allow_empty=False
|
| 190 |
-
).strip()
|
| 191 |
-
if not _SITE_ID_RE.fullmatch(site_id):
|
| 192 |
-
raise FeedbackValidationError("site_id: use a stable ASCII identifier")
|
| 193 |
-
return site_id
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
def normalize_site_id(value: Any) -> str:
|
| 197 |
-
"""Validate and canonicalize a stable public site identifier."""
|
| 198 |
-
return _site_id(value)
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
def _feedback_id(value: Any) -> str:
|
| 202 |
-
if not isinstance(value, str) or not _FEEDBACK_ID_RE.fullmatch(value):
|
| 203 |
-
raise FeedbackValidationError(
|
| 204 |
-
"feedback_id: expected feedback- followed by 48 lowercase hexadecimal characters"
|
| 205 |
-
)
|
| 206 |
-
return value
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
def _rating(value: Any) -> int:
|
| 210 |
-
_value = -5 <= value <= 5 # ruff: ignore[magic-value-comparison]
|
| 211 |
-
if isinstance(value, bool) or not isinstance(value, int) or not _value:
|
| 212 |
-
raise FeedbackValidationError("rating: expected an integer from -5 through +5")
|
| 213 |
-
return value
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
def _mode(value: Any) -> str:
|
| 217 |
-
if value not in {"quick", "detailed"}:
|
| 218 |
-
raise FeedbackValidationError(
|
| 219 |
-
"mode: expected 'quick' or 'detailed'",
|
| 220 |
-
)
|
| 221 |
-
return value
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
def _reject_duplicate_json_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
| 225 |
-
result: dict[str, Any] = {}
|
| 226 |
-
for key, value in pairs:
|
| 227 |
-
if key in result:
|
| 228 |
-
raise FeedbackValidationError(f"duplicate JSON field: {key}")
|
| 229 |
-
result[key] = value
|
| 230 |
-
return result
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
def _reject_nonfinite_json(value: str) -> None:
|
| 234 |
-
raise FeedbackValidationError(f"non-finite JSON number is not allowed: {value}")
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
def decode_feedback_request(raw: bytes | str) -> dict[str, Any]:
|
| 238 |
-
"""
|
| 239 |
-
Decode one wire request with strict JSON semantics, then validate it.
|
| 240 |
-
|
| 241 |
-
Duplicate object keys and non-finite numeric spellings are rejected before
|
| 242 |
-
contract normalization so different JSON parsers cannot disagree about the
|
| 243 |
-
event that an idempotency key represents.
|
| 244 |
-
"""
|
| 245 |
-
if not isinstance(raw, (bytes, str)):
|
| 246 |
-
raise FeedbackValidationError("Feedback body must be JSON text")
|
| 247 |
-
try:
|
| 248 |
-
payload = json.loads(
|
| 249 |
-
raw,
|
| 250 |
-
object_pairs_hook=_reject_duplicate_json_keys,
|
| 251 |
-
parse_constant=_reject_nonfinite_json,
|
| 252 |
-
)
|
| 253 |
-
except FeedbackValidationError:
|
| 254 |
-
raise
|
| 255 |
-
except (
|
| 256 |
-
UnicodeDecodeError,
|
| 257 |
-
json.JSONDecodeError,
|
| 258 |
-
RecursionError,
|
| 259 |
-
ValueError,
|
| 260 |
-
) as exc:
|
| 261 |
-
raise FeedbackValidationError(
|
| 262 |
-
"Invalid JSON",
|
| 263 |
-
code="invalid_json",
|
| 264 |
-
) from exc
|
| 265 |
-
return parse_feedback_request(payload)
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
def decode_feedback_event(raw: bytes | str) -> dict[str, Any]:
|
| 269 |
-
"""Decode one durable event with the same strict JSON semantics as requests."""
|
| 270 |
-
if not isinstance(raw, (bytes, str)):
|
| 271 |
-
raise FeedbackValidationError("Feedback event must be JSON text")
|
| 272 |
-
try:
|
| 273 |
-
payload = json.loads(
|
| 274 |
-
raw,
|
| 275 |
-
object_pairs_hook=_reject_duplicate_json_keys,
|
| 276 |
-
parse_constant=_reject_nonfinite_json,
|
| 277 |
-
)
|
| 278 |
-
except FeedbackValidationError:
|
| 279 |
-
raise
|
| 280 |
-
except (
|
| 281 |
-
UnicodeDecodeError,
|
| 282 |
-
json.JSONDecodeError,
|
| 283 |
-
RecursionError,
|
| 284 |
-
ValueError,
|
| 285 |
-
) as exc:
|
| 286 |
-
raise FeedbackValidationError(
|
| 287 |
-
"Invalid feedback event JSON",
|
| 288 |
-
code="invalid_event_json",
|
| 289 |
-
) from exc
|
| 290 |
-
return parse_feedback_event(payload)
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
def parse_feedback_request(payload: Any) -> dict[str, Any]:
|
| 294 |
-
"""
|
| 295 |
-
Validate and normalize one browser feedback request.
|
| 296 |
-
|
| 297 |
-
Unknown fields are rejected deliberately. This prevents future browser,
|
| 298 |
-
device, account, referrer, IP, or analytics fields from silently becoming
|
| 299 |
-
durable feedback data.
|
| 300 |
-
"""
|
| 301 |
-
if not isinstance(payload, dict):
|
| 302 |
-
raise FeedbackValidationError("Feedback body must be a JSON object")
|
| 303 |
-
_reject_unknown_keys(payload, _ALLOWED_TOP, "feedback")
|
| 304 |
-
|
| 305 |
-
if payload.get("contract") != REQUEST_CONTRACT:
|
| 306 |
-
raise FeedbackValidationError(
|
| 307 |
-
"Unsupported feedback contract", code="unsupported_contract"
|
| 308 |
-
)
|
| 309 |
-
if payload.get("action") != "submit":
|
| 310 |
-
raise FeedbackValidationError("Unsupported feedback action")
|
| 311 |
-
|
| 312 |
-
site_id = normalize_site_id(payload.get("site_id"))
|
| 313 |
-
page_id = normalize_page_id(payload.get("page_id"))
|
| 314 |
-
page_revision = normalize_page_revision(payload.get("page_revision", ""))
|
| 315 |
-
feedback_id = _feedback_id(payload.get("feedback_id"))
|
| 316 |
-
rating = _rating(payload.get("rating"))
|
| 317 |
-
mode = _mode(payload.get("mode"))
|
| 318 |
-
|
| 319 |
-
raw_contributor = payload.get("contributor", {})
|
| 320 |
-
if raw_contributor is None:
|
| 321 |
-
raw_contributor = {}
|
| 322 |
-
if not isinstance(raw_contributor, dict):
|
| 323 |
-
raise FeedbackValidationError("contributor: must be an object")
|
| 324 |
-
_reject_unknown_keys(raw_contributor, _ALLOWED_CONTRIBUTOR, "contributor")
|
| 325 |
-
contributor = _public_credit(raw_contributor.get("display_name", ""))
|
| 326 |
-
if contributor.lower() == "anonymous":
|
| 327 |
-
raise FeedbackValidationError(
|
| 328 |
-
"contributor.display_name: 'Anonymous' is reserved; leave public credit blank instead"
|
| 329 |
-
)
|
| 330 |
-
comment = _comment(payload.get("comment", ""))
|
| 331 |
-
|
| 332 |
-
if mode == "quick":
|
| 333 |
-
if rating not in {-1, 1}:
|
| 334 |
-
raise FeedbackValidationError("quick feedback rating must be -1 or +1")
|
| 335 |
-
if comment or contributor:
|
| 336 |
-
raise FeedbackValidationError(
|
| 337 |
-
"quick feedback must not include comment or contributor content"
|
| 338 |
-
)
|
| 339 |
-
|
| 340 |
-
normalized: dict[str, Any] = {
|
| 341 |
-
"contract": REQUEST_CONTRACT,
|
| 342 |
-
"action": "submit",
|
| 343 |
-
"site_id": site_id,
|
| 344 |
-
"page_id": page_id,
|
| 345 |
-
"feedback_id": feedback_id,
|
| 346 |
-
"rating": rating,
|
| 347 |
-
"mode": mode,
|
| 348 |
-
"contributor": {"display_name": contributor},
|
| 349 |
-
}
|
| 350 |
-
if page_revision:
|
| 351 |
-
normalized["page_revision"] = page_revision
|
| 352 |
-
if comment:
|
| 353 |
-
normalized["comment"] = comment
|
| 354 |
-
return normalized
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
def canonical_feedback_bytes(request: dict[str, Any]) -> bytes:
|
| 358 |
-
"""Return canonical UTF-8 JSON used for request commitments/idempotency."""
|
| 359 |
-
normalized = parse_feedback_request(request)
|
| 360 |
-
encoded = json.dumps(
|
| 361 |
-
normalized,
|
| 362 |
-
ensure_ascii=False,
|
| 363 |
-
sort_keys=True,
|
| 364 |
-
separators=(",", ":"),
|
| 365 |
-
).encode("utf-8")
|
| 366 |
-
if len(encoded) > MAX_REQUEST_BYTES:
|
| 367 |
-
raise FeedbackValidationError(
|
| 368 |
-
f"feedback request exceeds {MAX_REQUEST_BYTES} canonical bytes"
|
| 369 |
-
)
|
| 370 |
-
return encoded
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
def feedback_request_hash(request: dict[str, Any]) -> str:
|
| 374 |
-
"""Return a deterministic commitment to the normalized feedback request."""
|
| 375 |
-
return hashlib.sha256(canonical_feedback_bytes(request)).hexdigest()
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
def build_feedback_event(request: dict[str, Any]) -> dict[str, Any]:
|
| 379 |
-
"""Project a request into the minimal durable feedback event."""
|
| 380 |
-
normalized = parse_feedback_request(request)
|
| 381 |
-
feedback: dict[str, Any] = {
|
| 382 |
-
"id": normalized["feedback_id"],
|
| 383 |
-
"rating": normalized["rating"],
|
| 384 |
-
"mode": normalized["mode"],
|
| 385 |
-
"contributor": normalized["contributor"]["display_name"] or "Anonymous",
|
| 386 |
-
}
|
| 387 |
-
if normalized.get("comment"):
|
| 388 |
-
feedback["comment"] = normalized["comment"]
|
| 389 |
-
event: dict[str, Any] = {
|
| 390 |
-
"contract": EVENT_CONTRACT,
|
| 391 |
-
"site_id": normalized["site_id"],
|
| 392 |
-
"page_id": normalized["page_id"],
|
| 393 |
-
"feedback": feedback,
|
| 394 |
-
}
|
| 395 |
-
if normalized.get("page_revision"):
|
| 396 |
-
event["page_revision"] = normalized["page_revision"]
|
| 397 |
-
return event
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
def feedback_request_from_event(event: dict[str, Any]) -> dict[str, Any]:
|
| 401 |
-
"""
|
| 402 |
-
Reconstruct the unique normalized request represented by a durable event.
|
| 403 |
-
|
| 404 |
-
This inverse projection is intentionally strict. It gives storage/provider
|
| 405 |
-
adapters an independent way to verify that a supplied request commitment
|
| 406 |
-
actually commits to the exact durable event they are about to accept.
|
| 407 |
-
"""
|
| 408 |
-
normalized = parse_feedback_event(event)
|
| 409 |
-
feedback = normalized["feedback"]
|
| 410 |
-
contributor = feedback["contributor"]
|
| 411 |
-
request: dict[str, Any] = {
|
| 412 |
-
"contract": REQUEST_CONTRACT,
|
| 413 |
-
"action": "submit",
|
| 414 |
-
"site_id": normalized["site_id"],
|
| 415 |
-
"page_id": normalized["page_id"],
|
| 416 |
-
"feedback_id": feedback["id"],
|
| 417 |
-
"rating": feedback["rating"],
|
| 418 |
-
"mode": feedback["mode"],
|
| 419 |
-
"contributor": {
|
| 420 |
-
"display_name": "" if contributor == "Anonymous" else contributor
|
| 421 |
-
},
|
| 422 |
-
}
|
| 423 |
-
if normalized.get("page_revision"):
|
| 424 |
-
request["page_revision"] = normalized["page_revision"]
|
| 425 |
-
if feedback.get("comment"):
|
| 426 |
-
request["comment"] = feedback["comment"]
|
| 427 |
-
return parse_feedback_request(request)
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
def feedback_event_request_hash(event: dict[str, Any]) -> str:
|
| 431 |
-
"""Return the request commitment implied by one canonical durable event."""
|
| 432 |
-
return feedback_request_hash(feedback_request_from_event(event))
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
def parse_feedback_event( # ruff: ignore[too-many-branches]
|
| 436 |
-
payload: Any,
|
| 437 |
-
) -> dict[str, Any]:
|
| 438 |
-
"""Validate one durable event and require canonical public text forms."""
|
| 439 |
-
if not isinstance(payload, dict):
|
| 440 |
-
raise FeedbackValidationError("feedback event must be an object")
|
| 441 |
-
_reject_unknown_keys(payload, _ALLOWED_EVENT_TOP, "feedback event")
|
| 442 |
-
if payload.get("contract") != EVENT_CONTRACT:
|
| 443 |
-
raise FeedbackValidationError("Unsupported feedback event contract")
|
| 444 |
-
|
| 445 |
-
site_id = normalize_site_id(payload.get("site_id"))
|
| 446 |
-
page_id = normalize_page_id(payload.get("page_id"))
|
| 447 |
-
page_revision = normalize_page_revision(payload.get("page_revision", ""))
|
| 448 |
-
raw_feedback = payload.get("feedback")
|
| 449 |
-
if not isinstance(raw_feedback, dict):
|
| 450 |
-
raise FeedbackValidationError("feedback event feedback must be an object")
|
| 451 |
-
_reject_unknown_keys(
|
| 452 |
-
raw_feedback,
|
| 453 |
-
_ALLOWED_EVENT_FEEDBACK,
|
| 454 |
-
"feedback event.feedback",
|
| 455 |
-
)
|
| 456 |
-
feedback_id = _feedback_id(raw_feedback.get("id"))
|
| 457 |
-
rating = _rating(raw_feedback.get("rating"))
|
| 458 |
-
mode = _mode(raw_feedback.get("mode"))
|
| 459 |
-
|
| 460 |
-
raw_contributor = raw_feedback.get("contributor")
|
| 461 |
-
if raw_contributor == "Anonymous":
|
| 462 |
-
contributor = "Anonymous"
|
| 463 |
-
else:
|
| 464 |
-
contributor = _public_credit(raw_contributor)
|
| 465 |
-
if contributor.lower() == "anonymous":
|
| 466 |
-
raise FeedbackValidationError(
|
| 467 |
-
"feedback event contributor reserves 'Anonymous' for the anonymity sentinel"
|
| 468 |
-
)
|
| 469 |
-
if not contributor:
|
| 470 |
-
raise FeedbackValidationError(
|
| 471 |
-
"feedback event contributor must be 'Anonymous' or a non-empty public credit"
|
| 472 |
-
)
|
| 473 |
-
if contributor != raw_contributor:
|
| 474 |
-
raise FeedbackValidationError(
|
| 475 |
-
"feedback event contributor must already be canonicalized"
|
| 476 |
-
)
|
| 477 |
-
|
| 478 |
-
comment = _comment(raw_feedback.get("comment", ""))
|
| 479 |
-
if "comment" in raw_feedback and comment != raw_feedback.get("comment"):
|
| 480 |
-
raise FeedbackValidationError(
|
| 481 |
-
"feedback event comment must already be canonicalized",
|
| 482 |
-
)
|
| 483 |
-
if mode == "quick":
|
| 484 |
-
if rating not in {-1, 1}:
|
| 485 |
-
raise FeedbackValidationError(
|
| 486 |
-
"quick feedback rating must be -1 or +1",
|
| 487 |
-
)
|
| 488 |
-
if comment or contributor != "Anonymous":
|
| 489 |
-
raise FeedbackValidationError(
|
| 490 |
-
"quick feedback event must not contain comment or public credit",
|
| 491 |
-
)
|
| 492 |
-
|
| 493 |
-
normalized_feedback: dict[str, Any] = {
|
| 494 |
-
"id": feedback_id,
|
| 495 |
-
"rating": rating,
|
| 496 |
-
"mode": mode,
|
| 497 |
-
"contributor": contributor,
|
| 498 |
-
}
|
| 499 |
-
if comment:
|
| 500 |
-
normalized_feedback["comment"] = comment
|
| 501 |
-
normalized: dict[str, Any] = {
|
| 502 |
-
"contract": EVENT_CONTRACT,
|
| 503 |
-
"site_id": site_id,
|
| 504 |
-
"page_id": page_id,
|
| 505 |
-
"feedback": normalized_feedback,
|
| 506 |
-
}
|
| 507 |
-
if page_revision:
|
| 508 |
-
normalized["page_revision"] = page_revision
|
| 509 |
-
return normalized
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
def canonical_event_bytes(event: dict[str, Any]) -> bytes:
|
| 513 |
-
"""Return deterministic bytes for durable event equality checks."""
|
| 514 |
-
normalized = parse_feedback_event(event)
|
| 515 |
-
return (
|
| 516 |
-
json.dumps(
|
| 517 |
-
normalized,
|
| 518 |
-
ensure_ascii=False,
|
| 519 |
-
sort_keys=True,
|
| 520 |
-
separators=(",", ":"),
|
| 521 |
-
)
|
| 522 |
-
+ "\n"
|
| 523 |
-
).encode("utf-8")
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
def repository_event_bytes(event: dict[str, Any]) -> bytes:
|
| 527 |
-
"""
|
| 528 |
-
Return deterministic human-readable JSON for repository event files.
|
| 529 |
-
|
| 530 |
-
Repository presentation is intentionally separate from
|
| 531 |
-
:func:`canonical_event_bytes`: whitespace must never become feedback-event
|
| 532 |
-
identity or alter request/idempotency commitments.
|
| 533 |
-
"""
|
| 534 |
-
normalized = parse_feedback_event(event)
|
| 535 |
-
return (
|
| 536 |
-
json.dumps(
|
| 537 |
-
normalized,
|
| 538 |
-
ensure_ascii=False,
|
| 539 |
-
indent=2,
|
| 540 |
-
sort_keys=True,
|
| 541 |
-
)
|
| 542 |
-
+ "\n"
|
| 543 |
-
).encode("utf-8")
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
def validate_request_hash(value: Any) -> str:
|
| 547 |
-
"""Validate a lowercase SHA-256 request commitment."""
|
| 548 |
-
if not isinstance(value, str) or not _REQUEST_HASH_RE.fullmatch(value):
|
| 549 |
-
raise FeedbackValidationError(
|
| 550 |
-
"request_hash must be 64 lowercase hexadecimal characters",
|
| 551 |
-
)
|
| 552 |
-
return value
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
def page_digest(site_id: str, page_id: str) -> str:
|
| 556 |
-
"""Return a filesystem-safe opaque page bucket without hiding page_id in JSON."""
|
| 557 |
-
site = normalize_site_id(site_id)
|
| 558 |
-
page = normalize_page_id(page_id)
|
| 559 |
-
raw = f"{site}\0{page}".encode()
|
| 560 |
-
return hashlib.sha256(raw).hexdigest()[:24]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_page_feedback/_service/__init__.py
DELETED
|
@@ -1,20 +0,0 @@
|
|
| 1 |
-
"""Server-only generic page-feedback service API."""
|
| 2 |
-
|
| 3 |
-
from ._config import (
|
| 4 |
-
FeedbackServiceConfig,
|
| 5 |
-
FeedbackServiceConfigError,
|
| 6 |
-
StorageTarget,
|
| 7 |
-
load_service_config,
|
| 8 |
-
parse_storage_targets,
|
| 9 |
-
)
|
| 10 |
-
from ._core import FeedbackServiceUnavailable, PageFeedbackService
|
| 11 |
-
|
| 12 |
-
__all__ = [
|
| 13 |
-
"FeedbackServiceConfig",
|
| 14 |
-
"FeedbackServiceConfigError",
|
| 15 |
-
"FeedbackServiceUnavailable",
|
| 16 |
-
"PageFeedbackService",
|
| 17 |
-
"StorageTarget",
|
| 18 |
-
"load_service_config",
|
| 19 |
-
"parse_storage_targets",
|
| 20 |
-
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_page_feedback/_service/_config.py
DELETED
|
@@ -1,775 +0,0 @@
|
|
| 1 |
-
"""Server-only feedback storage/review policy parsing."""
|
| 2 |
-
|
| 3 |
-
from __future__ import annotations
|
| 4 |
-
|
| 5 |
-
import json
|
| 6 |
-
import os
|
| 7 |
-
import re
|
| 8 |
-
from dataclasses import dataclass
|
| 9 |
-
from pathlib import Path, PurePosixPath
|
| 10 |
-
from typing import Any, Mapping
|
| 11 |
-
|
| 12 |
-
from .._contracts import normalize_page_id, normalize_page_revision, normalize_site_id
|
| 13 |
-
|
| 14 |
-
PROVIDERS = frozenset({"sqlite", "github", "huggingface", "gitlab", "bitbucket"})
|
| 15 |
-
IMPLEMENTED_PROVIDERS = frozenset({"sqlite", "github"})
|
| 16 |
-
DEFAULT_FEEDBACK_STORAGE_TARGETS: tuple = ()
|
| 17 |
-
ROLES = frozenset({"primary", "mirror"})
|
| 18 |
-
REVIEW_MODES = frozenset({"disabled", "sqlite", "provider-pr"})
|
| 19 |
-
PAGE_AUTHORITY_CONTRACT = "page.feedback-authority.v1"
|
| 20 |
-
_MAX_PAGE_AUTHORITY_BYTES = 4 * 1024 * 1024
|
| 21 |
-
_MAX_PAGE_AUTHORITY_SITES = 32
|
| 22 |
-
_MAX_PAGE_AUTHORITY_PAGES_PER_SITE = 250_000
|
| 23 |
-
_ALLOWED_TARGET_KEYS = frozenset(
|
| 24 |
-
{
|
| 25 |
-
"id",
|
| 26 |
-
"label",
|
| 27 |
-
"authority",
|
| 28 |
-
"provider",
|
| 29 |
-
"role",
|
| 30 |
-
"repo",
|
| 31 |
-
"branch",
|
| 32 |
-
"paths",
|
| 33 |
-
"token_env",
|
| 34 |
-
"database",
|
| 35 |
-
"expose_links",
|
| 36 |
-
}
|
| 37 |
-
)
|
| 38 |
-
_ALLOWED_PATH_KEYS = frozenset({"feedback"})
|
| 39 |
-
_REPO_RE = re.compile(r"[A-Za-z0-9_.-]{1,100}/[A-Za-z0-9_.-]{1,100}\Z")
|
| 40 |
-
_ENV_RE = re.compile(r"[A-Z][A-Z0-9_]{0,127}\Z")
|
| 41 |
-
_ALLOWED_EXACT_TOKEN_ENVS = frozenset(
|
| 42 |
-
{
|
| 43 |
-
"GITHUB_TOKEN",
|
| 44 |
-
"HF_TOKEN",
|
| 45 |
-
"GITLAB_TOKEN",
|
| 46 |
-
"BITBUCKET_TOKEN",
|
| 47 |
-
},
|
| 48 |
-
)
|
| 49 |
-
_FEEDBACK_TOKEN_ENV_RE = re.compile(r"FEEDBACK_[A-Z0-9_]*TOKEN[A-Z0-9_]*\Z")
|
| 50 |
-
_ALLOWED_TOKEN_PREFIXES = ("AI_RECORD_STORAGE_TOKEN_",)
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
class FeedbackServiceConfigError(ValueError):
|
| 54 |
-
pass
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
def _reject_duplicate_json_keys(pairs):
|
| 58 |
-
result = {}
|
| 59 |
-
for key, value in pairs:
|
| 60 |
-
if key in result:
|
| 61 |
-
raise FeedbackServiceConfigError(
|
| 62 |
-
f"duplicate JSON field in feedback configuration: {key}",
|
| 63 |
-
)
|
| 64 |
-
result[key] = value
|
| 65 |
-
return result
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
def _reject_nonfinite_json(value: str) -> None:
|
| 69 |
-
raise FeedbackServiceConfigError(
|
| 70 |
-
f"non-finite JSON number is not allowed in feedback configuration: {value}"
|
| 71 |
-
)
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
@dataclass(frozen=True)
|
| 75 |
-
class StorageTarget:
|
| 76 |
-
id: str
|
| 77 |
-
label: str
|
| 78 |
-
provider: str
|
| 79 |
-
role: str
|
| 80 |
-
authority: str = "feedback"
|
| 81 |
-
repo: str = ""
|
| 82 |
-
branch: str = "main"
|
| 83 |
-
feedback_path: str = "feedback"
|
| 84 |
-
token_env: tuple[str, ...] = ()
|
| 85 |
-
database: str = ""
|
| 86 |
-
expose_links: bool = False
|
| 87 |
-
|
| 88 |
-
def resolve_token(self, env: Mapping[str, str] = os.environ) -> tuple[str, str]:
|
| 89 |
-
for name in self.token_env:
|
| 90 |
-
raw_value = env.get(name, "")
|
| 91 |
-
if raw_value in (None, ""):
|
| 92 |
-
continue
|
| 93 |
-
if not isinstance(raw_value, str):
|
| 94 |
-
raise FeedbackServiceConfigError(
|
| 95 |
-
f"feedback credential value in {name!r} must be a string"
|
| 96 |
-
)
|
| 97 |
-
value = raw_value
|
| 98 |
-
_len = len(value) > 4096 # ruff: ignore[magic-value-comparison]
|
| 99 |
-
if _len or any(
|
| 100 |
-
not 33 <= ord(ch) <= 126 # ruff: ignore[magic-value-comparison]
|
| 101 |
-
for ch in value
|
| 102 |
-
):
|
| 103 |
-
raise FeedbackServiceConfigError(
|
| 104 |
-
f"feedback credential value in {name!r} is invalid"
|
| 105 |
-
)
|
| 106 |
-
return value, name
|
| 107 |
-
return "", ""
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
@dataclass(frozen=True)
|
| 111 |
-
class FeedbackServiceConfig:
|
| 112 |
-
review_mode: str
|
| 113 |
-
targets: tuple[StorageTarget, ...]
|
| 114 |
-
allowed_site_ids: tuple[str, ...]
|
| 115 |
-
page_authority: tuple[tuple[str, str, str], ...] | None
|
| 116 |
-
max_body_bytes: int
|
| 117 |
-
rate_limit_per_hour: int
|
| 118 |
-
mirror_timeout_seconds: float
|
| 119 |
-
|
| 120 |
-
@property
|
| 121 |
-
def primary(self) -> StorageTarget | None:
|
| 122 |
-
return next(
|
| 123 |
-
(target for target in self.targets if target.role == "primary"),
|
| 124 |
-
None,
|
| 125 |
-
)
|
| 126 |
-
|
| 127 |
-
@property
|
| 128 |
-
def mirrors(self) -> tuple[StorageTarget, ...]:
|
| 129 |
-
return tuple(target for target in self.targets if target.role == "mirror")
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
def _token_envs(value: Any, provider: str) -> tuple[str, ...]:
|
| 133 |
-
if value in (None, ""):
|
| 134 |
-
defaults = {
|
| 135 |
-
"github": (
|
| 136 |
-
"FEEDBACK_GITHUB_TOKEN",
|
| 137 |
-
"GITHUB_TOKEN",
|
| 138 |
-
"AI_RECORD_STORAGE_TOKEN_GITHUB_MIRROR",
|
| 139 |
-
),
|
| 140 |
-
"huggingface": (
|
| 141 |
-
"FEEDBACK_HF_TOKEN",
|
| 142 |
-
"HF_TOKEN",
|
| 143 |
-
"AI_RECORD_STORAGE_TOKEN_HF_PRIMARY",
|
| 144 |
-
),
|
| 145 |
-
"gitlab": (
|
| 146 |
-
"FEEDBACK_GITLAB_TOKEN",
|
| 147 |
-
"GITLAB_TOKEN",
|
| 148 |
-
),
|
| 149 |
-
"bitbucket": (
|
| 150 |
-
"FEEDBACK_BITBUCKET_TOKEN",
|
| 151 |
-
"BITBUCKET_TOKEN",
|
| 152 |
-
),
|
| 153 |
-
"sqlite": (),
|
| 154 |
-
}
|
| 155 |
-
return defaults[provider]
|
| 156 |
-
if isinstance(value, str):
|
| 157 |
-
values = (value,)
|
| 158 |
-
elif isinstance(value, (list, tuple)):
|
| 159 |
-
values = tuple(value)
|
| 160 |
-
else:
|
| 161 |
-
values = ()
|
| 162 |
-
if not values and value not in ([], ()):
|
| 163 |
-
raise FeedbackServiceConfigError(
|
| 164 |
-
"token_env must be a string or list of environment-variable names",
|
| 165 |
-
)
|
| 166 |
-
if provider != "sqlite" and not values:
|
| 167 |
-
raise FeedbackServiceConfigError(
|
| 168 |
-
f"{provider} storage target requires at least one token_env credential alias",
|
| 169 |
-
)
|
| 170 |
-
if len(values) > 8: # ruff: ignore[magic-value-comparison]
|
| 171 |
-
raise FeedbackServiceConfigError(
|
| 172 |
-
"token_env contains too many credential aliases",
|
| 173 |
-
)
|
| 174 |
-
result = []
|
| 175 |
-
for item in values:
|
| 176 |
-
if not isinstance(item, str):
|
| 177 |
-
raise FeedbackServiceConfigError(
|
| 178 |
-
"token_env entries must be environment-variable name strings",
|
| 179 |
-
)
|
| 180 |
-
name = item.strip()
|
| 181 |
-
if not _ENV_RE.fullmatch(name):
|
| 182 |
-
raise FeedbackServiceConfigError(
|
| 183 |
-
"token_env contains an invalid environment-variable name",
|
| 184 |
-
)
|
| 185 |
-
if (
|
| 186 |
-
name not in _ALLOWED_EXACT_TOKEN_ENVS
|
| 187 |
-
and not _FEEDBACK_TOKEN_ENV_RE.fullmatch(name)
|
| 188 |
-
and not name.startswith(_ALLOWED_TOKEN_PREFIXES)
|
| 189 |
-
):
|
| 190 |
-
raise FeedbackServiceConfigError(
|
| 191 |
-
f"token_env {name!r} is outside the feedback credential allowlist",
|
| 192 |
-
)
|
| 193 |
-
if name not in result:
|
| 194 |
-
result.append(name)
|
| 195 |
-
return tuple(result)
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
def _target_string(
|
| 199 |
-
raw: dict[str, Any], key: str, *, default: str = "", allow_empty: bool = True
|
| 200 |
-
) -> str:
|
| 201 |
-
value = raw.get(key)
|
| 202 |
-
if value is None:
|
| 203 |
-
value = default
|
| 204 |
-
if not isinstance(value, str):
|
| 205 |
-
raise FeedbackServiceConfigError(f"storage target {key} must be a string")
|
| 206 |
-
try:
|
| 207 |
-
value.encode("utf-8", "strict")
|
| 208 |
-
except UnicodeError as exc:
|
| 209 |
-
raise FeedbackServiceConfigError(
|
| 210 |
-
f"storage target {key} contains invalid Unicode"
|
| 211 |
-
) from exc
|
| 212 |
-
text = value.strip()
|
| 213 |
-
if not allow_empty and not text:
|
| 214 |
-
raise FeedbackServiceConfigError(f"storage target {key} must not be empty")
|
| 215 |
-
return text
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
def _safe_feedback_path(value: Any) -> str:
|
| 219 |
-
if not isinstance(value, str):
|
| 220 |
-
raise FeedbackServiceConfigError("feedback path must be a string")
|
| 221 |
-
try:
|
| 222 |
-
value.encode("utf-8", "strict")
|
| 223 |
-
except UnicodeError as exc:
|
| 224 |
-
raise FeedbackServiceConfigError(
|
| 225 |
-
"feedback path contains invalid Unicode",
|
| 226 |
-
) from exc
|
| 227 |
-
text = value.strip()
|
| 228 |
-
if (
|
| 229 |
-
not text
|
| 230 |
-
or len(text) > 512 # ruff: ignore[magic-value-comparison]
|
| 231 |
-
or text.startswith("/")
|
| 232 |
-
or text.endswith("/")
|
| 233 |
-
or "\\" in text
|
| 234 |
-
or any(
|
| 235 |
-
ord(ch) < 32 or ord(ch) == 127 # ruff: ignore[magic-value-comparison]
|
| 236 |
-
for ch in text
|
| 237 |
-
)
|
| 238 |
-
):
|
| 239 |
-
raise FeedbackServiceConfigError("feedback path is invalid")
|
| 240 |
-
path = PurePosixPath(text)
|
| 241 |
-
if (
|
| 242 |
-
path.is_absolute()
|
| 243 |
-
or any(part in {"", ".", ".."} for part in path.parts)
|
| 244 |
-
or str(path) != text
|
| 245 |
-
):
|
| 246 |
-
raise FeedbackServiceConfigError(
|
| 247 |
-
"feedback path must be a canonical safe relative POSIX path"
|
| 248 |
-
)
|
| 249 |
-
return text
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
def _parse_target( # ruff: ignore[too-many-branches]
|
| 253 |
-
raw: Any,
|
| 254 |
-
) -> StorageTarget:
|
| 255 |
-
if not isinstance(raw, dict):
|
| 256 |
-
raise FeedbackServiceConfigError(
|
| 257 |
-
"each feedback storage target must be an object",
|
| 258 |
-
)
|
| 259 |
-
forbidden = {
|
| 260 |
-
"token",
|
| 261 |
-
"secret",
|
| 262 |
-
"password",
|
| 263 |
-
"authorization",
|
| 264 |
-
} & set(raw)
|
| 265 |
-
if forbidden:
|
| 266 |
-
raise FeedbackServiceConfigError(
|
| 267 |
-
"storage targets may name token_env values but may not contain literal secrets",
|
| 268 |
-
)
|
| 269 |
-
unknown = sorted(set(raw) - _ALLOWED_TARGET_KEYS)
|
| 270 |
-
if unknown:
|
| 271 |
-
raise FeedbackServiceConfigError(
|
| 272 |
-
(
|
| 273 |
-
"feedback storage target contains unsupported field(s): "
|
| 274 |
-
+ ", ".join(unknown)
|
| 275 |
-
),
|
| 276 |
-
)
|
| 277 |
-
target_id = _target_string(raw, "id", allow_empty=False)
|
| 278 |
-
if not re.fullmatch(r"[a-z0-9][a-z0-9._-]{0,63}", target_id):
|
| 279 |
-
raise FeedbackServiceConfigError(
|
| 280 |
-
"storage target id must be a lowercase stable identifier",
|
| 281 |
-
)
|
| 282 |
-
authority = _target_string(
|
| 283 |
-
raw,
|
| 284 |
-
"authority",
|
| 285 |
-
default="feedback",
|
| 286 |
-
allow_empty=False,
|
| 287 |
-
).lower()
|
| 288 |
-
if authority != "feedback":
|
| 289 |
-
raise FeedbackServiceConfigError(
|
| 290 |
-
"storage target authority must be 'feedback'",
|
| 291 |
-
)
|
| 292 |
-
provider = _target_string(raw, "provider", allow_empty=False).lower()
|
| 293 |
-
role = _target_string(raw, "role", allow_empty=False).lower()
|
| 294 |
-
if provider not in PROVIDERS:
|
| 295 |
-
raise FeedbackServiceConfigError(
|
| 296 |
-
f"unsupported feedback storage provider {provider!r}",
|
| 297 |
-
)
|
| 298 |
-
if role not in ROLES:
|
| 299 |
-
raise FeedbackServiceConfigError(
|
| 300 |
-
"storage target role must be primary or mirror",
|
| 301 |
-
)
|
| 302 |
-
label = _target_string(raw, "label", default=target_id, allow_empty=False)
|
| 303 |
-
_len = len(label) > 120 # ruff: ignore[magic-value-comparison]
|
| 304 |
-
if _len or any(
|
| 305 |
-
ord(ch) < 32 or ord(ch) == 127 # ruff: ignore[magic-value-comparison]
|
| 306 |
-
for ch in label
|
| 307 |
-
):
|
| 308 |
-
raise FeedbackServiceConfigError(
|
| 309 |
-
"storage target label is invalid",
|
| 310 |
-
)
|
| 311 |
-
repo = _target_string(raw, "repo")
|
| 312 |
-
if provider != "sqlite":
|
| 313 |
-
if not _REPO_RE.fullmatch(repo):
|
| 314 |
-
raise FeedbackServiceConfigError(
|
| 315 |
-
f"{provider} storage target requires owner/repo",
|
| 316 |
-
)
|
| 317 |
-
owner, repository = repo.split("/", 1)
|
| 318 |
-
if owner in {".", ".."} or repository in {".", ".."}:
|
| 319 |
-
raise FeedbackServiceConfigError(
|
| 320 |
-
f"{provider} storage target requires a canonical owner/repo",
|
| 321 |
-
)
|
| 322 |
-
branch = _target_string(
|
| 323 |
-
raw,
|
| 324 |
-
"branch",
|
| 325 |
-
default="main",
|
| 326 |
-
allow_empty=False,
|
| 327 |
-
)
|
| 328 |
-
if (
|
| 329 |
-
len(branch) > 128 # ruff: ignore[magic-value-comparison]
|
| 330 |
-
or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._/-]{0,127}", branch)
|
| 331 |
-
or ".." in branch
|
| 332 |
-
or "//" in branch
|
| 333 |
-
or branch.endswith(("/", ".", ".lock"))
|
| 334 |
-
or any(
|
| 335 |
-
part in {"", ".", ".."} or part.endswith(".lock")
|
| 336 |
-
for part in branch.split("/")
|
| 337 |
-
)
|
| 338 |
-
):
|
| 339 |
-
raise FeedbackServiceConfigError(
|
| 340 |
-
"storage target branch is invalid",
|
| 341 |
-
)
|
| 342 |
-
if provider == "github" and branch == "feedback":
|
| 343 |
-
raise FeedbackServiceConfigError(
|
| 344 |
-
"GitHub base branch 'feedback' conflicts with the deterministic feedback/<nonce> review namespace",
|
| 345 |
-
)
|
| 346 |
-
paths = raw.get("paths", None)
|
| 347 |
-
if paths is None:
|
| 348 |
-
paths = {}
|
| 349 |
-
if not isinstance(paths, dict):
|
| 350 |
-
raise FeedbackServiceConfigError(
|
| 351 |
-
"storage target paths must be an object",
|
| 352 |
-
)
|
| 353 |
-
unknown_paths = sorted(set(paths) - _ALLOWED_PATH_KEYS)
|
| 354 |
-
if unknown_paths:
|
| 355 |
-
raise FeedbackServiceConfigError(
|
| 356 |
-
(
|
| 357 |
-
"storage target paths contains unsupported field(s): "
|
| 358 |
-
+ ", ".join(unknown_paths)
|
| 359 |
-
),
|
| 360 |
-
)
|
| 361 |
-
feedback_path = _safe_feedback_path(paths.get("feedback", "feedback"))
|
| 362 |
-
database = _target_string(raw, "database")
|
| 363 |
-
if len(database) > 1024 or any( # ruff: ignore[magic-value-comparison]
|
| 364 |
-
ord(ch) < 32 or ord(ch) == 127 # ruff: ignore[magic-value-comparison]
|
| 365 |
-
for ch in database
|
| 366 |
-
):
|
| 367 |
-
raise FeedbackServiceConfigError(
|
| 368 |
-
"sqlite database path is invalid",
|
| 369 |
-
)
|
| 370 |
-
if provider == "sqlite" and not database:
|
| 371 |
-
database = "feedback.sqlite3"
|
| 372 |
-
if provider == "sqlite" and database == ":memory:":
|
| 373 |
-
raise FeedbackServiceConfigError(
|
| 374 |
-
"sqlite :memory: is unsupported because feedback opens bounded independent connections",
|
| 375 |
-
)
|
| 376 |
-
if provider != "sqlite" and database:
|
| 377 |
-
raise FeedbackServiceConfigError(
|
| 378 |
-
"database is only valid for sqlite targets",
|
| 379 |
-
)
|
| 380 |
-
if provider == "sqlite":
|
| 381 |
-
if repo:
|
| 382 |
-
raise FeedbackServiceConfigError(
|
| 383 |
-
"repo is not valid for sqlite targets",
|
| 384 |
-
)
|
| 385 |
-
if "branch" in raw:
|
| 386 |
-
raise FeedbackServiceConfigError(
|
| 387 |
-
"branch is not valid for sqlite targets",
|
| 388 |
-
)
|
| 389 |
-
if "paths" in raw:
|
| 390 |
-
raise FeedbackServiceConfigError(
|
| 391 |
-
"paths is not valid for sqlite targets",
|
| 392 |
-
)
|
| 393 |
-
if raw.get("token_env") not in (None, "", [], ()):
|
| 394 |
-
raise FeedbackServiceConfigError(
|
| 395 |
-
"token_env is not valid for sqlite targets",
|
| 396 |
-
)
|
| 397 |
-
if raw.get("expose_links", False) not in (False, None):
|
| 398 |
-
raise FeedbackServiceConfigError(
|
| 399 |
-
"expose_links is not valid for sqlite targets",
|
| 400 |
-
)
|
| 401 |
-
token_env = _token_envs(raw.get("token_env"), provider)
|
| 402 |
-
expose_links = raw.get("expose_links", False)
|
| 403 |
-
if not isinstance(expose_links, bool):
|
| 404 |
-
raise FeedbackServiceConfigError(
|
| 405 |
-
"storage target expose_links must be a boolean",
|
| 406 |
-
)
|
| 407 |
-
return StorageTarget(
|
| 408 |
-
id=target_id,
|
| 409 |
-
label=label,
|
| 410 |
-
provider=provider,
|
| 411 |
-
role=role,
|
| 412 |
-
authority=authority,
|
| 413 |
-
repo=repo,
|
| 414 |
-
branch=branch,
|
| 415 |
-
feedback_path=feedback_path,
|
| 416 |
-
token_env=token_env,
|
| 417 |
-
database=database,
|
| 418 |
-
expose_links=expose_links,
|
| 419 |
-
)
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
def parse_storage_targets(raw: Any) -> tuple[StorageTarget, ...]:
|
| 423 |
-
if raw in (None, "", []):
|
| 424 |
-
return ()
|
| 425 |
-
if isinstance(raw, str):
|
| 426 |
-
try:
|
| 427 |
-
raw_size = len(raw.encode("utf-8", "strict"))
|
| 428 |
-
except UnicodeError as exc:
|
| 429 |
-
raise FeedbackServiceConfigError(
|
| 430 |
-
"FEEDBACK_STORAGE_TARGETS contains invalid Unicode"
|
| 431 |
-
) from exc
|
| 432 |
-
if raw_size > 64 * 1024:
|
| 433 |
-
raise FeedbackServiceConfigError(
|
| 434 |
-
"FEEDBACK_STORAGE_TARGETS exceeds the 64 KiB safety limit"
|
| 435 |
-
)
|
| 436 |
-
try:
|
| 437 |
-
raw = json.loads(
|
| 438 |
-
raw,
|
| 439 |
-
object_pairs_hook=_reject_duplicate_json_keys,
|
| 440 |
-
parse_constant=_reject_nonfinite_json,
|
| 441 |
-
)
|
| 442 |
-
except FeedbackServiceConfigError:
|
| 443 |
-
raise
|
| 444 |
-
except (json.JSONDecodeError, ValueError, RecursionError) as exc:
|
| 445 |
-
raise FeedbackServiceConfigError(
|
| 446 |
-
"FEEDBACK_STORAGE_TARGETS is not valid JSON",
|
| 447 |
-
) from exc
|
| 448 |
-
if raw == []:
|
| 449 |
-
return ()
|
| 450 |
-
_len = len(raw) > 5 # ruff: ignore[magic-value-comparison]
|
| 451 |
-
if not isinstance(raw, list) or _len:
|
| 452 |
-
raise FeedbackServiceConfigError(
|
| 453 |
-
"feedback storage targets must be a list with at most five entries",
|
| 454 |
-
)
|
| 455 |
-
targets = tuple(_parse_target(item) for item in raw)
|
| 456 |
-
if len({target.id for target in targets}) != len(targets):
|
| 457 |
-
raise FeedbackServiceConfigError(
|
| 458 |
-
"feedback storage target ids must be unique",
|
| 459 |
-
)
|
| 460 |
-
primaries = [target for target in targets if target.role == "primary"]
|
| 461 |
-
if len(primaries) != 1:
|
| 462 |
-
raise FeedbackServiceConfigError(
|
| 463 |
-
"feedback storage topology requires exactly one primary target",
|
| 464 |
-
)
|
| 465 |
-
return targets
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
def _bounded_int(value: Any, default: int, low: int, high: int, *, name: str) -> int:
|
| 469 |
-
if value in (None, ""):
|
| 470 |
-
return default
|
| 471 |
-
if isinstance(value, bool):
|
| 472 |
-
raise FeedbackServiceConfigError(f"{name} must be an integer")
|
| 473 |
-
try:
|
| 474 |
-
parsed = int(value)
|
| 475 |
-
except (TypeError, ValueError) as exc:
|
| 476 |
-
raise FeedbackServiceConfigError(f"{name} must be an integer") from exc
|
| 477 |
-
if not low <= parsed <= high:
|
| 478 |
-
raise FeedbackServiceConfigError(f"{name} must be between {low} and {high}")
|
| 479 |
-
return parsed
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
def _bounded_float(
|
| 483 |
-
value: Any, default: float, low: float, high: float, *, name: str
|
| 484 |
-
) -> float:
|
| 485 |
-
if value in (None, ""):
|
| 486 |
-
return default
|
| 487 |
-
if isinstance(value, bool):
|
| 488 |
-
raise FeedbackServiceConfigError(f"{name} must be a number")
|
| 489 |
-
try:
|
| 490 |
-
parsed = float(value)
|
| 491 |
-
except (TypeError, ValueError) as exc:
|
| 492 |
-
raise FeedbackServiceConfigError(f"{name} must be a number") from exc
|
| 493 |
-
if not low <= parsed <= high:
|
| 494 |
-
raise FeedbackServiceConfigError(f"{name} must be between {low} and {high}")
|
| 495 |
-
return parsed
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
def _load_page_authority( # ruff: ignore[too-many-branches]
|
| 499 |
-
value: Any,
|
| 500 |
-
) -> tuple[tuple[str, str, str], ...] | None:
|
| 501 |
-
"""Load an optional bounded server-side page/revision authority manifest."""
|
| 502 |
-
if value in (None, ""):
|
| 503 |
-
return None
|
| 504 |
-
if not isinstance(value, str):
|
| 505 |
-
raise FeedbackServiceConfigError(
|
| 506 |
-
"FEEDBACK_PAGE_AUTHORITY_FILE must be a filesystem path string"
|
| 507 |
-
)
|
| 508 |
-
try:
|
| 509 |
-
value.encode("utf-8", "strict")
|
| 510 |
-
except UnicodeError as exc:
|
| 511 |
-
raise FeedbackServiceConfigError(
|
| 512 |
-
"FEEDBACK_PAGE_AUTHORITY_FILE contains invalid Unicode"
|
| 513 |
-
) from exc
|
| 514 |
-
path_text = value.strip()
|
| 515 |
-
_len = len(path_text) > 2048 # ruff: ignore[magic-value-comparison]
|
| 516 |
-
if (
|
| 517 |
-
not path_text
|
| 518 |
-
or _len
|
| 519 |
-
or any(
|
| 520 |
-
ord(ch) < 32 or ord(ch) == 127 # ruff: ignore[magic-value-comparison]
|
| 521 |
-
for ch in path_text
|
| 522 |
-
)
|
| 523 |
-
):
|
| 524 |
-
raise FeedbackServiceConfigError(
|
| 525 |
-
"FEEDBACK_PAGE_AUTHORITY_FILE path is invalid",
|
| 526 |
-
)
|
| 527 |
-
path = Path(path_text)
|
| 528 |
-
try:
|
| 529 |
-
data = path.read_bytes()
|
| 530 |
-
except OSError as exc:
|
| 531 |
-
raise FeedbackServiceConfigError(
|
| 532 |
-
"unable to read FEEDBACK_PAGE_AUTHORITY_FILE"
|
| 533 |
-
) from exc
|
| 534 |
-
if len(data) > _MAX_PAGE_AUTHORITY_BYTES:
|
| 535 |
-
raise FeedbackServiceConfigError(
|
| 536 |
-
"FEEDBACK_PAGE_AUTHORITY_FILE exceeds the 4 MiB safety limit"
|
| 537 |
-
)
|
| 538 |
-
try:
|
| 539 |
-
payload = json.loads(
|
| 540 |
-
data,
|
| 541 |
-
object_pairs_hook=_reject_duplicate_json_keys,
|
| 542 |
-
parse_constant=_reject_nonfinite_json,
|
| 543 |
-
)
|
| 544 |
-
except FeedbackServiceConfigError:
|
| 545 |
-
raise
|
| 546 |
-
except (
|
| 547 |
-
UnicodeDecodeError,
|
| 548 |
-
json.JSONDecodeError,
|
| 549 |
-
ValueError,
|
| 550 |
-
RecursionError,
|
| 551 |
-
) as exc:
|
| 552 |
-
raise FeedbackServiceConfigError(
|
| 553 |
-
"FEEDBACK_PAGE_AUTHORITY_FILE is not valid UTF-8 JSON",
|
| 554 |
-
) from exc
|
| 555 |
-
if (
|
| 556 |
-
not isinstance(payload, dict)
|
| 557 |
-
or payload.get("contract") != PAGE_AUTHORITY_CONTRACT
|
| 558 |
-
):
|
| 559 |
-
raise FeedbackServiceConfigError(
|
| 560 |
-
"FEEDBACK_PAGE_AUTHORITY_FILE has an unsupported contract",
|
| 561 |
-
)
|
| 562 |
-
unknown = sorted(set(payload) - {"contract", "sites"})
|
| 563 |
-
if unknown:
|
| 564 |
-
raise FeedbackServiceConfigError(
|
| 565 |
-
(
|
| 566 |
-
"feedback page authority contains unsupported field(s): "
|
| 567 |
-
+ ", ".join(unknown)
|
| 568 |
-
),
|
| 569 |
-
)
|
| 570 |
-
sites = payload.get("sites")
|
| 571 |
-
if not isinstance(sites, dict) or len(sites) > _MAX_PAGE_AUTHORITY_SITES:
|
| 572 |
-
raise FeedbackServiceConfigError(
|
| 573 |
-
"feedback page authority sites must be a bounded object",
|
| 574 |
-
)
|
| 575 |
-
rows: list[tuple[str, str, str]] = []
|
| 576 |
-
for raw_site_id, pages in sites.items():
|
| 577 |
-
try:
|
| 578 |
-
site_id = normalize_site_id(raw_site_id)
|
| 579 |
-
except ValueError as exc:
|
| 580 |
-
raise FeedbackServiceConfigError(
|
| 581 |
-
"feedback page authority contains an invalid site_id",
|
| 582 |
-
) from exc
|
| 583 |
-
if site_id != raw_site_id:
|
| 584 |
-
raise FeedbackServiceConfigError(
|
| 585 |
-
"feedback page authority site_id must already be canonicalized",
|
| 586 |
-
)
|
| 587 |
-
if (
|
| 588 |
-
not isinstance(pages, dict)
|
| 589 |
-
or len(pages) > _MAX_PAGE_AUTHORITY_PAGES_PER_SITE
|
| 590 |
-
):
|
| 591 |
-
raise FeedbackServiceConfigError(
|
| 592 |
-
"feedback page authority pages must be a bounded object",
|
| 593 |
-
)
|
| 594 |
-
for raw_page_id, raw_revision in pages.items():
|
| 595 |
-
try:
|
| 596 |
-
page_id = normalize_page_id(raw_page_id)
|
| 597 |
-
revision = normalize_page_revision(raw_revision)
|
| 598 |
-
except ValueError as exc:
|
| 599 |
-
raise FeedbackServiceConfigError(
|
| 600 |
-
"feedback page authority contains an invalid page or revision"
|
| 601 |
-
) from exc
|
| 602 |
-
if page_id != raw_page_id or revision != raw_revision:
|
| 603 |
-
raise FeedbackServiceConfigError(
|
| 604 |
-
"feedback page authority values must already be canonicalized"
|
| 605 |
-
)
|
| 606 |
-
rows.append((site_id, page_id, revision))
|
| 607 |
-
return tuple(rows)
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
def _allowed_site_ids(value: Any) -> tuple[str, ...]:
|
| 611 |
-
"""
|
| 612 |
-
Parse an optional exact server-side site allowlist.
|
| 613 |
-
|
| 614 |
-
The browser-provided ``site_id`` is descriptive event data, not authority.
|
| 615 |
-
Deployments can bind one feedback service to one or more known site IDs with
|
| 616 |
-
``FEEDBACK_ALLOWED_SITE_IDS``. Blank preserves generic multi-site compatibility.
|
| 617 |
-
"""
|
| 618 |
-
if value in (None, ""):
|
| 619 |
-
return ()
|
| 620 |
-
if not isinstance(value, str):
|
| 621 |
-
raise FeedbackServiceConfigError(
|
| 622 |
-
"FEEDBACK_ALLOWED_SITE_IDS must be a comma-separated string"
|
| 623 |
-
)
|
| 624 |
-
try:
|
| 625 |
-
raw_size = len(value.encode("utf-8", "strict"))
|
| 626 |
-
except UnicodeError as exc:
|
| 627 |
-
raise FeedbackServiceConfigError(
|
| 628 |
-
"FEEDBACK_ALLOWED_SITE_IDS contains invalid Unicode"
|
| 629 |
-
) from exc
|
| 630 |
-
if raw_size > 4096: # ruff: ignore[magic-value-comparison]
|
| 631 |
-
raise FeedbackServiceConfigError(
|
| 632 |
-
"FEEDBACK_ALLOWED_SITE_IDS exceeds the 4 KiB safety limit"
|
| 633 |
-
)
|
| 634 |
-
parts = [part.strip() for part in value.split(",")]
|
| 635 |
-
if any(not part for part in parts):
|
| 636 |
-
raise FeedbackServiceConfigError(
|
| 637 |
-
"FEEDBACK_ALLOWED_SITE_IDS contains an empty site identifier"
|
| 638 |
-
)
|
| 639 |
-
if len(parts) > 32: # ruff: ignore[magic-value-comparison]
|
| 640 |
-
raise FeedbackServiceConfigError(
|
| 641 |
-
"FEEDBACK_ALLOWED_SITE_IDS contains too many site identifiers"
|
| 642 |
-
)
|
| 643 |
-
result: list[str] = []
|
| 644 |
-
for part in parts:
|
| 645 |
-
try:
|
| 646 |
-
site_id = normalize_site_id(part)
|
| 647 |
-
except ValueError as exc:
|
| 648 |
-
raise FeedbackServiceConfigError(
|
| 649 |
-
"FEEDBACK_ALLOWED_SITE_IDS contains an invalid site identifier"
|
| 650 |
-
) from exc
|
| 651 |
-
if site_id not in result:
|
| 652 |
-
result.append(site_id)
|
| 653 |
-
return tuple(result)
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
def load_service_config(
|
| 657 |
-
env: Mapping[str, str] = os.environ,
|
| 658 |
-
) -> FeedbackServiceConfig:
|
| 659 |
-
mode = (
|
| 660 |
-
str(
|
| 661 |
-
env.get("FEEDBACK_REVIEW_MODE", "disabled") or "disabled",
|
| 662 |
-
)
|
| 663 |
-
.strip()
|
| 664 |
-
.lower()
|
| 665 |
-
)
|
| 666 |
-
if mode not in REVIEW_MODES:
|
| 667 |
-
raise FeedbackServiceConfigError(
|
| 668 |
-
f"FEEDBACK_REVIEW_MODE must be one of {sorted(REVIEW_MODES)}",
|
| 669 |
-
)
|
| 670 |
-
raw_targets = env.get("FEEDBACK_STORAGE_TARGETS", "")
|
| 671 |
-
if raw_targets:
|
| 672 |
-
targets = parse_storage_targets(raw_targets)
|
| 673 |
-
elif mode == "sqlite":
|
| 674 |
-
targets = parse_storage_targets(
|
| 675 |
-
[
|
| 676 |
-
{
|
| 677 |
-
"id": "sqlite-primary",
|
| 678 |
-
"label": "SQLite Feedback",
|
| 679 |
-
"authority": "feedback",
|
| 680 |
-
"provider": "sqlite",
|
| 681 |
-
"role": "primary",
|
| 682 |
-
"database": (
|
| 683 |
-
str(
|
| 684 |
-
env.get("FEEDBACK_SQLITE_PATH", "feedback.sqlite3")
|
| 685 |
-
or "feedback.sqlite3"
|
| 686 |
-
).strip()
|
| 687 |
-
),
|
| 688 |
-
}
|
| 689 |
-
]
|
| 690 |
-
)
|
| 691 |
-
elif (
|
| 692 |
-
mode == "provider-pr"
|
| 693 |
-
and str(env.get("FEEDBACK_GITHUB_REPOSITORY", "") or "").strip()
|
| 694 |
-
):
|
| 695 |
-
targets = parse_storage_targets(
|
| 696 |
-
[
|
| 697 |
-
{
|
| 698 |
-
"id": "github-primary",
|
| 699 |
-
"label": "GitHub Feedback Review",
|
| 700 |
-
"authority": "feedback",
|
| 701 |
-
"provider": "github",
|
| 702 |
-
"role": "primary",
|
| 703 |
-
"repo": str(env["FEEDBACK_GITHUB_REPOSITORY"]).strip(),
|
| 704 |
-
"branch": (
|
| 705 |
-
str(
|
| 706 |
-
env.get("FEEDBACK_GITHUB_DEFAULT_BRANCH", "main") or "main",
|
| 707 |
-
).strip()
|
| 708 |
-
),
|
| 709 |
-
"paths": {
|
| 710 |
-
"feedback": env.get("FEEDBACK_GITHUB_PATH", "feedback"),
|
| 711 |
-
},
|
| 712 |
-
"token_env": None,
|
| 713 |
-
"expose_links": False,
|
| 714 |
-
}
|
| 715 |
-
]
|
| 716 |
-
)
|
| 717 |
-
else:
|
| 718 |
-
targets = DEFAULT_FEEDBACK_STORAGE_TARGETS
|
| 719 |
-
if mode == "disabled" and targets:
|
| 720 |
-
raise FeedbackServiceConfigError(
|
| 721 |
-
"feedback storage targets must be empty when FEEDBACK_REVIEW_MODE=disabled"
|
| 722 |
-
)
|
| 723 |
-
if mode != "disabled" and not targets:
|
| 724 |
-
raise FeedbackServiceConfigError(
|
| 725 |
-
"enabled feedback review mode requires one primary storage target"
|
| 726 |
-
)
|
| 727 |
-
primary = next((target for target in targets if target.role == "primary"), None)
|
| 728 |
-
if mode == "sqlite" and primary and primary.provider != "sqlite":
|
| 729 |
-
raise FeedbackServiceConfigError(
|
| 730 |
-
"FEEDBACK_REVIEW_MODE=sqlite requires a sqlite primary target"
|
| 731 |
-
)
|
| 732 |
-
if mode == "sqlite" and any(target.provider != "sqlite" for target in targets):
|
| 733 |
-
raise FeedbackServiceConfigError(
|
| 734 |
-
"FEEDBACK_REVIEW_MODE=sqlite is local-only and requires every storage target to use sqlite"
|
| 735 |
-
)
|
| 736 |
-
if mode == "provider-pr" and primary and primary.provider == "sqlite":
|
| 737 |
-
raise FeedbackServiceConfigError(
|
| 738 |
-
"FEEDBACK_REVIEW_MODE=provider-pr requires a provider primary target"
|
| 739 |
-
)
|
| 740 |
-
if mode != "disabled":
|
| 741 |
-
unavailable = sorted(
|
| 742 |
-
{target.provider for target in targets} - IMPLEMENTED_PROVIDERS
|
| 743 |
-
)
|
| 744 |
-
if unavailable:
|
| 745 |
-
raise FeedbackServiceConfigError(
|
| 746 |
-
"configured feedback provider adapter(s) are not implemented in this release: "
|
| 747 |
-
+ ", ".join(unavailable)
|
| 748 |
-
)
|
| 749 |
-
return FeedbackServiceConfig(
|
| 750 |
-
review_mode=mode,
|
| 751 |
-
targets=targets,
|
| 752 |
-
allowed_site_ids=_allowed_site_ids(env.get("FEEDBACK_ALLOWED_SITE_IDS")),
|
| 753 |
-
page_authority=_load_page_authority(env.get("FEEDBACK_PAGE_AUTHORITY_FILE")),
|
| 754 |
-
max_body_bytes=_bounded_int(
|
| 755 |
-
env.get("FEEDBACK_PAGE_MAX_BODY_BYTES"),
|
| 756 |
-
16 * 1024,
|
| 757 |
-
1024,
|
| 758 |
-
65536,
|
| 759 |
-
name="FEEDBACK_PAGE_MAX_BODY_BYTES",
|
| 760 |
-
),
|
| 761 |
-
rate_limit_per_hour=_bounded_int(
|
| 762 |
-
env.get("FEEDBACK_PAGE_RATE_LIMIT_PER_HOUR"),
|
| 763 |
-
20,
|
| 764 |
-
1,
|
| 765 |
-
240,
|
| 766 |
-
name="FEEDBACK_PAGE_RATE_LIMIT_PER_HOUR",
|
| 767 |
-
),
|
| 768 |
-
mirror_timeout_seconds=_bounded_float(
|
| 769 |
-
env.get("FEEDBACK_MIRROR_TIMEOUT_SECONDS"),
|
| 770 |
-
8.0,
|
| 771 |
-
0.5,
|
| 772 |
-
15.0,
|
| 773 |
-
name="FEEDBACK_MIRROR_TIMEOUT_SECONDS",
|
| 774 |
-
),
|
| 775 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_page_feedback/_service/_core.py
DELETED
|
@@ -1,307 +0,0 @@
|
|
| 1 |
-
"""Provider-neutral feedback service coordinator."""
|
| 2 |
-
|
| 3 |
-
from __future__ import annotations
|
| 4 |
-
|
| 5 |
-
import asyncio
|
| 6 |
-
import os
|
| 7 |
-
import sqlite3
|
| 8 |
-
from pathlib import Path
|
| 9 |
-
from typing import Any, Mapping
|
| 10 |
-
|
| 11 |
-
from .._contracts import (
|
| 12 |
-
FeedbackConflictError,
|
| 13 |
-
FeedbackValidationError,
|
| 14 |
-
build_feedback_event,
|
| 15 |
-
feedback_request_hash,
|
| 16 |
-
parse_feedback_request,
|
| 17 |
-
)
|
| 18 |
-
from ._config import (
|
| 19 |
-
FeedbackServiceConfig,
|
| 20 |
-
FeedbackServiceConfigError,
|
| 21 |
-
StorageTarget,
|
| 22 |
-
load_service_config,
|
| 23 |
-
)
|
| 24 |
-
from ._github import ProviderWriteError, submit_github_review
|
| 25 |
-
from ._sqlite import SQLiteFeedbackStore
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
class FeedbackServiceUnavailable( # ruff: ignore[error-suffix-on-exception-name]
|
| 29 |
-
RuntimeError,
|
| 30 |
-
):
|
| 31 |
-
def __init__(self, code: str, message: str) -> None:
|
| 32 |
-
super().__init__(message)
|
| 33 |
-
self.code = code
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
class PageFeedbackService:
|
| 37 |
-
"""Accept one minimal feedback event and coordinate primary/mirror writes."""
|
| 38 |
-
|
| 39 |
-
def __init__(
|
| 40 |
-
self,
|
| 41 |
-
config: FeedbackServiceConfig,
|
| 42 |
-
*,
|
| 43 |
-
credential_env: Mapping[str, str] | None = None,
|
| 44 |
-
) -> None:
|
| 45 |
-
self.config = config
|
| 46 |
-
self._credential_env = os.environ if credential_env is None else credential_env
|
| 47 |
-
self._page_authority = (
|
| 48 |
-
None
|
| 49 |
-
if config.page_authority is None
|
| 50 |
-
else {
|
| 51 |
-
(site_id, page_id): revision
|
| 52 |
-
for site_id, page_id, revision in config.page_authority
|
| 53 |
-
}
|
| 54 |
-
)
|
| 55 |
-
|
| 56 |
-
@classmethod
|
| 57 |
-
def from_env(cls, env=None) -> PageFeedbackService:
|
| 58 |
-
source = os.environ if env is None else env
|
| 59 |
-
return cls(load_service_config(source), credential_env=source)
|
| 60 |
-
|
| 61 |
-
async def _write_target(
|
| 62 |
-
self,
|
| 63 |
-
target: StorageTarget,
|
| 64 |
-
*,
|
| 65 |
-
request: dict[str, Any],
|
| 66 |
-
event: dict[str, Any],
|
| 67 |
-
request_hash: str,
|
| 68 |
-
http_client=None,
|
| 69 |
-
) -> dict[str, Any]:
|
| 70 |
-
if target.provider == "sqlite":
|
| 71 |
-
store = SQLiteFeedbackStore(Path(target.database))
|
| 72 |
-
try:
|
| 73 |
-
return await asyncio.to_thread(
|
| 74 |
-
store.put,
|
| 75 |
-
feedback_id=event["feedback"]["id"],
|
| 76 |
-
request_hash=request_hash,
|
| 77 |
-
event=event,
|
| 78 |
-
)
|
| 79 |
-
except FeedbackConflictError:
|
| 80 |
-
raise
|
| 81 |
-
except (OSError, sqlite3.Error) as exc:
|
| 82 |
-
raise FeedbackServiceUnavailable(
|
| 83 |
-
"sqlite_write_failed", "SQLite feedback storage is unavailable"
|
| 84 |
-
) from exc
|
| 85 |
-
if target.provider == "github":
|
| 86 |
-
try:
|
| 87 |
-
token, _token_name = target.resolve_token(self._credential_env)
|
| 88 |
-
except FeedbackServiceConfigError as exc:
|
| 89 |
-
raise FeedbackServiceUnavailable(
|
| 90 |
-
"credential_invalid",
|
| 91 |
-
"GitHub feedback credential is invalid",
|
| 92 |
-
) from exc
|
| 93 |
-
if http_client is None:
|
| 94 |
-
try:
|
| 95 |
-
import httpx # ruff: ignore[import-outside-top-level]
|
| 96 |
-
except ( # pragma: no cover - runtime dependency boundary
|
| 97 |
-
Exception
|
| 98 |
-
) as exc:
|
| 99 |
-
raise FeedbackServiceUnavailable(
|
| 100 |
-
"http_client_unavailable",
|
| 101 |
-
"GitHub feedback transport requires httpx",
|
| 102 |
-
) from exc
|
| 103 |
-
timeout = httpx.Timeout(
|
| 104 |
-
20.0, connect=10.0, read=20.0, write=20.0, pool=10.0
|
| 105 |
-
)
|
| 106 |
-
async with httpx.AsyncClient(
|
| 107 |
-
timeout=timeout, follow_redirects=False
|
| 108 |
-
) as client:
|
| 109 |
-
return await submit_github_review(
|
| 110 |
-
client=client,
|
| 111 |
-
target=target,
|
| 112 |
-
token=token,
|
| 113 |
-
event=event,
|
| 114 |
-
request_hash=request_hash,
|
| 115 |
-
)
|
| 116 |
-
return await submit_github_review(
|
| 117 |
-
client=http_client,
|
| 118 |
-
target=target,
|
| 119 |
-
token=token,
|
| 120 |
-
event=event,
|
| 121 |
-
request_hash=request_hash,
|
| 122 |
-
)
|
| 123 |
-
raise FeedbackServiceUnavailable(
|
| 124 |
-
"provider_not_implemented",
|
| 125 |
-
f"Feedback provider {target.provider!r} is modeled but not implemented in this release",
|
| 126 |
-
)
|
| 127 |
-
|
| 128 |
-
def validate_request(
|
| 129 |
-
self,
|
| 130 |
-
payload: dict[str, Any],
|
| 131 |
-
) -> dict[str, Any]:
|
| 132 |
-
"""Normalize one request and enforce server-side submission authority."""
|
| 133 |
-
request = parse_feedback_request(payload)
|
| 134 |
-
if (
|
| 135 |
-
self.config.allowed_site_ids
|
| 136 |
-
and request["site_id"] not in self.config.allowed_site_ids
|
| 137 |
-
):
|
| 138 |
-
raise FeedbackValidationError(
|
| 139 |
-
"site_id is not authorized by this feedback service",
|
| 140 |
-
code="site_not_allowed",
|
| 141 |
-
)
|
| 142 |
-
if self._page_authority is not None:
|
| 143 |
-
authority_key = (request["site_id"], request["page_id"])
|
| 144 |
-
if authority_key not in self._page_authority:
|
| 145 |
-
raise FeedbackValidationError(
|
| 146 |
-
"page_id is not authorized by this feedback service",
|
| 147 |
-
code="page_not_allowed",
|
| 148 |
-
)
|
| 149 |
-
expected_revision = self._page_authority[authority_key]
|
| 150 |
-
if (
|
| 151 |
-
expected_revision
|
| 152 |
-
and request.get("page_revision", "") != expected_revision
|
| 153 |
-
):
|
| 154 |
-
raise FeedbackValidationError(
|
| 155 |
-
"page_revision does not match the server-side page authority",
|
| 156 |
-
code="page_revision_mismatch",
|
| 157 |
-
)
|
| 158 |
-
return request
|
| 159 |
-
|
| 160 |
-
@staticmethod
|
| 161 |
-
def _valid_target_receipt(
|
| 162 |
-
target: StorageTarget,
|
| 163 |
-
receipt: Any,
|
| 164 |
-
*,
|
| 165 |
-
feedback_id: str,
|
| 166 |
-
request_hash: str,
|
| 167 |
-
) -> bool:
|
| 168 |
-
return (
|
| 169 |
-
isinstance(receipt, dict)
|
| 170 |
-
and receipt.get("status") in {"accepted", "replay"}
|
| 171 |
-
and receipt.get("provider") == target.provider
|
| 172 |
-
and receipt.get("feedback_id") == feedback_id
|
| 173 |
-
and receipt.get("request_hash") == request_hash
|
| 174 |
-
)
|
| 175 |
-
|
| 176 |
-
async def submit(
|
| 177 |
-
self,
|
| 178 |
-
payload: dict[str, Any],
|
| 179 |
-
*,
|
| 180 |
-
http_client=None,
|
| 181 |
-
) -> dict[str, Any]:
|
| 182 |
-
if self.config.review_mode == "disabled":
|
| 183 |
-
raise FeedbackServiceUnavailable(
|
| 184 |
-
"feedback_disabled",
|
| 185 |
-
"Generic page feedback is disabled on this service",
|
| 186 |
-
)
|
| 187 |
-
request = self.validate_request(payload)
|
| 188 |
-
request_hash = feedback_request_hash(request)
|
| 189 |
-
event = build_feedback_event(request)
|
| 190 |
-
primary = self.config.primary
|
| 191 |
-
if primary is None:
|
| 192 |
-
raise FeedbackServiceUnavailable(
|
| 193 |
-
"primary_missing",
|
| 194 |
-
"No primary feedback storage target is configured",
|
| 195 |
-
)
|
| 196 |
-
try:
|
| 197 |
-
primary_receipt = await self._write_target(
|
| 198 |
-
primary,
|
| 199 |
-
request=request,
|
| 200 |
-
event=event,
|
| 201 |
-
request_hash=request_hash,
|
| 202 |
-
http_client=http_client,
|
| 203 |
-
)
|
| 204 |
-
except ProviderWriteError as exc:
|
| 205 |
-
raise FeedbackServiceUnavailable(exc.code, str(exc)) from exc
|
| 206 |
-
|
| 207 |
-
if not self._valid_target_receipt(
|
| 208 |
-
primary,
|
| 209 |
-
primary_receipt,
|
| 210 |
-
feedback_id=event["feedback"]["id"],
|
| 211 |
-
request_hash=request_hash,
|
| 212 |
-
):
|
| 213 |
-
raise FeedbackServiceUnavailable(
|
| 214 |
-
"provider_receipt_invalid",
|
| 215 |
-
"Primary feedback provider returned an invalid receipt",
|
| 216 |
-
)
|
| 217 |
-
primary_status = primary_receipt["status"]
|
| 218 |
-
|
| 219 |
-
mirror_status: dict[str, str] = {}
|
| 220 |
-
|
| 221 |
-
async def write_mirror(mirror: StorageTarget) -> tuple[str, str]:
|
| 222 |
-
try:
|
| 223 |
-
mirror_receipt = await self._write_target(
|
| 224 |
-
mirror,
|
| 225 |
-
request=request,
|
| 226 |
-
event=event,
|
| 227 |
-
request_hash=request_hash,
|
| 228 |
-
http_client=http_client,
|
| 229 |
-
)
|
| 230 |
-
if not self._valid_target_receipt(
|
| 231 |
-
mirror,
|
| 232 |
-
mirror_receipt,
|
| 233 |
-
feedback_id=event["feedback"]["id"],
|
| 234 |
-
request_hash=request_hash,
|
| 235 |
-
):
|
| 236 |
-
return mirror.id, "degraded"
|
| 237 |
-
return mirror.id, "ok"
|
| 238 |
-
except asyncio.CancelledError:
|
| 239 |
-
raise
|
| 240 |
-
except (
|
| 241 |
-
FeedbackConflictError,
|
| 242 |
-
FeedbackServiceUnavailable,
|
| 243 |
-
ProviderWriteError,
|
| 244 |
-
OSError,
|
| 245 |
-
):
|
| 246 |
-
# Mirrors are durability only. Primary authority already accepted.
|
| 247 |
-
return mirror.id, "degraded"
|
| 248 |
-
|
| 249 |
-
tasks = {
|
| 250 |
-
asyncio.create_task(write_mirror(mirror)): mirror.id
|
| 251 |
-
for mirror in self.config.mirrors
|
| 252 |
-
}
|
| 253 |
-
if tasks:
|
| 254 |
-
pending: set[asyncio.Task] = set()
|
| 255 |
-
try:
|
| 256 |
-
done, pending = await asyncio.wait(
|
| 257 |
-
tasks, timeout=self.config.mirror_timeout_seconds
|
| 258 |
-
)
|
| 259 |
-
for task in done:
|
| 260 |
-
mirror_id = tasks[task]
|
| 261 |
-
try:
|
| 262 |
-
resolved_id, status = task.result()
|
| 263 |
-
except asyncio.CancelledError:
|
| 264 |
-
# A child mirror can be cancelled independently by its
|
| 265 |
-
# transport/runtime. Primary authority has already
|
| 266 |
-
# succeeded, so treat that as durability degradation;
|
| 267 |
-
# parent cancellation is handled by the outer finally.
|
| 268 |
-
resolved_id, status = mirror_id, "degraded"
|
| 269 |
-
except Exception: # ruff: ignore[blind-except]
|
| 270 |
-
resolved_id, status = mirror_id, "degraded"
|
| 271 |
-
mirror_status[resolved_id] = status
|
| 272 |
-
for task in pending:
|
| 273 |
-
mirror_status[tasks[task]] = "degraded"
|
| 274 |
-
finally:
|
| 275 |
-
# If the request task itself is cancelled after primary authority
|
| 276 |
-
# has committed, never leave mirror writes detached from their
|
| 277 |
-
# parent lifecycle. Cancelling and gathering unfinished children
|
| 278 |
-
# makes shutdown/disconnect behavior deterministic; a later retry
|
| 279 |
-
# can safely resume every provider with the same event nonce.
|
| 280 |
-
unfinished = [task for task in tasks if not task.done()]
|
| 281 |
-
for task in unfinished:
|
| 282 |
-
task.cancel()
|
| 283 |
-
if unfinished:
|
| 284 |
-
await asyncio.gather(*unfinished, return_exceptions=True)
|
| 285 |
-
return {
|
| 286 |
-
"ok": True,
|
| 287 |
-
"contract": "page.feedback-receipt.v1",
|
| 288 |
-
"feedback_id": event["feedback"]["id"],
|
| 289 |
-
"status": primary_status,
|
| 290 |
-
"provider": primary.provider,
|
| 291 |
-
"request_hash": request_hash,
|
| 292 |
-
"review_url": (
|
| 293 |
-
primary_receipt.get("review_url", "") if primary.expose_links else ""
|
| 294 |
-
),
|
| 295 |
-
"mirrors": {
|
| 296 |
-
mirror_id: mirror_status[mirror_id]
|
| 297 |
-
for mirror_id in sorted(mirror_status)
|
| 298 |
-
},
|
| 299 |
-
}
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
__all__ = [
|
| 303 |
-
"FeedbackConflictError",
|
| 304 |
-
"FeedbackServiceConfigError",
|
| 305 |
-
"FeedbackServiceUnavailable",
|
| 306 |
-
"PageFeedbackService",
|
| 307 |
-
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_page_feedback/_service/_github.py
DELETED
|
@@ -1,714 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
GitHub provider-review adapter for immutable feedback JSON events.
|
| 3 |
-
|
| 4 |
-
The adapter is deliberately retry-oriented. A network interruption may happen after
|
| 5 |
-
GitHub has created a branch, committed the immutable event, or opened a pull
|
| 6 |
-
request. Replaying the same feedback ID verifies each durable step and resumes at
|
| 7 |
-
the first missing one instead of treating a partially completed submission as a
|
| 8 |
-
permanent conflict.
|
| 9 |
-
"""
|
| 10 |
-
|
| 11 |
-
from __future__ import annotations
|
| 12 |
-
|
| 13 |
-
import base64
|
| 14 |
-
import json
|
| 15 |
-
from typing import Any
|
| 16 |
-
from urllib.parse import quote, urlsplit
|
| 17 |
-
|
| 18 |
-
from .. import __version__
|
| 19 |
-
from .._contracts import (
|
| 20 |
-
FeedbackConflictError,
|
| 21 |
-
FeedbackValidationError,
|
| 22 |
-
canonical_event_bytes,
|
| 23 |
-
decode_feedback_event,
|
| 24 |
-
feedback_event_request_hash,
|
| 25 |
-
page_digest,
|
| 26 |
-
parse_feedback_event,
|
| 27 |
-
repository_event_bytes,
|
| 28 |
-
validate_request_hash,
|
| 29 |
-
)
|
| 30 |
-
from ._config import StorageTarget
|
| 31 |
-
|
| 32 |
-
_MAX_RESPONSE = 256 * 1024
|
| 33 |
-
_MAX_RESPONSE_CHUNKS = 1024
|
| 34 |
-
_REQUEST_TIMEOUT_SECONDS = 20.0
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
class ProviderWriteError(RuntimeError):
|
| 38 |
-
"""Bounded provider failure safe to surface as a stable service code."""
|
| 39 |
-
|
| 40 |
-
def __init__(self, code: str, message: str) -> None:
|
| 41 |
-
super().__init__(message)
|
| 42 |
-
self.code = code
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
async def _bounded_json( # ruff: ignore[too-many-branches]
|
| 46 |
-
client,
|
| 47 |
-
method: str,
|
| 48 |
-
url: str,
|
| 49 |
-
*,
|
| 50 |
-
headers: dict[str, str],
|
| 51 |
-
json_body: Any = None,
|
| 52 |
-
params: dict[str, str] | None = None,
|
| 53 |
-
) -> tuple[int, Any]:
|
| 54 |
-
"""Perform one bounded GitHub API request even with an unbounded shared client."""
|
| 55 |
-
response = None
|
| 56 |
-
try:
|
| 57 |
-
request = client.build_request(
|
| 58 |
-
method,
|
| 59 |
-
url,
|
| 60 |
-
headers=headers,
|
| 61 |
-
json=json_body,
|
| 62 |
-
params=params,
|
| 63 |
-
timeout=_REQUEST_TIMEOUT_SECONDS,
|
| 64 |
-
)
|
| 65 |
-
response = await client.send(request, stream=True, follow_redirects=False)
|
| 66 |
-
length = response.headers.get("content-length")
|
| 67 |
-
if length:
|
| 68 |
-
try:
|
| 69 |
-
if int(length) > _MAX_RESPONSE:
|
| 70 |
-
raise ProviderWriteError(
|
| 71 |
-
"provider_response_too_large",
|
| 72 |
-
"GitHub response exceeded the safety limit",
|
| 73 |
-
)
|
| 74 |
-
except ValueError:
|
| 75 |
-
pass
|
| 76 |
-
chunks: list[bytes] = []
|
| 77 |
-
total = 0
|
| 78 |
-
chunk_count = 0
|
| 79 |
-
async for chunk in response.aiter_bytes():
|
| 80 |
-
chunk_count += 1
|
| 81 |
-
if chunk_count > _MAX_RESPONSE_CHUNKS:
|
| 82 |
-
raise ProviderWriteError(
|
| 83 |
-
"provider_response_too_fragmented",
|
| 84 |
-
"GitHub response exceeded the response-frame safety limit",
|
| 85 |
-
)
|
| 86 |
-
total += len(chunk)
|
| 87 |
-
if total > _MAX_RESPONSE:
|
| 88 |
-
raise ProviderWriteError(
|
| 89 |
-
"provider_response_too_large",
|
| 90 |
-
"GitHub response exceeded the safety limit",
|
| 91 |
-
)
|
| 92 |
-
chunks.append(chunk)
|
| 93 |
-
raw = b"".join(chunks)
|
| 94 |
-
if not raw:
|
| 95 |
-
payload: Any = {}
|
| 96 |
-
else:
|
| 97 |
-
try:
|
| 98 |
-
payload = json.loads(raw)
|
| 99 |
-
except (
|
| 100 |
-
UnicodeDecodeError,
|
| 101 |
-
json.JSONDecodeError,
|
| 102 |
-
ValueError,
|
| 103 |
-
RecursionError,
|
| 104 |
-
) as exc:
|
| 105 |
-
raise ProviderWriteError(
|
| 106 |
-
"provider_response_invalid",
|
| 107 |
-
"GitHub returned invalid JSON",
|
| 108 |
-
) from exc
|
| 109 |
-
return int(response.status_code), payload
|
| 110 |
-
except ProviderWriteError:
|
| 111 |
-
raise
|
| 112 |
-
except Exception as exc:
|
| 113 |
-
# Do not leak low-level network details/tokens to the public route. The
|
| 114 |
-
# exception remains chained for server-side diagnostics.
|
| 115 |
-
raise ProviderWriteError(
|
| 116 |
-
"provider_transport_failed",
|
| 117 |
-
"GitHub feedback transport failed",
|
| 118 |
-
) from exc
|
| 119 |
-
finally:
|
| 120 |
-
if response is not None:
|
| 121 |
-
try: # ruff: ignore[suppressible-exception]
|
| 122 |
-
await response.aclose()
|
| 123 |
-
except Exception: # ruff: ignore[blind-except]
|
| 124 |
-
pass
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
def _headers(token: str) -> dict[str, str]:
|
| 128 |
-
return {
|
| 129 |
-
"Accept": "application/vnd.github+json",
|
| 130 |
-
"Authorization": f"Bearer {token}",
|
| 131 |
-
"X-GitHub-Api-Version": "2022-11-28",
|
| 132 |
-
"User-Agent": f"sphinx-feedback/{__version__}",
|
| 133 |
-
}
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
def _event_path(target: StorageTarget, event: dict[str, Any]) -> str:
|
| 137 |
-
bucket = page_digest(event["site_id"], event["page_id"])
|
| 138 |
-
return f"{target.feedback_path}/pages/{bucket}/{event['feedback']['id']}.json"
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
def _review_page_text(page_id: str, *, maximum: int | None = None) -> str:
|
| 142 |
-
"""Return a provider-display-safe page label with no Markdown mention syntax."""
|
| 143 |
-
text = quote(page_id, safe="/._-")
|
| 144 |
-
if maximum is not None:
|
| 145 |
-
text = text[:maximum]
|
| 146 |
-
return text
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
def _branch(event: dict[str, Any]) -> str:
|
| 150 |
-
# Use the full 192-bit event nonce. Branch identity is event provenance, not
|
| 151 |
-
# participant identity, and full entropy avoids unnecessary prefix collisions.
|
| 152 |
-
return "feedback/" + event["feedback"]["id"].removeprefix("feedback-")
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
def _object_sha(payload: Any, *, code: str) -> str:
|
| 156 |
-
try:
|
| 157 |
-
sha = str(payload["object"]["sha"])
|
| 158 |
-
except Exception as exc:
|
| 159 |
-
raise ProviderWriteError(
|
| 160 |
-
code,
|
| 161 |
-
"GitHub branch response was incomplete",
|
| 162 |
-
) from exc
|
| 163 |
-
_len = len(sha) != 40 # ruff: ignore[magic-value-comparison]
|
| 164 |
-
if _len or any(ch not in "0123456789abcdefABCDEF" for ch in sha):
|
| 165 |
-
raise ProviderWriteError(
|
| 166 |
-
code,
|
| 167 |
-
"GitHub branch response contained an invalid object id",
|
| 168 |
-
)
|
| 169 |
-
return sha
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
def _review_url(value: Any, *, repo: str | None = None) -> str:
|
| 173 |
-
text = str(value or "").strip()
|
| 174 |
-
try:
|
| 175 |
-
parsed = urlsplit(text)
|
| 176 |
-
port = parsed.port
|
| 177 |
-
except ValueError as exc:
|
| 178 |
-
raise ProviderWriteError(
|
| 179 |
-
"review_lookup_invalid", "GitHub feedback review URL was invalid"
|
| 180 |
-
) from exc
|
| 181 |
-
if (
|
| 182 |
-
parsed.scheme != "https"
|
| 183 |
-
or (parsed.hostname or "").lower() != "github.com"
|
| 184 |
-
or parsed.username
|
| 185 |
-
or parsed.password
|
| 186 |
-
or parsed.query
|
| 187 |
-
or parsed.fragment
|
| 188 |
-
or port not in (None, 443)
|
| 189 |
-
):
|
| 190 |
-
raise ProviderWriteError(
|
| 191 |
-
"review_lookup_invalid", "GitHub feedback review URL was invalid"
|
| 192 |
-
)
|
| 193 |
-
if repo is not None:
|
| 194 |
-
prefix = f"/{repo}/pull/"
|
| 195 |
-
number = parsed.path.removeprefix(prefix)
|
| 196 |
-
if not parsed.path.startswith(prefix) or not number.isdigit():
|
| 197 |
-
raise ProviderWriteError(
|
| 198 |
-
"review_lookup_invalid",
|
| 199 |
-
"GitHub feedback review URL did not belong to the configured repository",
|
| 200 |
-
)
|
| 201 |
-
return text
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
async def _existing_review_url(
|
| 205 |
-
client,
|
| 206 |
-
*,
|
| 207 |
-
api: str,
|
| 208 |
-
headers: dict[str, str],
|
| 209 |
-
repo: str,
|
| 210 |
-
branch: str,
|
| 211 |
-
base_branch: str,
|
| 212 |
-
) -> str | None:
|
| 213 |
-
owner = repo.split("/", 1)[0]
|
| 214 |
-
status, pulls = await _bounded_json(
|
| 215 |
-
client,
|
| 216 |
-
"GET",
|
| 217 |
-
f"{api}/pulls",
|
| 218 |
-
headers=headers,
|
| 219 |
-
params={"state": "all", "head": f"{owner}:{branch}", "base": base_branch},
|
| 220 |
-
)
|
| 221 |
-
if status != 200: # ruff: ignore[magic-value-comparison]
|
| 222 |
-
raise ProviderWriteError(
|
| 223 |
-
"review_lookup_failed",
|
| 224 |
-
"Existing GitHub feedback review could not be verified",
|
| 225 |
-
)
|
| 226 |
-
if not isinstance(pulls, list):
|
| 227 |
-
raise ProviderWriteError(
|
| 228 |
-
"review_lookup_invalid",
|
| 229 |
-
"GitHub feedback review lookup returned an invalid response",
|
| 230 |
-
)
|
| 231 |
-
if not pulls:
|
| 232 |
-
return None
|
| 233 |
-
first = pulls[0]
|
| 234 |
-
if not isinstance(first, dict):
|
| 235 |
-
raise ProviderWriteError(
|
| 236 |
-
"review_lookup_invalid",
|
| 237 |
-
"GitHub feedback review lookup returned an invalid entry",
|
| 238 |
-
)
|
| 239 |
-
return _review_url(first.get("html_url"), repo=repo)
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
async def _branch_comparison(
|
| 243 |
-
client,
|
| 244 |
-
*,
|
| 245 |
-
api: str,
|
| 246 |
-
headers: dict[str, str],
|
| 247 |
-
base_branch: str,
|
| 248 |
-
branch: str,
|
| 249 |
-
) -> dict[str, Any]:
|
| 250 |
-
status, comparison = await _bounded_json(
|
| 251 |
-
client,
|
| 252 |
-
"GET",
|
| 253 |
-
f"{api}/compare/{quote(base_branch, safe='')}...{quote(branch, safe='')}",
|
| 254 |
-
headers=headers,
|
| 255 |
-
)
|
| 256 |
-
_status = status != 200 # ruff: ignore[magic-value-comparison]
|
| 257 |
-
if _status or not isinstance(comparison, dict):
|
| 258 |
-
raise ProviderWriteError(
|
| 259 |
-
"existing_branch_unverifiable",
|
| 260 |
-
"Existing GitHub feedback branch could not be compared safely",
|
| 261 |
-
)
|
| 262 |
-
ahead_by = comparison.get("ahead_by")
|
| 263 |
-
if isinstance(ahead_by, bool) or not isinstance(ahead_by, int) or ahead_by < 0:
|
| 264 |
-
raise ProviderWriteError(
|
| 265 |
-
"existing_branch_unverifiable",
|
| 266 |
-
"GitHub branch comparison was incomplete",
|
| 267 |
-
)
|
| 268 |
-
return comparison
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
async def _branch_has_no_unique_commits(
|
| 272 |
-
client,
|
| 273 |
-
*,
|
| 274 |
-
api: str,
|
| 275 |
-
headers: dict[str, str],
|
| 276 |
-
base_branch: str,
|
| 277 |
-
branch: str,
|
| 278 |
-
) -> bool:
|
| 279 |
-
comparison = await _branch_comparison(
|
| 280 |
-
client,
|
| 281 |
-
api=api,
|
| 282 |
-
headers=headers,
|
| 283 |
-
base_branch=base_branch,
|
| 284 |
-
branch=branch,
|
| 285 |
-
)
|
| 286 |
-
return comparison["ahead_by"] == 0
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
async def _verify_review_branch_scope(
|
| 290 |
-
client,
|
| 291 |
-
*,
|
| 292 |
-
api: str,
|
| 293 |
-
headers: dict[str, str],
|
| 294 |
-
base_branch: str,
|
| 295 |
-
branch: str,
|
| 296 |
-
path: str,
|
| 297 |
-
event: dict[str, Any],
|
| 298 |
-
review_exists: bool,
|
| 299 |
-
) -> None:
|
| 300 |
-
"""
|
| 301 |
-
Require the review branch to contain exactly one immutable feedback file.
|
| 302 |
-
|
| 303 |
-
A deterministic branch name is resumable, but it is also a security boundary:
|
| 304 |
-
an existing branch must never be allowed to smuggle unrelated repository changes
|
| 305 |
-
into the pull request merely because the expected feedback JSON is present.
|
| 306 |
-
"""
|
| 307 |
-
existing_bytes = await _existing_event_bytes(
|
| 308 |
-
client, api=api, headers=headers, path=path, branch=branch
|
| 309 |
-
)
|
| 310 |
-
if not _event_content_matches(existing_bytes, event):
|
| 311 |
-
if existing_bytes is None:
|
| 312 |
-
raise FeedbackConflictError(
|
| 313 |
-
"feedback review branch is missing the expected immutable event"
|
| 314 |
-
)
|
| 315 |
-
raise FeedbackConflictError(
|
| 316 |
-
"feedback_id was already used for different feedback content"
|
| 317 |
-
)
|
| 318 |
-
|
| 319 |
-
comparison = await _branch_comparison(
|
| 320 |
-
client,
|
| 321 |
-
api=api,
|
| 322 |
-
headers=headers,
|
| 323 |
-
base_branch=base_branch,
|
| 324 |
-
branch=branch,
|
| 325 |
-
)
|
| 326 |
-
if comparison["ahead_by"] == 0:
|
| 327 |
-
# After a reviewed pull request is merged, the deterministic branch can
|
| 328 |
-
# compare equal to the base. Preserve idempotent replay only when a review
|
| 329 |
-
# already exists and the immutable event is now present on the base branch.
|
| 330 |
-
if not review_exists:
|
| 331 |
-
raise FeedbackConflictError(
|
| 332 |
-
"feedback review branch contains an unexpected commit history"
|
| 333 |
-
)
|
| 334 |
-
base_bytes = await _existing_event_bytes(
|
| 335 |
-
client, api=api, headers=headers, path=path, branch=base_branch
|
| 336 |
-
)
|
| 337 |
-
if not _event_content_matches(base_bytes, event):
|
| 338 |
-
raise FeedbackConflictError(
|
| 339 |
-
"feedback review branch no longer proves the expected reviewed event"
|
| 340 |
-
)
|
| 341 |
-
return
|
| 342 |
-
if comparison["ahead_by"] != 1:
|
| 343 |
-
raise FeedbackConflictError(
|
| 344 |
-
"feedback review branch contains an unexpected commit history"
|
| 345 |
-
)
|
| 346 |
-
files = comparison.get("files")
|
| 347 |
-
if not isinstance(files, list) or len(files) != 1 or not isinstance(files[0], dict):
|
| 348 |
-
raise FeedbackConflictError(
|
| 349 |
-
"feedback review branch contains unexpected repository changes"
|
| 350 |
-
)
|
| 351 |
-
changed = files[0]
|
| 352 |
-
if changed.get("filename") != path or changed.get("status") != "added":
|
| 353 |
-
raise FeedbackConflictError(
|
| 354 |
-
"feedback review branch contains unexpected repository changes"
|
| 355 |
-
)
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
async def _branch_exists(
|
| 359 |
-
client, *, api: str, headers: dict[str, str], branch: str
|
| 360 |
-
) -> bool:
|
| 361 |
-
status, payload = await _bounded_json(
|
| 362 |
-
client,
|
| 363 |
-
"GET",
|
| 364 |
-
f"{api}/git/ref/heads/{quote(branch, safe='')}",
|
| 365 |
-
headers=headers,
|
| 366 |
-
)
|
| 367 |
-
if status == 404: # ruff: ignore[magic-value-comparison]
|
| 368 |
-
return False
|
| 369 |
-
if status != 200: # ruff: ignore[magic-value-comparison]
|
| 370 |
-
raise ProviderWriteError(
|
| 371 |
-
"existing_branch_unverifiable",
|
| 372 |
-
"Existing GitHub feedback branch could not be verified",
|
| 373 |
-
)
|
| 374 |
-
_object_sha(payload, code="existing_branch_invalid")
|
| 375 |
-
return True
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
async def _existing_event_bytes(
|
| 379 |
-
client,
|
| 380 |
-
*,
|
| 381 |
-
api: str,
|
| 382 |
-
headers: dict[str, str],
|
| 383 |
-
path: str,
|
| 384 |
-
branch: str,
|
| 385 |
-
) -> bytes | None:
|
| 386 |
-
content_status, existing = await _bounded_json(
|
| 387 |
-
client,
|
| 388 |
-
"GET",
|
| 389 |
-
f"{api}/contents/{quote(path, safe='/')}",
|
| 390 |
-
headers=headers,
|
| 391 |
-
params={"ref": branch},
|
| 392 |
-
)
|
| 393 |
-
if content_status == 404: # ruff: ignore[magic-value-comparison]
|
| 394 |
-
return None
|
| 395 |
-
_content_status = content_status != 200 # ruff: ignore[magic-value-comparison]
|
| 396 |
-
if _content_status or not isinstance(existing, dict):
|
| 397 |
-
raise ProviderWriteError(
|
| 398 |
-
"existing_branch_unverifiable",
|
| 399 |
-
"Existing GitHub feedback event could not be verified",
|
| 400 |
-
)
|
| 401 |
-
try:
|
| 402 |
-
return base64.b64decode(
|
| 403 |
-
str(existing.get("content", "")).replace("\n", ""), validate=True
|
| 404 |
-
)
|
| 405 |
-
except Exception as exc:
|
| 406 |
-
raise ProviderWriteError(
|
| 407 |
-
"existing_content_invalid",
|
| 408 |
-
"Existing GitHub feedback event could not be verified",
|
| 409 |
-
) from exc
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
def _event_content_matches(existing_bytes: bytes | None, event: dict[str, Any]) -> bool:
|
| 413 |
-
"""Compare repository JSON by validated event semantics, never whitespace."""
|
| 414 |
-
if existing_bytes is None:
|
| 415 |
-
return False
|
| 416 |
-
try:
|
| 417 |
-
existing_event = decode_feedback_event(existing_bytes)
|
| 418 |
-
expected_event = parse_feedback_event(event)
|
| 419 |
-
except FeedbackValidationError:
|
| 420 |
-
return False
|
| 421 |
-
return canonical_event_bytes(existing_event) == canonical_event_bytes(
|
| 422 |
-
expected_event,
|
| 423 |
-
)
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
async def submit_github_review( # ruff: ignore[too-many-branches]
|
| 427 |
-
*,
|
| 428 |
-
client,
|
| 429 |
-
target: StorageTarget,
|
| 430 |
-
token: str,
|
| 431 |
-
event: dict[str, Any],
|
| 432 |
-
request_hash: str,
|
| 433 |
-
) -> dict[str, Any]:
|
| 434 |
-
"""Idempotently ensure branch, immutable event, and provider review exist."""
|
| 435 |
-
if not token:
|
| 436 |
-
raise ProviderWriteError(
|
| 437 |
-
"credential_missing", "GitHub feedback credential is not configured"
|
| 438 |
-
)
|
| 439 |
-
event = parse_feedback_event(event)
|
| 440 |
-
validate_request_hash(request_hash)
|
| 441 |
-
if request_hash != feedback_event_request_hash(event):
|
| 442 |
-
raise ProviderWriteError(
|
| 443 |
-
"request_commitment_mismatch",
|
| 444 |
-
"GitHub feedback request commitment does not match the durable event",
|
| 445 |
-
)
|
| 446 |
-
repo = target.repo
|
| 447 |
-
api = "https://api.github.com/repos/" + repo
|
| 448 |
-
headers = _headers(token)
|
| 449 |
-
branch = _branch(event)
|
| 450 |
-
if branch == target.branch:
|
| 451 |
-
raise ProviderWriteError(
|
| 452 |
-
"branch_namespace_conflict",
|
| 453 |
-
"GitHub feedback review branch collides with the configured base branch",
|
| 454 |
-
)
|
| 455 |
-
path = _event_path(target, event)
|
| 456 |
-
# Repository files are intentionally pretty-printed for human review.
|
| 457 |
-
# Idempotency/equality remains semantic so pre-v0.6.1 compact files and
|
| 458 |
-
# harmless formatting changes do not become false content conflicts.
|
| 459 |
-
event_bytes = repository_event_bytes(event)
|
| 460 |
-
|
| 461 |
-
status, base_ref = await _bounded_json(
|
| 462 |
-
client,
|
| 463 |
-
"GET",
|
| 464 |
-
f"{api}/git/ref/heads/{quote(target.branch, safe='')}",
|
| 465 |
-
headers=headers,
|
| 466 |
-
)
|
| 467 |
-
if status != 200: # ruff: ignore[magic-value-comparison]
|
| 468 |
-
raise ProviderWriteError(
|
| 469 |
-
"base_ref_unavailable", "GitHub feedback base branch could not be resolved"
|
| 470 |
-
)
|
| 471 |
-
base_sha = _object_sha(base_ref, code="base_ref_invalid")
|
| 472 |
-
|
| 473 |
-
branch_created = False
|
| 474 |
-
try:
|
| 475 |
-
status, _created = await _bounded_json(
|
| 476 |
-
client,
|
| 477 |
-
"POST",
|
| 478 |
-
f"{api}/git/refs",
|
| 479 |
-
headers=headers,
|
| 480 |
-
json_body={"ref": "refs/heads/" + branch, "sha": base_sha},
|
| 481 |
-
)
|
| 482 |
-
except ProviderWriteError as exc:
|
| 483 |
-
if exc.code != "provider_transport_failed":
|
| 484 |
-
raise
|
| 485 |
-
# Broken pipe after GitHub accepted branch creation: reconcile before
|
| 486 |
-
# asking the browser to retry the whole workflow.
|
| 487 |
-
try:
|
| 488 |
-
if await _branch_exists(client, api=api, headers=headers, branch=branch):
|
| 489 |
-
status = 422
|
| 490 |
-
else:
|
| 491 |
-
raise exc # ruff: ignore[verbose-raise]
|
| 492 |
-
except ProviderWriteError:
|
| 493 |
-
raise exc # ruff: ignore[raise-without-from-inside-except]
|
| 494 |
-
if status == 201: # ruff: ignore[magic-value-comparison]
|
| 495 |
-
branch_created = True
|
| 496 |
-
elif status == 422: # ruff: ignore[magic-value-comparison]
|
| 497 |
-
if not await _branch_exists(client, api=api, headers=headers, branch=branch):
|
| 498 |
-
raise ProviderWriteError(
|
| 499 |
-
"branch_create_failed",
|
| 500 |
-
"GitHub rejected the feedback branch and no resumable branch exists",
|
| 501 |
-
)
|
| 502 |
-
else:
|
| 503 |
-
raise ProviderWriteError(
|
| 504 |
-
"branch_create_failed", "GitHub feedback review branch could not be created"
|
| 505 |
-
)
|
| 506 |
-
|
| 507 |
-
event_present = False
|
| 508 |
-
if not branch_created:
|
| 509 |
-
existing_bytes = await _existing_event_bytes(
|
| 510 |
-
client, api=api, headers=headers, path=path, branch=branch
|
| 511 |
-
)
|
| 512 |
-
if existing_bytes is not None:
|
| 513 |
-
if not _event_content_matches(existing_bytes, event):
|
| 514 |
-
raise FeedbackConflictError(
|
| 515 |
-
"feedback_id was already used for different feedback content"
|
| 516 |
-
)
|
| 517 |
-
event_present = True
|
| 518 |
-
elif not await _branch_has_no_unique_commits(
|
| 519 |
-
client,
|
| 520 |
-
api=api,
|
| 521 |
-
headers=headers,
|
| 522 |
-
base_branch=target.branch,
|
| 523 |
-
branch=branch,
|
| 524 |
-
):
|
| 525 |
-
raise FeedbackConflictError(
|
| 526 |
-
"feedback review branch exists without the expected immutable event"
|
| 527 |
-
)
|
| 528 |
-
|
| 529 |
-
if not event_present:
|
| 530 |
-
try:
|
| 531 |
-
status, _content = await _bounded_json(
|
| 532 |
-
client,
|
| 533 |
-
"PUT",
|
| 534 |
-
f"{api}/contents/{quote(path, safe='/')}",
|
| 535 |
-
headers=headers,
|
| 536 |
-
json_body={
|
| 537 |
-
"message": f"Add page feedback {event['feedback']['id']}",
|
| 538 |
-
"content": base64.b64encode(event_bytes).decode("ascii"),
|
| 539 |
-
"branch": branch,
|
| 540 |
-
},
|
| 541 |
-
)
|
| 542 |
-
except ProviderWriteError as exc:
|
| 543 |
-
if exc.code != "provider_transport_failed":
|
| 544 |
-
raise
|
| 545 |
-
# The commit may have succeeded before the connection failed. Verify
|
| 546 |
-
# the immutable bytes once; otherwise preserve the ambiguous error so
|
| 547 |
-
# a later retry can safely resume the same deterministic branch.
|
| 548 |
-
try:
|
| 549 |
-
existing_bytes = await _existing_event_bytes(
|
| 550 |
-
client, api=api, headers=headers, path=path, branch=branch
|
| 551 |
-
)
|
| 552 |
-
except ProviderWriteError:
|
| 553 |
-
raise exc # ruff: ignore[raise-without-from-inside-except]
|
| 554 |
-
if _event_content_matches(existing_bytes, event):
|
| 555 |
-
status = 201
|
| 556 |
-
else:
|
| 557 |
-
raise exc # ruff: ignore[verbose-raise]
|
| 558 |
-
if status not in {200, 201}:
|
| 559 |
-
# Concurrent same-event submissions can race between the existence
|
| 560 |
-
# check and PUT. Re-read immutable content before treating an
|
| 561 |
-
# explicit provider rejection as failure.
|
| 562 |
-
existing_bytes = await _existing_event_bytes(
|
| 563 |
-
client, api=api, headers=headers, path=path, branch=branch
|
| 564 |
-
)
|
| 565 |
-
if _event_content_matches(existing_bytes, event):
|
| 566 |
-
status = 201
|
| 567 |
-
elif existing_bytes is not None:
|
| 568 |
-
raise FeedbackConflictError(
|
| 569 |
-
"feedback_id was already used for different feedback content"
|
| 570 |
-
)
|
| 571 |
-
else:
|
| 572 |
-
raise ProviderWriteError(
|
| 573 |
-
"content_write_failed",
|
| 574 |
-
"GitHub feedback event could not be committed",
|
| 575 |
-
)
|
| 576 |
-
|
| 577 |
-
existing_review = await _existing_review_url(
|
| 578 |
-
client,
|
| 579 |
-
api=api,
|
| 580 |
-
headers=headers,
|
| 581 |
-
repo=repo,
|
| 582 |
-
branch=branch,
|
| 583 |
-
base_branch=target.branch,
|
| 584 |
-
)
|
| 585 |
-
await _verify_review_branch_scope(
|
| 586 |
-
client,
|
| 587 |
-
api=api,
|
| 588 |
-
headers=headers,
|
| 589 |
-
base_branch=target.branch,
|
| 590 |
-
branch=branch,
|
| 591 |
-
path=path,
|
| 592 |
-
event=event,
|
| 593 |
-
review_exists=existing_review is not None,
|
| 594 |
-
)
|
| 595 |
-
if existing_review is not None:
|
| 596 |
-
return {
|
| 597 |
-
"status": "replay",
|
| 598 |
-
"provider": "github",
|
| 599 |
-
"feedback_id": event["feedback"]["id"],
|
| 600 |
-
"review_url": existing_review if target.expose_links else "",
|
| 601 |
-
"request_hash": request_hash,
|
| 602 |
-
}
|
| 603 |
-
|
| 604 |
-
rating = int(event["feedback"]["rating"])
|
| 605 |
-
signed = f"{rating:+d}" if rating else "0"
|
| 606 |
-
title_page = _review_page_text(event["page_id"], maximum=100)
|
| 607 |
-
body_page = _review_page_text(event["page_id"])
|
| 608 |
-
body = (
|
| 609 |
-
"Automated page-feedback review.\n\n"
|
| 610 |
-
f"Request commitment: `{request_hash}`\n\n"
|
| 611 |
-
f"Page: `{body_page}`\n\n"
|
| 612 |
-
"The reviewed JSON contains no implicit browser, device, account, network, "
|
| 613 |
-
"referrer, or telemetry fields."
|
| 614 |
-
)
|
| 615 |
-
try:
|
| 616 |
-
status, pull = await _bounded_json(
|
| 617 |
-
client,
|
| 618 |
-
"POST",
|
| 619 |
-
f"{api}/pulls",
|
| 620 |
-
headers=headers,
|
| 621 |
-
json_body={
|
| 622 |
-
"title": f"[Feedback] {signed} · {title_page}",
|
| 623 |
-
"head": branch,
|
| 624 |
-
"base": target.branch,
|
| 625 |
-
"body": body,
|
| 626 |
-
},
|
| 627 |
-
)
|
| 628 |
-
except ProviderWriteError as exc:
|
| 629 |
-
if exc.code != "provider_transport_failed":
|
| 630 |
-
raise
|
| 631 |
-
try:
|
| 632 |
-
existing_review = await _existing_review_url(
|
| 633 |
-
client,
|
| 634 |
-
api=api,
|
| 635 |
-
headers=headers,
|
| 636 |
-
repo=repo,
|
| 637 |
-
branch=branch,
|
| 638 |
-
base_branch=target.branch,
|
| 639 |
-
)
|
| 640 |
-
except ProviderWriteError:
|
| 641 |
-
raise exc # ruff: ignore[raise-without-from-inside-except]
|
| 642 |
-
if existing_review is None:
|
| 643 |
-
raise exc # ruff: ignore[verbose-raise]
|
| 644 |
-
await _verify_review_branch_scope(
|
| 645 |
-
client,
|
| 646 |
-
api=api,
|
| 647 |
-
headers=headers,
|
| 648 |
-
base_branch=target.branch,
|
| 649 |
-
branch=branch,
|
| 650 |
-
path=path,
|
| 651 |
-
event=event,
|
| 652 |
-
review_exists=True,
|
| 653 |
-
)
|
| 654 |
-
return {
|
| 655 |
-
"status": "replay",
|
| 656 |
-
"provider": "github",
|
| 657 |
-
"feedback_id": event["feedback"]["id"],
|
| 658 |
-
"review_url": existing_review if target.expose_links else "",
|
| 659 |
-
"request_hash": request_hash,
|
| 660 |
-
}
|
| 661 |
-
if status not in {200, 201}:
|
| 662 |
-
# A provider race/non-success may still have opened the review. Reconcile
|
| 663 |
-
# once for every non-success status, not only the common 422 case.
|
| 664 |
-
existing_review = await _existing_review_url(
|
| 665 |
-
client,
|
| 666 |
-
api=api,
|
| 667 |
-
headers=headers,
|
| 668 |
-
repo=repo,
|
| 669 |
-
branch=branch,
|
| 670 |
-
base_branch=target.branch,
|
| 671 |
-
)
|
| 672 |
-
if existing_review is not None:
|
| 673 |
-
await _verify_review_branch_scope(
|
| 674 |
-
client,
|
| 675 |
-
api=api,
|
| 676 |
-
headers=headers,
|
| 677 |
-
base_branch=target.branch,
|
| 678 |
-
branch=branch,
|
| 679 |
-
path=path,
|
| 680 |
-
event=event,
|
| 681 |
-
review_exists=True,
|
| 682 |
-
)
|
| 683 |
-
return {
|
| 684 |
-
"status": "replay",
|
| 685 |
-
"provider": "github",
|
| 686 |
-
"feedback_id": event["feedback"]["id"],
|
| 687 |
-
"review_url": existing_review if target.expose_links else "",
|
| 688 |
-
"request_hash": request_hash,
|
| 689 |
-
}
|
| 690 |
-
raise ProviderWriteError(
|
| 691 |
-
"review_open_failed", "GitHub feedback pull request could not be opened"
|
| 692 |
-
)
|
| 693 |
-
if not isinstance(pull, dict):
|
| 694 |
-
raise ProviderWriteError(
|
| 695 |
-
"review_open_invalid", "GitHub feedback pull request response was invalid"
|
| 696 |
-
)
|
| 697 |
-
review_url = _review_url(pull.get("html_url"), repo=repo)
|
| 698 |
-
await _verify_review_branch_scope(
|
| 699 |
-
client,
|
| 700 |
-
api=api,
|
| 701 |
-
headers=headers,
|
| 702 |
-
base_branch=target.branch,
|
| 703 |
-
branch=branch,
|
| 704 |
-
path=path,
|
| 705 |
-
event=event,
|
| 706 |
-
review_exists=True,
|
| 707 |
-
)
|
| 708 |
-
return {
|
| 709 |
-
"status": "accepted",
|
| 710 |
-
"provider": "github",
|
| 711 |
-
"feedback_id": event["feedback"]["id"],
|
| 712 |
-
"review_url": review_url if target.expose_links else "",
|
| 713 |
-
"request_hash": request_hash,
|
| 714 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_page_feedback/_service/_sqlite.py
DELETED
|
@@ -1,218 +0,0 @@
|
|
| 1 |
-
"""Durable SQLite feedback provider with conflict-safe idempotency."""
|
| 2 |
-
|
| 3 |
-
from __future__ import annotations
|
| 4 |
-
|
| 5 |
-
import json
|
| 6 |
-
import sqlite3
|
| 7 |
-
import time
|
| 8 |
-
from pathlib import Path
|
| 9 |
-
from typing import Any
|
| 10 |
-
|
| 11 |
-
from .._contracts import (
|
| 12 |
-
FeedbackConflictError,
|
| 13 |
-
canonical_event_bytes,
|
| 14 |
-
feedback_event_request_hash,
|
| 15 |
-
normalize_page_revision,
|
| 16 |
-
normalize_site_id,
|
| 17 |
-
parse_feedback_event,
|
| 18 |
-
validate_request_hash,
|
| 19 |
-
)
|
| 20 |
-
|
| 21 |
-
_SCHEMA = """
|
| 22 |
-
CREATE TABLE IF NOT EXISTS feedback_events (
|
| 23 |
-
feedback_id TEXT PRIMARY KEY,
|
| 24 |
-
request_hash TEXT NOT NULL,
|
| 25 |
-
site_id TEXT NOT NULL,
|
| 26 |
-
page_id TEXT NOT NULL,
|
| 27 |
-
event_json TEXT NOT NULL
|
| 28 |
-
) WITHOUT ROWID;
|
| 29 |
-
CREATE INDEX IF NOT EXISTS feedback_events_page_idx
|
| 30 |
-
ON feedback_events(site_id, page_id);
|
| 31 |
-
"""
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
class SQLiteFeedbackStore:
|
| 35 |
-
"""Small transactional event store; no participant/network identity columns."""
|
| 36 |
-
|
| 37 |
-
def __init__(self, path: str | Path, *, busy_timeout_ms: int = 5000) -> None:
|
| 38 |
-
self.path = Path(path)
|
| 39 |
-
self.busy_timeout_ms = max(100, min(int(busy_timeout_ms), 30_000))
|
| 40 |
-
|
| 41 |
-
def _setup_with_retry(self, conn: sqlite3.Connection) -> None:
|
| 42 |
-
"""
|
| 43 |
-
Establish WAL/schema with bounded retry for first-open races.
|
| 44 |
-
|
| 45 |
-
SQLite's busy timeout does not reliably cover every journal-mode transition.
|
| 46 |
-
Concurrent first connections can therefore observe ``database is locked``
|
| 47 |
-
before ordinary transactional retry behavior applies. Keep that race inside
|
| 48 |
-
this initialization boundary rather than surfacing it as a failed feedback
|
| 49 |
-
submission.
|
| 50 |
-
"""
|
| 51 |
-
deadline = time.monotonic() + self.busy_timeout_ms / 1000
|
| 52 |
-
delay = 0.005
|
| 53 |
-
while True:
|
| 54 |
-
try:
|
| 55 |
-
mode = str(
|
| 56 |
-
conn.execute("PRAGMA journal_mode").fetchone()[0],
|
| 57 |
-
).lower()
|
| 58 |
-
if mode != "wal":
|
| 59 |
-
mode = str(
|
| 60 |
-
conn.execute("PRAGMA journal_mode=WAL").fetchone()[0],
|
| 61 |
-
).lower()
|
| 62 |
-
if mode != "wal":
|
| 63 |
-
raise sqlite3.OperationalError(
|
| 64 |
-
"unable to enable WAL journal mode",
|
| 65 |
-
)
|
| 66 |
-
conn.executescript(_SCHEMA)
|
| 67 |
-
return
|
| 68 |
-
except sqlite3.OperationalError as exc: # ruff: ignore[try-except-in-loop]
|
| 69 |
-
locked = "locked" in str(exc).lower() or "busy" in str(exc).lower()
|
| 70 |
-
if not locked or time.monotonic() >= deadline:
|
| 71 |
-
raise
|
| 72 |
-
time.sleep(delay)
|
| 73 |
-
delay = min(delay * 2, 0.1)
|
| 74 |
-
|
| 75 |
-
def _connect(self) -> sqlite3.Connection:
|
| 76 |
-
self.path.parent.mkdir(parents=True, exist_ok=True)
|
| 77 |
-
conn = sqlite3.connect(
|
| 78 |
-
self.path,
|
| 79 |
-
timeout=self.busy_timeout_ms / 1000,
|
| 80 |
-
isolation_level=None,
|
| 81 |
-
)
|
| 82 |
-
try:
|
| 83 |
-
conn.execute(f"PRAGMA busy_timeout={self.busy_timeout_ms}")
|
| 84 |
-
self._setup_with_retry(conn)
|
| 85 |
-
conn.execute("PRAGMA synchronous=FULL")
|
| 86 |
-
return conn
|
| 87 |
-
except Exception:
|
| 88 |
-
conn.close()
|
| 89 |
-
raise
|
| 90 |
-
|
| 91 |
-
def put(
|
| 92 |
-
self, *, feedback_id: str, request_hash: str, event: dict[str, Any]
|
| 93 |
-
) -> dict[str, Any]:
|
| 94 |
-
normalized = parse_feedback_event(event)
|
| 95 |
-
if feedback_id != normalized["feedback"]["id"]:
|
| 96 |
-
raise ValueError("feedback_id does not match the durable event")
|
| 97 |
-
validate_request_hash(request_hash)
|
| 98 |
-
expected_hash = feedback_event_request_hash(normalized)
|
| 99 |
-
if request_hash != expected_hash:
|
| 100 |
-
raise ValueError("request_hash does not match the durable feedback event")
|
| 101 |
-
event_json = canonical_event_bytes(normalized).decode("utf-8").rstrip("\n")
|
| 102 |
-
with self._connect() as conn:
|
| 103 |
-
conn.execute("BEGIN IMMEDIATE")
|
| 104 |
-
row = conn.execute(
|
| 105 |
-
"SELECT request_hash, event_json FROM feedback_events WHERE feedback_id = ?",
|
| 106 |
-
(feedback_id,),
|
| 107 |
-
).fetchone()
|
| 108 |
-
if row is not None:
|
| 109 |
-
if row[0] != request_hash or row[1] != event_json:
|
| 110 |
-
conn.rollback()
|
| 111 |
-
raise FeedbackConflictError(
|
| 112 |
-
"feedback_id was already used for different feedback content"
|
| 113 |
-
)
|
| 114 |
-
conn.commit()
|
| 115 |
-
return {
|
| 116 |
-
"status": "replay",
|
| 117 |
-
"provider": "sqlite",
|
| 118 |
-
"feedback_id": feedback_id,
|
| 119 |
-
"request_hash": request_hash,
|
| 120 |
-
}
|
| 121 |
-
conn.execute(
|
| 122 |
-
"INSERT INTO feedback_events(feedback_id, request_hash, site_id, page_id, event_json) VALUES(?,?,?,?,?)",
|
| 123 |
-
(
|
| 124 |
-
feedback_id,
|
| 125 |
-
request_hash,
|
| 126 |
-
normalized["site_id"],
|
| 127 |
-
normalized["page_id"],
|
| 128 |
-
event_json,
|
| 129 |
-
),
|
| 130 |
-
)
|
| 131 |
-
conn.commit()
|
| 132 |
-
return {
|
| 133 |
-
"status": "accepted",
|
| 134 |
-
"provider": "sqlite",
|
| 135 |
-
"feedback_id": feedback_id,
|
| 136 |
-
"request_hash": request_hash,
|
| 137 |
-
}
|
| 138 |
-
|
| 139 |
-
def events(self, *, site_id: str | None = None) -> list[dict[str, Any]]:
|
| 140 |
-
if site_id is not None:
|
| 141 |
-
site_id = normalize_site_id(site_id)
|
| 142 |
-
with self._connect() as conn:
|
| 143 |
-
if site_id is None:
|
| 144 |
-
rows = conn.execute(
|
| 145 |
-
"SELECT feedback_id, request_hash, site_id, page_id, event_json "
|
| 146 |
-
"FROM feedback_events ORDER BY site_id, page_id, feedback_id"
|
| 147 |
-
).fetchall()
|
| 148 |
-
else:
|
| 149 |
-
rows = conn.execute(
|
| 150 |
-
"SELECT feedback_id, request_hash, site_id, page_id, event_json "
|
| 151 |
-
"FROM feedback_events WHERE site_id = ? ORDER BY page_id, feedback_id",
|
| 152 |
-
(site_id,),
|
| 153 |
-
).fetchall()
|
| 154 |
-
events: list[dict[str, Any]] = []
|
| 155 |
-
for stored_id, stored_hash, stored_site, stored_page, raw_json in rows:
|
| 156 |
-
try:
|
| 157 |
-
decoded = json.loads(raw_json)
|
| 158 |
-
event = parse_feedback_event(decoded)
|
| 159 |
-
canonical = canonical_event_bytes(event).decode("utf-8").rstrip("\n")
|
| 160 |
-
expected_hash = feedback_event_request_hash(event)
|
| 161 |
-
except (json.JSONDecodeError, ValueError, RecursionError) as exc:
|
| 162 |
-
raise sqlite3.DatabaseError(
|
| 163 |
-
"stored feedback event is invalid",
|
| 164 |
-
) from exc
|
| 165 |
-
if canonical != raw_json:
|
| 166 |
-
raise sqlite3.DatabaseError(
|
| 167 |
-
"stored feedback event is not canonical",
|
| 168 |
-
)
|
| 169 |
-
if (
|
| 170 |
-
stored_id != event["feedback"]["id"]
|
| 171 |
-
or stored_site != event["site_id"]
|
| 172 |
-
or stored_page != event["page_id"]
|
| 173 |
-
or stored_hash != expected_hash
|
| 174 |
-
):
|
| 175 |
-
raise sqlite3.DatabaseError(
|
| 176 |
-
"stored feedback event metadata does not match canonical event content"
|
| 177 |
-
)
|
| 178 |
-
events.append(event)
|
| 179 |
-
return events
|
| 180 |
-
|
| 181 |
-
def aggregate(
|
| 182 |
-
self, *, site_id: str, page_revision: str | None = None
|
| 183 |
-
) -> dict[str, dict[str, int]]:
|
| 184 |
-
site_id = normalize_site_id(site_id)
|
| 185 |
-
revision_filter: str | None = None
|
| 186 |
-
if page_revision not in (None, ""):
|
| 187 |
-
revision_filter = normalize_page_revision(page_revision)
|
| 188 |
-
if not revision_filter:
|
| 189 |
-
raise ValueError(
|
| 190 |
-
"revision-scoped aggregate requires a non-empty page_revision",
|
| 191 |
-
)
|
| 192 |
-
pages: dict[str, dict[str, int]] = {}
|
| 193 |
-
for event in self.events(site_id=site_id):
|
| 194 |
-
if (
|
| 195 |
-
revision_filter is not None
|
| 196 |
-
and event.get("page_revision", "") != revision_filter
|
| 197 |
-
):
|
| 198 |
-
continue
|
| 199 |
-
state = pages.setdefault(
|
| 200 |
-
event["page_id"],
|
| 201 |
-
{
|
| 202 |
-
"count": 0,
|
| 203 |
-
"score": 0,
|
| 204 |
-
"positive_count": 0,
|
| 205 |
-
"negative_count": 0,
|
| 206 |
-
"neutral_count": 0,
|
| 207 |
-
},
|
| 208 |
-
)
|
| 209 |
-
rating = int(event["feedback"]["rating"])
|
| 210 |
-
state["count"] += 1
|
| 211 |
-
state["score"] += rating
|
| 212 |
-
if rating > 0:
|
| 213 |
-
state["positive_count"] += 1
|
| 214 |
-
elif rating < 0:
|
| 215 |
-
state["negative_count"] += 1
|
| 216 |
-
else:
|
| 217 |
-
state["neutral_count"] += 1
|
| 218 |
-
return pages
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_page_feedback/_service/app.py
DELETED
|
@@ -1,690 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Dependency-free ASGI adapter for the generic page-feedback service.
|
| 3 |
-
|
| 4 |
-
This module intentionally does not depend on FastAPI/Starlette. It is small enough to
|
| 5 |
-
run directly under any ASGI server while the provider-neutral service remains reusable
|
| 6 |
-
inside an existing application. The adapter stores no page-view data and keeps abuse
|
| 7 |
-
control identity separate from durable feedback events.
|
| 8 |
-
"""
|
| 9 |
-
|
| 10 |
-
from __future__ import annotations
|
| 11 |
-
|
| 12 |
-
import asyncio
|
| 13 |
-
import hashlib
|
| 14 |
-
import hmac
|
| 15 |
-
import ipaddress
|
| 16 |
-
import json
|
| 17 |
-
import os
|
| 18 |
-
import secrets
|
| 19 |
-
import time
|
| 20 |
-
from collections.abc import Mapping, Sequence
|
| 21 |
-
from typing import Any
|
| 22 |
-
from urllib.parse import urlsplit
|
| 23 |
-
|
| 24 |
-
from .._contracts import (
|
| 25 |
-
FeedbackConflictError,
|
| 26 |
-
FeedbackValidationError,
|
| 27 |
-
decode_feedback_request,
|
| 28 |
-
feedback_request_hash,
|
| 29 |
-
)
|
| 30 |
-
from ._config import FeedbackServiceConfig, load_service_config
|
| 31 |
-
from ._core import FeedbackServiceUnavailable, PageFeedbackService
|
| 32 |
-
|
| 33 |
-
_ENDPOINT = "/v1/feedback"
|
| 34 |
-
_WINDOW_SECONDS = 3600.0
|
| 35 |
-
_MAX_ORIGINS = 64
|
| 36 |
-
_MAX_RATE_IDENTITIES = 10_000
|
| 37 |
-
_MAX_RETRY_KEYS = 50_000
|
| 38 |
-
_MAX_SAME_REQUEST_RETRIES = 3
|
| 39 |
-
_MAX_REQUEST_FRAMES = 256
|
| 40 |
-
_MAX_TRUSTED_PROXY_CIDRS = 64
|
| 41 |
-
_MAX_FORWARDED_HOPS = 32
|
| 42 |
-
_MAX_SECURITY_HEADER_BYTES = 8192
|
| 43 |
-
_RATE_PRUNE_INTERVAL_SECONDS = 60.0
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
class FeedbackASGIConfigError(ValueError):
|
| 47 |
-
"""Raised for unsafe standalone-adapter configuration."""
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
def _origin(value: str) -> str:
|
| 51 |
-
text = str(value or "").strip()
|
| 52 |
-
_len = len(text) > 2048 # ruff: ignore[magic-value-comparison]
|
| 53 |
-
if (
|
| 54 |
-
not text
|
| 55 |
-
or _len
|
| 56 |
-
or any(
|
| 57 |
-
ord(ch) < 32 or ord(ch) == 127 # ruff: ignore[magic-value-comparison]
|
| 58 |
-
for ch in text
|
| 59 |
-
)
|
| 60 |
-
):
|
| 61 |
-
raise FeedbackASGIConfigError(
|
| 62 |
-
"feedback allowed origin is invalid",
|
| 63 |
-
)
|
| 64 |
-
try:
|
| 65 |
-
parsed = urlsplit(text)
|
| 66 |
-
port = parsed.port
|
| 67 |
-
except ValueError as exc:
|
| 68 |
-
raise FeedbackASGIConfigError(
|
| 69 |
-
"feedback allowed origin is invalid",
|
| 70 |
-
) from exc
|
| 71 |
-
host = (parsed.hostname or "").lower()
|
| 72 |
-
local_http = parsed.scheme == "http" and host in {"127.0.0.1", "localhost", "::1"}
|
| 73 |
-
if parsed.scheme != "https" and not local_http:
|
| 74 |
-
raise FeedbackASGIConfigError(
|
| 75 |
-
"feedback allowed origins must use HTTPS or localhost HTTP",
|
| 76 |
-
)
|
| 77 |
-
if (
|
| 78 |
-
not host
|
| 79 |
-
or parsed.username
|
| 80 |
-
or parsed.password
|
| 81 |
-
or parsed.path not in {"", "/"}
|
| 82 |
-
or parsed.query
|
| 83 |
-
or parsed.fragment
|
| 84 |
-
):
|
| 85 |
-
raise FeedbackASGIConfigError(
|
| 86 |
-
"feedback allowed origin must contain only scheme and authority",
|
| 87 |
-
)
|
| 88 |
-
if parsed.scheme == "https" and port not in (None, 443):
|
| 89 |
-
raise FeedbackASGIConfigError(
|
| 90 |
-
"feedback allowed HTTPS origins must use port 443",
|
| 91 |
-
)
|
| 92 |
-
authority = host
|
| 93 |
-
if ":" in host and not host.startswith("["):
|
| 94 |
-
authority = f"[{host}]"
|
| 95 |
-
if (
|
| 96 |
-
port is not None
|
| 97 |
-
and not (
|
| 98 |
-
parsed.scheme == "https" # lint
|
| 99 |
-
and port == 443 # ruff: ignore[magic-value-comparison]
|
| 100 |
-
)
|
| 101 |
-
and not (
|
| 102 |
-
parsed.scheme == "http" # lint
|
| 103 |
-
and port == 80 # ruff: ignore[magic-value-comparison]
|
| 104 |
-
)
|
| 105 |
-
):
|
| 106 |
-
authority += f":{port}"
|
| 107 |
-
return f"{parsed.scheme}://{authority}"
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
def parse_allowed_origins(value: str | Sequence[str] | None) -> tuple[str, ...]:
|
| 111 |
-
"""Normalize an optional exact CORS allowlist; blank means no CORS headers."""
|
| 112 |
-
if value in (None, ""):
|
| 113 |
-
return ()
|
| 114 |
-
if isinstance(value, Sequence) and not isinstance(value, str) and len(value) == 0:
|
| 115 |
-
return ()
|
| 116 |
-
if isinstance(value, str):
|
| 117 |
-
items = [part.strip() for part in value.split(",") if part.strip()]
|
| 118 |
-
elif isinstance(value, Sequence):
|
| 119 |
-
items = list(value)
|
| 120 |
-
else:
|
| 121 |
-
raise FeedbackASGIConfigError(
|
| 122 |
-
"feedback allowed origins must be a string or sequence",
|
| 123 |
-
)
|
| 124 |
-
if len(items) > _MAX_ORIGINS:
|
| 125 |
-
raise FeedbackASGIConfigError(
|
| 126 |
-
"feedback allowed origins contains too many entries",
|
| 127 |
-
)
|
| 128 |
-
result: list[str] = []
|
| 129 |
-
for item in items:
|
| 130 |
-
normalized = _origin(str(item))
|
| 131 |
-
if normalized not in result:
|
| 132 |
-
result.append(normalized)
|
| 133 |
-
return tuple(result)
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
def _singleton_header(scope: dict[str, Any], name: str) -> str:
|
| 137 |
-
values: list[str] = []
|
| 138 |
-
expected = name.lower()
|
| 139 |
-
headers = scope.get("headers") or []
|
| 140 |
-
if not isinstance(headers, (list, tuple)):
|
| 141 |
-
raise FeedbackASGIConfigError("ASGI headers must be a sequence of byte pairs")
|
| 142 |
-
for item in headers:
|
| 143 |
-
_len = len(item) != 2 # ruff: ignore[magic-value-comparison]
|
| 144 |
-
if not isinstance(item, (list, tuple)) or _len:
|
| 145 |
-
raise FeedbackASGIConfigError("ASGI headers must contain byte pairs")
|
| 146 |
-
key, value = item
|
| 147 |
-
if not isinstance(key, bytes) or not isinstance(value, bytes):
|
| 148 |
-
raise FeedbackASGIConfigError("ASGI header names and values must be bytes")
|
| 149 |
-
if (
|
| 150 |
-
len(key) > 256 # ruff: ignore[magic-value-comparison]
|
| 151 |
-
or len(value) > _MAX_SECURITY_HEADER_BYTES
|
| 152 |
-
or any(ch in key for ch in (0, 10, 13))
|
| 153 |
-
or any(ch in value for ch in (0, 10, 13))
|
| 154 |
-
):
|
| 155 |
-
raise FeedbackASGIConfigError("ASGI header framing is invalid")
|
| 156 |
-
header_name = key.decode("latin-1").lower()
|
| 157 |
-
header_value = value.decode("latin-1")
|
| 158 |
-
if header_name == expected:
|
| 159 |
-
values.append(header_value)
|
| 160 |
-
if len(values) > 1:
|
| 161 |
-
raise FeedbackASGIConfigError(f"duplicate {name} header is not allowed")
|
| 162 |
-
return values[0] if values else ""
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
def parse_trusted_proxy_cidrs( # ruff: ignore[too-many-branches]
|
| 166 |
-
value: str | Sequence[str] | None,
|
| 167 |
-
) -> tuple[str, ...]:
|
| 168 |
-
"""
|
| 169 |
-
Return canonical trusted-proxy CIDRs for X-Forwarded-For processing.
|
| 170 |
-
|
| 171 |
-
Forwarded identity is ignored unless the direct ASGI peer belongs to one of
|
| 172 |
-
these explicitly trusted networks. Networks must already be canonical (host
|
| 173 |
-
bits are rejected), which makes deployment policy deterministic and auditable.
|
| 174 |
-
"""
|
| 175 |
-
if value in (None, ""):
|
| 176 |
-
return ()
|
| 177 |
-
if isinstance(value, Sequence) and not isinstance(value, str) and len(value) == 0:
|
| 178 |
-
return ()
|
| 179 |
-
if isinstance(value, str):
|
| 180 |
-
items = [part.strip() for part in value.split(",") if part.strip()]
|
| 181 |
-
elif isinstance(value, Sequence):
|
| 182 |
-
items = list(value)
|
| 183 |
-
else:
|
| 184 |
-
raise FeedbackASGIConfigError(
|
| 185 |
-
"trusted proxy CIDRs must be a string or sequence",
|
| 186 |
-
)
|
| 187 |
-
if not items or len(items) > _MAX_TRUSTED_PROXY_CIDRS:
|
| 188 |
-
raise FeedbackASGIConfigError(
|
| 189 |
-
"trusted proxy CIDRs must contain 1..64 entries",
|
| 190 |
-
)
|
| 191 |
-
result: list[str] = []
|
| 192 |
-
for item in items:
|
| 193 |
-
if not isinstance(item, str):
|
| 194 |
-
raise FeedbackASGIConfigError(
|
| 195 |
-
"trusted proxy CIDR entries must be strings",
|
| 196 |
-
)
|
| 197 |
-
text = item.strip()
|
| 198 |
-
_len = len(text) > 128 # ruff: ignore[magic-value-comparison]
|
| 199 |
-
if (
|
| 200 |
-
not text
|
| 201 |
-
or _len
|
| 202 |
-
or any(
|
| 203 |
-
ord(ch) < 33 or ord(ch) > 126 # ruff: ignore[magic-value-comparison]
|
| 204 |
-
for ch in text
|
| 205 |
-
)
|
| 206 |
-
):
|
| 207 |
-
raise FeedbackASGIConfigError("trusted proxy CIDR is invalid")
|
| 208 |
-
try:
|
| 209 |
-
network = ipaddress.ip_network(text, strict=True)
|
| 210 |
-
except ValueError as exc:
|
| 211 |
-
raise FeedbackASGIConfigError("trusted proxy CIDR is invalid") from exc
|
| 212 |
-
if network.prefixlen == 0:
|
| 213 |
-
raise FeedbackASGIConfigError(
|
| 214 |
-
"trusted proxy CIDR must not trust an entire address family"
|
| 215 |
-
)
|
| 216 |
-
canonical = network.with_prefixlen
|
| 217 |
-
if canonical != text:
|
| 218 |
-
raise FeedbackASGIConfigError(
|
| 219 |
-
"trusted proxy CIDR must already be canonical",
|
| 220 |
-
)
|
| 221 |
-
if canonical not in result:
|
| 222 |
-
result.append(canonical)
|
| 223 |
-
|
| 224 |
-
# Reject a collectively universal trust policy too. A literal /0 is not the
|
| 225 |
-
# only way to trust an entire address family: two complementary /1 networks
|
| 226 |
-
# (or several narrower networks) can collapse to the same /0. Treat that as
|
| 227 |
-
# the same unsafe deployment mistake instead of letting spelling bypass the
|
| 228 |
-
# guard above. Keep IPv4 and IPv6 separate because collapse_addresses does
|
| 229 |
-
# not accept mixed address families.
|
| 230 |
-
for version in (4, 6):
|
| 231 |
-
networks = [
|
| 232 |
-
ipaddress.ip_network(item, strict=True)
|
| 233 |
-
for item in result
|
| 234 |
-
if ipaddress.ip_network(item, strict=True).version == version
|
| 235 |
-
]
|
| 236 |
-
if networks and any(
|
| 237 |
-
network.prefixlen == 0 for network in ipaddress.collapse_addresses(networks)
|
| 238 |
-
):
|
| 239 |
-
raise FeedbackASGIConfigError(
|
| 240 |
-
"trusted proxy CIDRs must not collectively trust an entire address family"
|
| 241 |
-
)
|
| 242 |
-
return tuple(result)
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
def _json_bytes(payload: dict[str, Any]) -> bytes:
|
| 246 |
-
return json.dumps(
|
| 247 |
-
payload,
|
| 248 |
-
ensure_ascii=False,
|
| 249 |
-
separators=(",", ":"),
|
| 250 |
-
).encode("utf-8")
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
class FeedbackASGIApp:
|
| 254 |
-
"""Minimal standalone ASGI transport for :class:`PageFeedbackService`."""
|
| 255 |
-
|
| 256 |
-
def __init__(
|
| 257 |
-
self,
|
| 258 |
-
config: FeedbackServiceConfig,
|
| 259 |
-
*,
|
| 260 |
-
allowed_origins: Sequence[str] = (),
|
| 261 |
-
service: PageFeedbackService | None = None,
|
| 262 |
-
trusted_proxy_cidrs: Sequence[str] = (),
|
| 263 |
-
) -> None:
|
| 264 |
-
self.config = config
|
| 265 |
-
self.service = service or PageFeedbackService(config)
|
| 266 |
-
self.allowed_origins = parse_allowed_origins(allowed_origins)
|
| 267 |
-
self.trusted_proxy_cidrs = parse_trusted_proxy_cidrs(trusted_proxy_cidrs)
|
| 268 |
-
self._trusted_proxy_networks = tuple(
|
| 269 |
-
ipaddress.ip_network(item, strict=True) for item in self.trusted_proxy_cidrs
|
| 270 |
-
)
|
| 271 |
-
self._rate_secret = secrets.token_bytes(32)
|
| 272 |
-
self._rate: dict[str, tuple[int, float]] = {}
|
| 273 |
-
self._rate_retries: dict[tuple[str, str], tuple[int, float]] = {}
|
| 274 |
-
self._rate_lock = asyncio.Lock()
|
| 275 |
-
self._rate_last_prune = 0.0
|
| 276 |
-
|
| 277 |
-
def _cors_origin(self, scope: dict[str, Any]) -> str:
|
| 278 |
-
origin = _singleton_header(scope, "origin").strip()
|
| 279 |
-
if not origin or not self.allowed_origins:
|
| 280 |
-
return ""
|
| 281 |
-
try:
|
| 282 |
-
normalized = _origin(origin)
|
| 283 |
-
except FeedbackASGIConfigError:
|
| 284 |
-
return "!"
|
| 285 |
-
return normalized if normalized in self.allowed_origins else "!"
|
| 286 |
-
|
| 287 |
-
def _is_trusted_proxy(
|
| 288 |
-
self,
|
| 289 |
-
address: ipaddress.IPv4Address | ipaddress.IPv6Address,
|
| 290 |
-
) -> bool:
|
| 291 |
-
return any(address in network for network in self._trusted_proxy_networks)
|
| 292 |
-
|
| 293 |
-
def _rate_address( # ruff: ignore[too-many-return-statements]
|
| 294 |
-
self,
|
| 295 |
-
scope: dict[str, Any],
|
| 296 |
-
) -> str:
|
| 297 |
-
"""
|
| 298 |
-
Resolve the abuse-control address without trusting caller headers.
|
| 299 |
-
|
| 300 |
-
Direct peers are authoritative by default. ``X-Forwarded-For`` is used
|
| 301 |
-
only when the immediate peer is explicitly trusted, and the chain is
|
| 302 |
-
walked from right to left until the first untrusted hop. Malformed
|
| 303 |
-
forwarding metadata falls back to the direct peer, which can over-limit
|
| 304 |
-
but cannot create attacker-controlled identities.
|
| 305 |
-
"""
|
| 306 |
-
client = scope.get("client")
|
| 307 |
-
if not isinstance(client, (list, tuple)) or not client:
|
| 308 |
-
return "unknown"
|
| 309 |
-
try:
|
| 310 |
-
peer = ipaddress.ip_address(str(client[0] or ""))
|
| 311 |
-
except ValueError:
|
| 312 |
-
return "unknown"
|
| 313 |
-
if not self._trusted_proxy_networks or not self._is_trusted_proxy(peer):
|
| 314 |
-
return peer.compressed
|
| 315 |
-
try:
|
| 316 |
-
forwarded = _singleton_header(scope, "x-forwarded-for").strip()
|
| 317 |
-
except FeedbackASGIConfigError:
|
| 318 |
-
return peer.compressed
|
| 319 |
-
if not forwarded:
|
| 320 |
-
return peer.compressed
|
| 321 |
-
parts = [part.strip() for part in forwarded.split(",")]
|
| 322 |
-
if (
|
| 323 |
-
not parts
|
| 324 |
-
or len(parts) > _MAX_FORWARDED_HOPS
|
| 325 |
-
or any(not part for part in parts)
|
| 326 |
-
):
|
| 327 |
-
return peer.compressed
|
| 328 |
-
try:
|
| 329 |
-
chain = [ipaddress.ip_address(part) for part in parts]
|
| 330 |
-
except ValueError:
|
| 331 |
-
return peer.compressed
|
| 332 |
-
current = peer
|
| 333 |
-
for candidate in reversed(chain):
|
| 334 |
-
if not self._is_trusted_proxy(current):
|
| 335 |
-
break
|
| 336 |
-
current = candidate
|
| 337 |
-
return current.compressed
|
| 338 |
-
|
| 339 |
-
def _rate_identity(self, scope: dict[str, Any]) -> str:
|
| 340 |
-
address = self._rate_address(scope)
|
| 341 |
-
message = f"page-feedback-asgi:v1:submit\0{address}".encode("utf-8", "replace")
|
| 342 |
-
return hmac.new(self._rate_secret, message, hashlib.sha256).hexdigest()
|
| 343 |
-
|
| 344 |
-
def _prune_rate_locked(self, now: float, *, force: bool = False) -> None:
|
| 345 |
-
if not force and now - self._rate_last_prune < _RATE_PRUNE_INTERVAL_SECONDS:
|
| 346 |
-
return
|
| 347 |
-
stale = [
|
| 348 |
-
key
|
| 349 |
-
for key, (_count, start) in self._rate.items()
|
| 350 |
-
if now - start >= _WINDOW_SECONDS
|
| 351 |
-
]
|
| 352 |
-
for key in stale:
|
| 353 |
-
self._rate.pop(key, None)
|
| 354 |
-
stale_retries = [
|
| 355 |
-
key
|
| 356 |
-
for key, (_count, start) in self._rate_retries.items()
|
| 357 |
-
if now - start >= _WINDOW_SECONDS
|
| 358 |
-
]
|
| 359 |
-
for key in stale_retries:
|
| 360 |
-
self._rate_retries.pop(key, None)
|
| 361 |
-
self._rate_last_prune = now
|
| 362 |
-
|
| 363 |
-
async def _consume_rate(self, identity: str, request_hash: str) -> bool:
|
| 364 |
-
"""
|
| 365 |
-
Admit a new event or a bounded exact retry without tracking people.
|
| 366 |
-
|
| 367 |
-
The main quota counts distinct request commitments. Once a commitment has
|
| 368 |
-
been admitted, up to ``_MAX_SAME_REQUEST_RETRIES`` exact retries may pass
|
| 369 |
-
without consuming another new-event slot. This preserves broken-pipe
|
| 370 |
-
recovery even at a 1/hour quota while keeping retries bounded. The cache
|
| 371 |
-
contains only the HMAC abuse identity plus a request commitment.
|
| 372 |
-
"""
|
| 373 |
-
now = time.monotonic()
|
| 374 |
-
async with self._rate_lock:
|
| 375 |
-
self._prune_rate_locked(now)
|
| 376 |
-
|
| 377 |
-
retry_key = (identity, request_hash)
|
| 378 |
-
retry = self._rate_retries.get(retry_key)
|
| 379 |
-
if retry is not None:
|
| 380 |
-
retry_count, retry_start = retry
|
| 381 |
-
if retry_count >= _MAX_SAME_REQUEST_RETRIES:
|
| 382 |
-
return False
|
| 383 |
-
self._rate_retries[retry_key] = (retry_count + 1, retry_start)
|
| 384 |
-
return True
|
| 385 |
-
|
| 386 |
-
if (
|
| 387 |
-
identity not in self._rate and len(self._rate) >= _MAX_RATE_IDENTITIES
|
| 388 |
-
) or len(self._rate_retries) >= _MAX_RETRY_KEYS:
|
| 389 |
-
self._prune_rate_locked(now, force=True)
|
| 390 |
-
if identity not in self._rate and len(self._rate) >= _MAX_RATE_IDENTITIES:
|
| 391 |
-
return False
|
| 392 |
-
if len(self._rate_retries) >= _MAX_RETRY_KEYS:
|
| 393 |
-
# Fail closed rather than allowing attacker-controlled commitment
|
| 394 |
-
# cardinality to grow process memory without bound.
|
| 395 |
-
return False
|
| 396 |
-
|
| 397 |
-
count, start = self._rate.get(identity, (0, now))
|
| 398 |
-
if now - start >= _WINDOW_SECONDS:
|
| 399 |
-
count, start = 0, now
|
| 400 |
-
if count >= self.config.rate_limit_per_hour:
|
| 401 |
-
return False
|
| 402 |
-
self._rate[identity] = (count + 1, start)
|
| 403 |
-
self._rate_retries[retry_key] = (0, now)
|
| 404 |
-
return True
|
| 405 |
-
|
| 406 |
-
async def _send_json(
|
| 407 |
-
self,
|
| 408 |
-
send,
|
| 409 |
-
status: int,
|
| 410 |
-
payload: dict[str, Any],
|
| 411 |
-
*,
|
| 412 |
-
cors_origin: str = "",
|
| 413 |
-
extra_headers: Sequence[tuple[bytes, bytes]] = (),
|
| 414 |
-
) -> None:
|
| 415 |
-
body = (
|
| 416 |
-
b""
|
| 417 |
-
if status == 204 # ruff: ignore[magic-value-comparison]
|
| 418 |
-
else _json_bytes(payload)
|
| 419 |
-
)
|
| 420 |
-
headers = [
|
| 421 |
-
(b"cache-control", b"no-store"),
|
| 422 |
-
(b"content-length", str(len(body)).encode("ascii")),
|
| 423 |
-
(b"x-content-type-options", b"nosniff"),
|
| 424 |
-
]
|
| 425 |
-
if status != 204: # ruff: ignore[magic-value-comparison]
|
| 426 |
-
headers.insert(0, (b"content-type", b"application/json; charset=utf-8"))
|
| 427 |
-
if cors_origin and cors_origin != "!":
|
| 428 |
-
headers.extend(
|
| 429 |
-
[
|
| 430 |
-
(b"access-control-allow-origin", cors_origin.encode("ascii")),
|
| 431 |
-
(b"vary", b"Origin"),
|
| 432 |
-
]
|
| 433 |
-
)
|
| 434 |
-
headers.extend(extra_headers)
|
| 435 |
-
try:
|
| 436 |
-
await send(
|
| 437 |
-
{"type": "http.response.start", "status": status, "headers": headers}
|
| 438 |
-
)
|
| 439 |
-
await send({"type": "http.response.body", "body": body})
|
| 440 |
-
except OSError:
|
| 441 |
-
# ASGI servers are allowed to raise OSError when the client disconnects
|
| 442 |
-
# after a durable write. The browser retry uses the same event id, so
|
| 443 |
-
# suppressing this transport-only broken pipe preserves exact replay.
|
| 444 |
-
return
|
| 445 |
-
|
| 446 |
-
async def _read_body(self, receive) -> bytes:
|
| 447 |
-
body = bytearray()
|
| 448 |
-
messages = 0
|
| 449 |
-
while True:
|
| 450 |
-
message = await receive()
|
| 451 |
-
messages += 1
|
| 452 |
-
if messages > _MAX_REQUEST_FRAMES:
|
| 453 |
-
raise OverflowError("request is too fragmented")
|
| 454 |
-
if message.get("type") == "http.disconnect":
|
| 455 |
-
raise ConnectionError("client disconnected")
|
| 456 |
-
if message.get("type") != "http.request":
|
| 457 |
-
continue
|
| 458 |
-
raw_chunk = message.get("body", b"")
|
| 459 |
-
if not isinstance(raw_chunk, bytes):
|
| 460 |
-
raise ValueError( # ruff: ignore[type-check-without-type-error]
|
| 461 |
-
"request body frame must contain bytes",
|
| 462 |
-
)
|
| 463 |
-
if len(body) + len(raw_chunk) > self.config.max_body_bytes:
|
| 464 |
-
raise OverflowError("request too large")
|
| 465 |
-
body.extend(raw_chunk)
|
| 466 |
-
if not message.get("more_body", False):
|
| 467 |
-
return bytes(body)
|
| 468 |
-
|
| 469 |
-
async def __call__( # ruff: ignore[too-many-branches, too-many-return-statements]
|
| 470 |
-
self,
|
| 471 |
-
scope,
|
| 472 |
-
receive,
|
| 473 |
-
send,
|
| 474 |
-
) -> None:
|
| 475 |
-
if scope.get("type") != "http":
|
| 476 |
-
return
|
| 477 |
-
path = str(scope.get("path") or "")
|
| 478 |
-
method = str(scope.get("method") or "GET").upper()
|
| 479 |
-
try:
|
| 480 |
-
# These headers are security-relevant singletons. Ambiguous duplicates
|
| 481 |
-
# are rejected instead of relying on first/last-value proxy behavior.
|
| 482 |
-
_singleton_header(scope, "content-type")
|
| 483 |
-
declared_length = _singleton_header(scope, "content-length")
|
| 484 |
-
cors_origin = self._cors_origin(scope)
|
| 485 |
-
except FeedbackASGIConfigError:
|
| 486 |
-
await self._send_json(send, 400, {"detail": "Ambiguous request headers."})
|
| 487 |
-
return
|
| 488 |
-
if cors_origin == "!":
|
| 489 |
-
await self._send_json(
|
| 490 |
-
send, 403, {"detail": "Origin is not allowed for page feedback."}
|
| 491 |
-
)
|
| 492 |
-
return
|
| 493 |
-
if path != _ENDPOINT:
|
| 494 |
-
await self._send_json(
|
| 495 |
-
send,
|
| 496 |
-
404,
|
| 497 |
-
{"detail": "Not found."},
|
| 498 |
-
cors_origin=cors_origin,
|
| 499 |
-
)
|
| 500 |
-
return
|
| 501 |
-
if method == "OPTIONS":
|
| 502 |
-
if not cors_origin:
|
| 503 |
-
await self._send_json(
|
| 504 |
-
send,
|
| 505 |
-
204,
|
| 506 |
-
{},
|
| 507 |
-
cors_origin="",
|
| 508 |
-
)
|
| 509 |
-
return
|
| 510 |
-
await self._send_json(
|
| 511 |
-
send,
|
| 512 |
-
204,
|
| 513 |
-
{},
|
| 514 |
-
cors_origin=cors_origin,
|
| 515 |
-
extra_headers=(
|
| 516 |
-
(b"access-control-allow-methods", b"POST, OPTIONS"),
|
| 517 |
-
(b"access-control-allow-headers", b"Content-Type, Accept"),
|
| 518 |
-
(b"access-control-max-age", b"600"),
|
| 519 |
-
),
|
| 520 |
-
)
|
| 521 |
-
return
|
| 522 |
-
if method != "POST":
|
| 523 |
-
await self._send_json(
|
| 524 |
-
send, 405, {"detail": "Method not allowed."}, cors_origin=cors_origin
|
| 525 |
-
)
|
| 526 |
-
return
|
| 527 |
-
declared_bytes: int | None = None
|
| 528 |
-
if declared_length:
|
| 529 |
-
if not declared_length.isascii() or not declared_length.isdecimal():
|
| 530 |
-
await self._send_json(
|
| 531 |
-
send,
|
| 532 |
-
400,
|
| 533 |
-
{"detail": "Invalid Content-Length."},
|
| 534 |
-
cors_origin=cors_origin,
|
| 535 |
-
)
|
| 536 |
-
return
|
| 537 |
-
try:
|
| 538 |
-
declared_bytes = int(declared_length, 10)
|
| 539 |
-
except ValueError:
|
| 540 |
-
await self._send_json(
|
| 541 |
-
send,
|
| 542 |
-
400,
|
| 543 |
-
{"detail": "Invalid Content-Length."},
|
| 544 |
-
cors_origin=cors_origin,
|
| 545 |
-
)
|
| 546 |
-
return
|
| 547 |
-
if declared_bytes < 0:
|
| 548 |
-
await self._send_json(
|
| 549 |
-
send,
|
| 550 |
-
400,
|
| 551 |
-
{"detail": "Invalid Content-Length."},
|
| 552 |
-
cors_origin=cors_origin,
|
| 553 |
-
)
|
| 554 |
-
return
|
| 555 |
-
if declared_bytes > self.config.max_body_bytes:
|
| 556 |
-
await self._send_json(
|
| 557 |
-
send,
|
| 558 |
-
413,
|
| 559 |
-
{
|
| 560 |
-
"detail": (
|
| 561 |
-
"Page feedback request exceeds the configured safety limit."
|
| 562 |
-
),
|
| 563 |
-
},
|
| 564 |
-
cors_origin=cors_origin,
|
| 565 |
-
)
|
| 566 |
-
return
|
| 567 |
-
media_type = (
|
| 568 |
-
_singleton_header(
|
| 569 |
-
scope,
|
| 570 |
-
"content-type",
|
| 571 |
-
)
|
| 572 |
-
.split(";", 1)[0]
|
| 573 |
-
.strip()
|
| 574 |
-
.lower()
|
| 575 |
-
)
|
| 576 |
-
if media_type != "application/json":
|
| 577 |
-
await self._send_json(
|
| 578 |
-
send,
|
| 579 |
-
415,
|
| 580 |
-
{"detail": "Page feedback requires application/json."},
|
| 581 |
-
cors_origin=cors_origin,
|
| 582 |
-
)
|
| 583 |
-
return
|
| 584 |
-
try:
|
| 585 |
-
raw = await self._read_body(receive)
|
| 586 |
-
except OverflowError:
|
| 587 |
-
await self._send_json(
|
| 588 |
-
send,
|
| 589 |
-
413,
|
| 590 |
-
{
|
| 591 |
-
"detail": (
|
| 592 |
-
"Page feedback request exceeds the configured safety limit."
|
| 593 |
-
),
|
| 594 |
-
},
|
| 595 |
-
cors_origin=cors_origin,
|
| 596 |
-
)
|
| 597 |
-
return
|
| 598 |
-
except ConnectionError:
|
| 599 |
-
return
|
| 600 |
-
except ValueError:
|
| 601 |
-
await self._send_json(
|
| 602 |
-
send,
|
| 603 |
-
400,
|
| 604 |
-
{"detail": "Malformed page feedback request framing."},
|
| 605 |
-
cors_origin=cors_origin,
|
| 606 |
-
)
|
| 607 |
-
return
|
| 608 |
-
if declared_bytes is not None and declared_bytes != len(raw):
|
| 609 |
-
await self._send_json(
|
| 610 |
-
send,
|
| 611 |
-
400,
|
| 612 |
-
{"detail": "Content-Length does not match the request body."},
|
| 613 |
-
cors_origin=cors_origin,
|
| 614 |
-
)
|
| 615 |
-
return
|
| 616 |
-
try:
|
| 617 |
-
validated = decode_feedback_request(raw)
|
| 618 |
-
validated = self.service.validate_request(validated)
|
| 619 |
-
except FeedbackValidationError as exc:
|
| 620 |
-
await self._send_json(
|
| 621 |
-
send, 422, {"detail": str(exc)}, cors_origin=cors_origin
|
| 622 |
-
)
|
| 623 |
-
return
|
| 624 |
-
request_hash = feedback_request_hash(validated)
|
| 625 |
-
if not await self._consume_rate(self._rate_identity(scope), request_hash):
|
| 626 |
-
await self._send_json(
|
| 627 |
-
send,
|
| 628 |
-
429,
|
| 629 |
-
{"detail": "Rate limit exceeded for page feedback submissions."},
|
| 630 |
-
cors_origin=cors_origin,
|
| 631 |
-
extra_headers=((b"retry-after", b"3600"),),
|
| 632 |
-
)
|
| 633 |
-
return
|
| 634 |
-
try:
|
| 635 |
-
receipt = await self.service.submit(validated)
|
| 636 |
-
except FeedbackConflictError as exc:
|
| 637 |
-
await self._send_json(
|
| 638 |
-
send, 409, {"detail": str(exc)}, cors_origin=cors_origin
|
| 639 |
-
)
|
| 640 |
-
return
|
| 641 |
-
except FeedbackValidationError as exc:
|
| 642 |
-
await self._send_json(
|
| 643 |
-
send, 422, {"detail": str(exc)}, cors_origin=cors_origin
|
| 644 |
-
)
|
| 645 |
-
return
|
| 646 |
-
except FeedbackServiceUnavailable:
|
| 647 |
-
await self._send_json(
|
| 648 |
-
send,
|
| 649 |
-
503,
|
| 650 |
-
{"detail": "Page feedback storage is temporarily unavailable."},
|
| 651 |
-
cors_origin=cors_origin,
|
| 652 |
-
)
|
| 653 |
-
return
|
| 654 |
-
await self._send_json(send, 202, receipt, cors_origin=cors_origin)
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
def create_app(
|
| 658 |
-
env: Mapping[str, str] | None = None,
|
| 659 |
-
*,
|
| 660 |
-
allowed_origins: str | Sequence[str] | None = None,
|
| 661 |
-
) -> FeedbackASGIApp:
|
| 662 |
-
"""Create a standalone ASGI app from explicit/process environment config."""
|
| 663 |
-
source = os.environ if env is None else env
|
| 664 |
-
config = load_service_config(source)
|
| 665 |
-
origins = (
|
| 666 |
-
source.get("FEEDBACK_ALLOWED_ORIGINS", "")
|
| 667 |
-
if allowed_origins is None
|
| 668 |
-
else allowed_origins
|
| 669 |
-
)
|
| 670 |
-
trusted_proxies = parse_trusted_proxy_cidrs(
|
| 671 |
-
source.get("FEEDBACK_TRUSTED_PROXY_CIDRS", "")
|
| 672 |
-
)
|
| 673 |
-
return FeedbackASGIApp(
|
| 674 |
-
config,
|
| 675 |
-
allowed_origins=parse_allowed_origins(origins),
|
| 676 |
-
service=PageFeedbackService(config, credential_env=source),
|
| 677 |
-
trusted_proxy_cidrs=trusted_proxies,
|
| 678 |
-
)
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
app = create_app()
|
| 682 |
-
|
| 683 |
-
__all__ = [
|
| 684 |
-
"FeedbackASGIApp",
|
| 685 |
-
"FeedbackASGIConfigError",
|
| 686 |
-
"app",
|
| 687 |
-
"create_app",
|
| 688 |
-
"parse_allowed_origins",
|
| 689 |
-
"parse_trusted_proxy_cidrs",
|
| 690 |
-
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|