feat(gif): Tenor GIF picker [operator-action] (LC-488) #469

Merged
longjacksonle merged 4 commits from feat/LC-488-gif-picker into main 2026-06-28 02:18:24 +02:00

What

GIF picker (LC-488): a composer GIF button opens a Tenor search modal; picking one posts it as a normal same-origin attachment. Gated on an operator-set LETS_CHAT_TENOR_API_KEY (hidden when unset).

How

  • Config: gif::GifConfig::from_env (env-only, read on demand - no AppState client, so zero AppState-constructor churn). LETS_CHAT_TENOR_API_KEY (+ optional _CLIENT_KEY, _CONTENT_FILTER default medium).
  • Search: GET /api/gifs?q= proxies Tenor v2 (featured when empty), parse_search extracts preview + full URLs, renders a result grid fragment.
  • Post: POST /room/{id}/gif validates the URL is on the Tenor CDN (is_tenor_media_url) on top of the http_client public-IP SSRF filter, streams the GIF with a hard byte cap, magic-byte-sniffs (must be image/gif), re-encodes through the uploads pipeline (LC-206 decode limits), stores a content-addressed file_uploads row, and posts it as an image-only message via finalize_message_send. GIFs are never hotlinked into messages - always fetched + served same-origin.
  • UI: composer GIF button (gated via gif_available on RoomPage/DmPage/ComposerFragment) + a singleton search modal (debounced hx-get grid, click-to-post), Tenor attribution. i18n en + es.

Safety / content

  • Two-layer egress control: Tenor-CDN host allowlist + public-IP SSRF filter; client can't steer the fetch at an arbitrary host. Byte cap + decode limits guard size/decode bombs.
  • [operator-action]: optional new env; search terms + picks are sent to Tenor (third-party content/usage), documented in the CLAUDE.md env table. Content filter defaults to medium.

Tests

Unit tests for parse_search (fallbacks + skipping incomplete results) and is_tenor_media_url (incl. look-alike host rejection). The outbound HTTP path is not mockable in-process (concrete reqwest, by design), so the fetch/search HTTP is not covered by an in-process test - the pure parsing + allowlist are. just check, just test, just test-saas green.

Notes

  • Implemented the composer-button path; the /gif slash-command variant the issue also mentions was not added (button is sufficient; easy follow-up).

Verify

just verify builds the release binary clean; boot fails only on the missing SSO env (environmental, as in prior PRs).

## What GIF picker (LC-488): a composer GIF button opens a Tenor search modal; picking one posts it as a normal same-origin attachment. Gated on an operator-set `LETS_CHAT_TENOR_API_KEY` (hidden when unset). ## How - **Config**: `gif::GifConfig::from_env` (env-only, read on demand - no AppState client, so zero AppState-constructor churn). `LETS_CHAT_TENOR_API_KEY` (+ optional `_CLIENT_KEY`, `_CONTENT_FILTER` default `medium`). - **Search**: `GET /api/gifs?q=` proxies Tenor v2 (featured when empty), `parse_search` extracts preview + full URLs, renders a result grid fragment. - **Post**: `POST /room/{id}/gif` validates the URL is on the Tenor CDN (`is_tenor_media_url`) on top of the `http_client` public-IP SSRF filter, streams the GIF with a hard byte cap, magic-byte-sniffs (must be `image/gif`), re-encodes through the uploads pipeline (LC-206 decode limits), stores a content-addressed `file_uploads` row, and posts it as an image-only message via `finalize_message_send`. **GIFs are never hotlinked into messages** - always fetched + served same-origin. - **UI**: composer GIF button (gated via `gif_available` on RoomPage/DmPage/ComposerFragment) + a singleton search modal (debounced hx-get grid, click-to-post), Tenor attribution. i18n en + es. ## Safety / content - Two-layer egress control: Tenor-CDN host allowlist + public-IP SSRF filter; client can't steer the fetch at an arbitrary host. Byte cap + decode limits guard size/decode bombs. - `[operator-action]`: optional new env; search terms + picks are sent to Tenor (third-party content/usage), documented in the CLAUDE.md env table. Content filter defaults to `medium`. ## Tests Unit tests for `parse_search` (fallbacks + skipping incomplete results) and `is_tenor_media_url` (incl. look-alike host rejection). The outbound HTTP path is not mockable in-process (concrete reqwest, by design), so the fetch/search HTTP is not covered by an in-process test - the pure parsing + allowlist are. `just check`, `just test`, `just test-saas` green. ## Notes - Implemented the composer-button path; the `/gif` slash-command variant the issue also mentions was not added (button is sufficient; easy follow-up). ## Verify `just verify` builds the release binary clean; boot fails only on the missing SSO env (environmental, as in prior PRs).
New gif module: env-only GifConfig (LETS_CHAT_TENOR_API_KEY/_CLIENT_KEY/_CONTENT_FILTER, read on demand - no AppState client), parse_search for the Tenor v2 response, and is_tenor_media_url host allowlist. Unit-tested (parse fallbacks/skips, allowlist incl. look-alike rejection).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET /api/gifs proxies Tenor search (featured when empty) and renders the result grid. POST /room/{id}/gif validates the URL is on the Tenor CDN, streams it with a hard byte cap, sniffs + re-encodes through the uploads pipeline (LC-206 decode limits), stores a content-addressed file_uploads row, and posts it as an image-only message attachment via finalize_message_send.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A composer GIF button (gated on gif_available, threaded through RoomPage/DmPage/ComposerFragment) opens a singleton search modal: debounced hx-get into a result grid, click-to-post. Token-driven grid CSS, Tenor attribution, i18n en + es.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs(gif): document LETS_CHAT_TENOR_* env [operator-action] (LC-488)
All checks were successful
Check / clippy + fmt + tests (pull_request) Successful in 5m31s
Create release / Create release from merged PR (pull_request) Has been skipped
check-secrets / TruffleHog (pull_request) Successful in 7s
check-secrets / Kingfisher (push) Successful in 13s
check-secrets / Nosey parker (push) Successful in 6s
check-secrets / Kingfisher (pull_request) Successful in 6s
check-secrets / Nosey parker (pull_request) Successful in 6s
check-secrets / TruffleHog (push) Successful in 7s
36a4a354f1
New optional env (LETS_CHAT_TENOR_API_KEY/_CLIENT_KEY/_CONTENT_FILTER) enables the GIF picker; documented in the CLAUDE.md env table with the SSRF/allowlist + third-party-content notes.

Operator-Action: Optional. Set LETS_CHAT_TENOR_API_KEY (Google Tenor v2) to enable the composer GIF picker; search terms + picks are sent to Tenor and chosen GIFs are fetched + re-served same-origin. Unset leaves the feature hidden.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
longjacksonle scheduled this pull request to auto merge when all checks succeed 2026-06-28 02:14:02 +02:00
longjacksonle deleted branch feat/LC-488-gif-picker 2026-06-28 02:18:25 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!469
No description provided.