feat(forge,core): fetch and decode authenticated images #69

Merged
deadstyle merged 7 commits from feature/65-authenticated-images into develop 2026-10-08 22:50:40 -05:00
Owner

Summary

Lets the app fetch and decode images the forge serves behind a login (attachments in PR and issue bodies). Groundwork for inline images (#60); nothing user-visible changes yet.

Changes

  • forge.AssetReader (optional capability, forge.ActAssets) and a SameOrigin check. The gitea adapter sends Authorization: token only to the session's origin and strips it on any redirect that leaves that origin. Only 2xx succeeds; a redirect loop past 10 hops errors; errors never carry a query string.
  • forgetest.Fake implements it with an embedded demo PNG, and the contract suite exercises it for both the Fake and gitea.
  • core.Service.Image and PeekImage (returns (image.Image, bool, error)): resolve against <host>/<owner>/<repo>/, refuse off-host and userinfo URLs with no I/O, 20 s timeout, 10 MiB read cap, 40 MP cap, PNG/JPEG/GIF decode, downscale to 1600 px, 16-entry LRU that also caches failures and is cleared by r.
  • ADR 0016 (inline images via kitty Unicode placeholders), docs/architecture.md Forge listing, ADR 0014 Consequences.

Decisions

  • Opus: kitty Unicode placeholders; images load only from the connected forge (PM answer on #60); fetch is behind the shared semaphore, decode is not capped (callers request only on-screen images).
  • Opus planner: gitea strips Authorization itself in CheckRedirect, because Go forwards it to the same hostname on any port. PeekImage returns (image.Image, bool, error) to satisfy revive's error-return rule.
  • ADR number is 0016: 0015 was taken by #67.

Test plan

  • make build, make test (race), go vet, gofumpt, golangci-lint v2.14.0 (built locally with the repo's toolchain; 0 issues). shellcheck is not installed locally; CI covers it.
  • Every new test was mutation-checked in a throwaway worktree. Known gaps: the production PNG import is masked by other test imports, the post-decode deadline recheck (AC-B17) and the OpenAsset body close are untested.
  • Token auth on /attachments/ could not be tested against a live host (none available); the httptest fixture covers header, foreign origin and redirect.

Follow-ups

  • #66 terminal layer; integration ticket after #65 and #66 merge.

Refs #65

🤖 Generated with Claude Code

https://claude.ai/code/session_01APHERaPcnmg6YuAkRuoDYq

## Summary Lets the app fetch and decode images the forge serves behind a login (attachments in PR and issue bodies). Groundwork for inline images (#60); nothing user-visible changes yet. ## Changes - `forge.AssetReader` (optional capability, `forge.ActAssets`) and a `SameOrigin` check. The gitea adapter sends `Authorization: token` only to the session's origin and strips it on any redirect that leaves that origin. Only 2xx succeeds; a redirect loop past 10 hops errors; errors never carry a query string. - `forgetest.Fake` implements it with an embedded demo PNG, and the contract suite exercises it for both the Fake and gitea. - `core.Service.Image` and `PeekImage` (returns `(image.Image, bool, error)`): resolve against `<host>/<owner>/<repo>/`, refuse off-host and userinfo URLs with no I/O, 20 s timeout, 10 MiB read cap, 40 MP cap, PNG/JPEG/GIF decode, downscale to 1600 px, 16-entry LRU that also caches failures and is cleared by `r`. - ADR 0016 (inline images via kitty Unicode placeholders), `docs/architecture.md` Forge listing, ADR 0014 Consequences. ## Decisions - Opus: kitty Unicode placeholders; images load only from the connected forge (PM answer on #60); fetch is behind the shared semaphore, decode is not capped (callers request only on-screen images). - Opus planner: gitea strips Authorization itself in `CheckRedirect`, because Go forwards it to the same hostname on any port. `PeekImage` returns `(image.Image, bool, error)` to satisfy revive's error-return rule. - ADR number is 0016: 0015 was taken by #67. ## Test plan - `make build`, `make test` (race), `go vet`, `gofumpt`, `golangci-lint` v2.14.0 (built locally with the repo's toolchain; 0 issues). shellcheck is not installed locally; CI covers it. - Every new test was mutation-checked in a throwaway worktree. Known gaps: the production PNG import is masked by other test imports, the post-decode deadline recheck (AC-B17) and the OpenAsset body close are untested. - Token auth on `/attachments/` could not be tested against a live host (none available); the httptest fixture covers header, foreign origin and redirect. ## Follow-ups - #66 terminal layer; integration ticket after #65 and #66 merge. Refs #65 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01APHERaPcnmg6YuAkRuoDYq
The gitea adapter opens attachments on the session's host and drops Authorization on every redirect hop off that origin. The Fake serves seeded assets and the embedded demo PNG.

Refs #65
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APHERaPcnmg6YuAkRuoDYq
core.Service.Image fetches an image the session's forge serves behind a login, decodes it within the byte and pixel limits and downscales it to 1600 px. PeekImage reads the 16-entry LRU without I/O, and r clears it.

Refs #65
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APHERaPcnmg6YuAkRuoDYq
ADR 0016 covers kitty placeholders, the same-origin token rule, the image cache exception to ADR 0007 and the pure markdown boundary. architecture.md lists AssetReader and the image cache; ADR 0014 now points to ADR 0016 instead of saying images are only a link.

Refs #65
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APHERaPcnmg6YuAkRuoDYq
Cover the forge asset capability, SameOrigin, the gitea OpenAsset token and redirect rules, the Fake asset seam, and the core image service (resolve and refuse, decode, downscale, size limits, LRU, clear, concurrency). They fail to build until the seams land.

Refs #65

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APHERaPcnmg6YuAkRuoDYq
Wire Fixture.Asset into RunContract for both the Fake and the gitea
adapter: decode, off-origin refusal (hostname, port, scheme) and
unseeded ErrNotFound, skipped for a forge that is not an AssetReader.

Refs #65

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APHERaPcnmg6YuAkRuoDYq
Refs #65

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APHERaPcnmg6YuAkRuoDYq
test(images): make format, scheme, redirect-loop, timeout and thin-image tests catch regressions
All checks were successful
ci / check (pull_request) Successful in 1m9s
f120fafd7c
The JPEG and GIF bodies now come from testdata, so the core test binary no longer registers their decoders and removing the production import fails the format test. PNG is still registered by the forgetest contract, so it stays unprovable from core tests. The scheme-case test builds its URL without parsing, because url.Parse already lowercases the scheme. The redirect-loop test counts requests against the 10-redirect limit. The timeout test ends the body cleanly so only the post-read check can report the deadline. The thin-image row covers the max(1) clamp.

Refs #65

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APHERaPcnmg6YuAkRuoDYq
deadstyle merged commit 6de43c0e67 into develop 2026-10-08 22:50:40 -05:00
deadstyle deleted branch feature/65-authenticated-images 2026-10-08 22:50:40 -05:00
Sign in to join this conversation.
No description provided.