signal-bot/DOCS-add-photo.md
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

7.2 KiB

Adding a photo from the chat: !add_photo

How a member of an album's group puts a photo into it by sending one, and what keeps that path from leaking anything. The album itself — where it lives, what its manifests look like, how several albums coexist — is DOCS-photo-pack.md.

Send a photo captioned !add_photo alfa wakacje2019 in one of the album's groups. alfa resolves to that person's key; wakacje2019 matches nobody and is minted as a custom tag. The photo is retrievable through !alfa and !random wakacje2019 immediately, with no restart.

What holds:

  • No typed label travels. Labels become keys in the registry's arg_resolver, at the dispatcher, before the line that logs the command name. The reply is counts and pseudonyms — dodano · p1: 4 · t1: 1.
  • No cap. No cooldown, no size limit, no daily limit. !random takes the ordinary pack cooldown; !add_photo takes no decorator at all.
  • At most six labels, the same ceiling !alfa beta … has.
  • The attachment must be on the same message, never a quoted one: a reply carries the quoted message's media in quote.attachments, and taking it would let anyone re-file somebody else's photo.
  • A duplicate changes nothing. The stored name is a content hash, so a photo already in the album is reported and skipped; a mixed batch stores the new files and says how many were already there.
  • Nothing is written until it works. Media, tags.json, custom.json and adds.json are written, the album is reloaded, and only a load that succeeds is published. A load that fails rolls all four back and keeps serving the album that was already working.
  • The second copy goes. signal-cli keeps its own copy of every inbound attachment outside the album's 0700. !add_photo deletes it once the file is stored, and also when the upload was refused.

Stripping: exiftool, with a positive check

ffmpeg -map_metadata -1 -c copy was tried first and fails silently on photos: given a JPEG with a real APP1 Exif segment it returned a byte-identical file, GPS marker intact, and exited 0. -c copy passes the compressed frame through as an opaque packet and a JPEG's Exif lives inside it. Do not reintroduce ffmpeg for this.

So the gate is a positive check on the output, never an exit code:

exiftool -all= -time:all= -o <dst> <src>   # never --overwrite_original
exiftool -G0 -s -all <dst>                 # then read it back

-time:all= is not redundant with -all=. media_tags.ZEROED_DATE_TAGS allows the QuickTime date tags "by value" on the grounds that -all= zeroes rather than deletes them. Measured 2026-08-31: it does not. -all= alone left QuickTime:CreateDate at its real value, and the consequence was that every mp4 was refused — the code documented behaviour it did not get. -time:all= is what actually zeroes them.

and every surviving tag must be structural. Measured against exiftool 13.55, the plan's original "the surviving set must be empty" would have rejected every format except JPEG — a valid PNG always reports its IHDR under PNG, a GIF its screen descriptor under GIF, an MP4 some fifty QuickTime tags that are the container. So the allowlist is per tag name (signal_bot/media_tags.py), which keeps the property that matters: an atom nobody anticipated arrives as a name that is not on the list, and the upload is refused.

Allowlist, not blocklist, for the reason a naive gate misses: an MP4 geotag is a QuickTime udta/©xyz atom reported under QuickTime, not GPS.

Tracks: ffmpeg, for what no tag allowlist can see

A tag allowlist is structurally blind to one class, and it is the worst one. IMG_6924.mp4 in the live album carries two mebx timed-metadata tracks, and after a complete -all= -time:all= pass its bytes still contain:

com.apple.quicktime.detected-face
com.apple.quicktime.detected-face.bounds
com.apple.quicktime.detected-face.face-id
com.apple.quicktime.detected-face.roll-angle / .yaw-angle

Per-frame face bounding boxes and a face identity number. exiftool is not broken for missing them: mebx samples live in mdat as opaque payload, not as tags, so there is no tag to refuse. The only answer is to drop the track, which signal_bot/media_tracks.py does before exiftool runs:

ffmpeg -v error -i <src> -map 0:v -map "0:a?" -c copy \
       -map_metadata -1 -map_chapters -1 -y <dst>
ffprobe -v error -show_entries stream=codec_type -of csv=p=0 <dst>

and every surviving stream must be video or audio.

This is not the ffmpeg mistake above. That one was ffmpeg replacing exiftool for tag removal, where it silently failed on JPEG. Here ffmpeg removes tracks, exiftool still does every tag removal afterwards, and the gate is a positive read-back of which streams survived rather than an exit code. -c copy is safe for this job precisely because nothing is being claimed about the frames — the claim is about which tracks exist, and ffprobe answers that directly. Do not collapse the two jobs back together.

QuickTime:MetaFormat is deliberately absent from the allowlist: a file still reporting one has a metadata track the re-mux should have removed, and refusing it is the correct outcome.

Both entry points obey this. ./web.sh --import used to be a verbatim shutil.copy2; since 2026-08-31 it runs the same stripper, so the guarantee on this page covers the whole album and not just the chat path.

The list includes the codec's own configuration — CompressorName, VendorID, and the HEVC parameter set (HEVCConfigurationVersion, GeneralProfileIDC, BitDepthLuma, …). That was forced by measurement, not chosen for convenience: with them off the list exiftool 13.55 could produce no acceptable video and no acceptable HEIC, because CompressorName lives in the avc1 sample description and exiftool reports it as not writable at all (Sorry, QuickTime:CompressorName doesn't exist or isn't writable), and a real HEIC reports sixteen HEVC config tags. Since HEIC is the iPhone default, the choice was codec parameters or no phone photos. They describe how the picture is encoded, never where it was taken or who took it; every location and identity tag is still refused, and scripts/e2e.sh proves a QuickTime GPS atom does not survive on a file the album actually stored.

Formats are decided by magic bytes, never by the filename: jpg, png, gif, webp, mp4, mov, m4v, heic, heif, avif, webm and svg. Every one of them must also be in pack.MEDIA_TYPES, or a file that uploaded cleanly is refused by the next load and takes startup down.

⚠️ .webm and .svg are stored unstripped

exiftool 13.55 can write neither (exiftool -listwf lists no WEBM and no SVG; a strip attempt returns Can't write SVG files). Its SVG reader does not surface <metadata> content either, so there is no detection to fall back on.

kuhy's decision of 2026-08-31 is to keep both on the allowlist and store them as they arrive, metadata included. The consequence, stated plainly: an .svg carrying <metadata> or an embedded <image xlink:href="data:image/jpeg;base64,…"> keeps whatever is in it, and every later !alfa re-sends that file verbatim. Every other format on the list is stripped and verified.