signal-bot/signal_bot/pack_store.py
Krzysztof kuhy Rudnicki a24d0d5905
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
feat: one page to manage the bot, and the tagger folded into it
./web.sh serves health, the configuration and the photo album on
127.0.0.1:8140 -- the port tool/tagger.py vacates. React 19, Vite 8,
TypeScript 6.0.3, ESLint 10 with strictTypeChecked + stylisticTypeChecked +
unicorn + sonarjs + perfectionist, Vitest 4 at 100% on all four metrics.

No coverage exclude list, including for main.tsx. This repository treats a
coverage omit as a suppression, and "the DOM bootstrap is awkward to assert"
is the same sentence as "this code is untested" -- so the bootstrap is a shim
thin enough to test in jsdom, and the album view holds its file list apart
from the album so the update path stopped carrying a "what if it is null"
branch that cannot happen. An untestable branch is an untested one.

TypeScript stays at 6.0.3: typescript-eslint 8.68.0 declares
typescript >=4.8.4 <6.1.0, and the fleet-wide allowlist entry says the
compiler waits for the linter. The freshness gate then caught two pins the
reference repo was carrying stale -- eslint-plugin-unicorn 73 -> 74 and a
floating node-version: "24" in CI -- both bumped rather than allowlisted.

Traps the gates found, all of them real:
- .gitignore has a bare lib/, which matches at any depth, so web/src/lib/
  would have been silently untracked. The source lives in src/api/.
- CI's shellcheck glob was `run.sh scripts/*.sh`, so tag.sh was unlinted in
  CI for its whole life and web.sh would have inherited that. Now ./*.sh.
- tsconfig.json is strict JSON, not JSONC: check-json is generic and
  tsconfig being commentable is a convention, not a requirement.
- The personal-data gate refused an invented phone number in a test, which
  is exactly what it is for.

The album's warning that names a command moved with the command, and every
path that pointed at tool/tagger.py now points at ./web.sh. The historical
mentions stay: they explain why the code is shaped as it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01416TegJ3sa8xL5qGGgrygP
2026-08-31 14:54:49 +02:00

147 lines
5.2 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Putting a file into the album, and taking it back out when the swap fails.
Every write here is temp-then-``Path.replace``, the pattern
``pack_album.Album.write`` already uses, because a manifest that is half
written is a manifest that fails the next load -- and with ``Restart=always``
a startup-time ConfigError is a loop, not an error message.
The stored name is a content hash. The original filename is discarded: it is
attacker-chosen, it is frequently somebody's name and a date, and hashing
makes "is this photo already here?" a lookup instead of a comparison.
``adds.json`` records who added what. Nothing reads it -- ``pack_load``
ignores the file entirely -- and it exists because ``!undo_photo`` cannot be
built later without it, and the id has to have been recorded at add time.
It stays inside the album directory, which is 0700 and outside the
repository, so it is subject to exactly the same handling as the photos.
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Final
from signal_bot.pack import MEDIA_DIR, TAGS_FILE
from signal_bot.pack_custom import CUSTOM_FILE, FILE_MODE
if TYPE_CHECKING:
from collections.abc import Mapping
from pathlib import Path
ADDS_FILE: Final = "adds.json"
# Half a SHA-256 is 128 bits of name. Collisions are not a threat model here;
# a shorter name is simply easier to read in a directory listing.
NAME_LENGTH: Final = 32
def content_name(payload: bytes, suffix: str) -> str:
"""The filename one upload gets, derived from the bytes themselves."""
digest = hashlib.sha256(payload).hexdigest()[:NAME_LENGTH]
return f"{digest}{suffix}"
@dataclass(frozen=True, slots=True)
class Snapshot:
"""The album's manifests as they were before a write, for rolling back.
Bytes rather than parsed documents: restoring has to put back exactly
what was there, and a reparse-and-redump could quietly normalise a file
the tagger wrote by hand.
"""
tags: bytes | None
custom: bytes | None
adds: bytes | None
def snapshot(directory: Path) -> Snapshot:
"""Read the three manifests as they stand, before anything is written."""
return Snapshot(
tags=_read(directory / TAGS_FILE),
custom=_read(directory / CUSTOM_FILE),
adds=_read(directory / ADDS_FILE),
)
def restore(directory: Path, previous: Snapshot, written: tuple[str, ...]) -> None:
"""Undo a failed add: put the manifests back and delete the new files.
Called only when the album no longer loads after a write, which means the
bot is still serving the *old* pack from memory. Leaving the new files
behind would make the next start fail on a tag that no longer resolves.
"""
_restore(directory / TAGS_FILE, previous.tags)
_restore(directory / CUSTOM_FILE, previous.custom)
_restore(directory / ADDS_FILE, previous.adds)
for name in written:
(directory / MEDIA_DIR / name).unlink(missing_ok=True)
def write_media(directory: Path, name: str, payload: bytes) -> None:
"""Put one stripped file into the album, atomically."""
album = directory / MEDIA_DIR
album.mkdir(parents=True, exist_ok=True)
path = album / name
temporary = path.with_name(f"{name}.tmp")
temporary.write_bytes(payload)
temporary.chmod(FILE_MODE)
temporary.replace(path)
def write_tags(directory: Path, tags: Mapping[str, frozenset[str]]) -> None:
"""Replace tags.json with the merged table."""
document = {name: sorted(keys) for name, keys in sorted(tags.items())}
write_json(directory / TAGS_FILE, document)
def record_adds(directory: Path, entries: Mapping[str, dict[str, Any]]) -> None:
"""Append to adds.json, which nothing at run time ever reads back."""
document = _document(directory / ADDS_FILE)
document.update(entries)
write_json(directory / ADDS_FILE, document)
def write_json(path: Path, document: Mapping[str, object]) -> None:
"""Write one manifest atomically, 0600, so a crash cannot halve it."""
temporary = path.with_name(f"{path.name}.tmp")
temporary.write_text(json.dumps(document, indent=2), encoding="utf-8")
temporary.chmod(FILE_MODE)
temporary.replace(path)
def _document(path: Path) -> dict[str, Any]:
"""One JSON object, or an empty one for a file that is not there yet.
Deliberately forgiving, unlike pack_load: adds.json is write-only
bookkeeping, and refusing to store a photo because an unread audit file
got corrupted would be the tail wagging the dog.
"""
raw = _read(path)
if raw is None:
return {}
try:
document = json.loads(raw)
except json.JSONDecodeError:
return {}
return document if isinstance(document, dict) else {}
def _read(path: Path) -> bytes | None:
"""The file's bytes, or None when it does not exist."""
try:
return path.read_bytes()
except OSError:
return None
def _restore(path: Path, previous: bytes | None) -> None:
"""Put one manifest back, or remove it if there was none."""
if previous is None:
path.unlink(missing_ok=True)
return
path.write_bytes(previous)