mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:27:20 +01:00
./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
142 lines
7.2 KiB
Markdown
142 lines
7.2 KiB
Markdown
# 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:
|
|
|
|
```sh
|
|
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:
|
|
|
|
```sh
|
|
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.
|