mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:17:02 +01:00
A photo sent to the chat no longer enters the album by itself. pack_add strips it, checks it against the album and the queue, and writes it to pending/ with a sidecar naming the people and labels the sender proposed; the reply is "czeka na zatwierdzenie". The management page lists the queue above the tagger, with the proposal pre-ticked, and Approve moves the file in, mints the labels, records approved_at and asks the bot to rebuild its table through /_reload -- the same publish path every other data change takes, so PackHolder.reload is gone. A reload the bot refuses is reported on the page, not rolled back: the photo is in the album either way. The personal-data gate now reads pending/*.json too, so a label that is still waiting for approval is forbidden in the tree already. The e2e run boots the management page beside the bot with the same environment, proves the queue is stripped and never reaches media/, approves it through the real routes, and has !losowe answer with the photo afterwards without a restart; exiftool is found through media_strip.locate() there rather than PATH, which lacks vendor_perl on Arch. !gadaj is dropped from !odcisz: it was within a typo of !dodaj, and the nearest-command suggestion must never be a coin toss. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
174 lines
9.3 KiB
Markdown
174 lines
9.3 KiB
Markdown
# Adding a photo from the chat: `!add_photo`
|
|
|
|
How a member of an album's group proposes a photo for it by sending one,
|
|
how the operator approves it, 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 `!dodaj alfa wakacje2019` in one of the album's groups.
|
|
`alfa` resolves to that person's key; `wakacje2019` matches nobody and travels
|
|
as a *proposed* label. The photo is stripped and lands in the album's
|
|
`pending/`, and the reply is `czeka na zatwierdzenie: 1`. It is **not in the
|
|
album yet**: `!alfa` and `!losowe wakacje2019` cannot find it until somebody
|
|
approves it.
|
|
|
|
### Approval: the management page, and nothing else
|
|
|
|
`./web.sh` shows a **Waiting for approval** section above the tagger whenever
|
|
`pending/` is not empty (`web/src/views/pending.tsx`). Each queued photo is a
|
|
card with the album's people as checkboxes — the ones the sender named are
|
|
pre-ticked — and one checkbox per proposed label. **Approve** moves the file
|
|
into `media/`, writes `tags.json`, mints any ticked label in `custom.json`,
|
|
records the sender in `adds.json` (with `approved_at`), and asks the bot for
|
|
`POST /_reload`. From that moment `!losowe wakacje2019` answers with it, with
|
|
no restart. **Reject** deletes the file and its sidecar; nothing else to undo,
|
|
because nothing else was written.
|
|
|
|
The queue is the only door in. The bot's own process never approves anything;
|
|
it writes `pending/<content-hash>.<ext>` plus a `.json` sidecar (`keys`,
|
|
`labels`, `by`, `at`, `group`) and reads the result back at the reload. The
|
|
page can only approve a label the sender actually proposed (anything else is
|
|
`400`), and it can keep fewer — an unticked label is simply not minted. If the
|
|
bot then refuses the reload (a stale alias, say) the page **reports** it and
|
|
does not roll back: the photo is in the album with the tags the approver
|
|
chose, and the bot's reason is about something else.
|
|
|
|
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; a new word becomes a per-message token (`new1`, `new2`) and is claimed
|
|
back by its own handler, so concurrent frames cannot swap labels. The reply
|
|
is counts only — `czeka na zatwierdzenie: 2 · 1 już było` — and the proposed
|
|
word exists in exactly one place, the sidecar inside the album's `0700`.
|
|
`scripts/check_personal_data.sh` reads those sidecars too, so a proposed
|
|
name is on the forbidden list before it is ever approved.
|
|
- **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 `już było`, one already waiting is `już
|
|
czeka` — the same photo twice in one message counts as waiting too — and a
|
|
mixed batch queues the new files and says how many were which.
|
|
- **Approval is whole or not at all.** Media, `tags.json`, `custom.json` and
|
|
`adds.json` are written, the album is loaded again, and only a load that
|
|
succeeds keeps them. A load that fails rolls all four back and leaves the
|
|
photo in the queue with a `400` naming the reason.
|
|
- **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 queued, and also when the upload was refused.
|
|
- **Stripping happens before the wait.** The file in `pending/` is already
|
|
the stripped one; `scripts/e2e.sh` re-reads the queue with exiftool before
|
|
approving anything, and the album after.
|
|
|
|
### 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.
|