Attachment object-store pipeline: serve tutorial repo attachments like images (#1931) - #1967
Merged
Conversation
… image test case (#1931)
- HTML neutering unconditional: text/html always served as text/plain, even with download:true - Filename header-injection: sanitize CR/LF in addition to quotes - Add tests for HTML+download and filename injection cases
) - srv/lib/attachment-source-handler.js: attachmentSourceHandler (GET /content/attachment-source?u=&dl=) + warmAttachmentsLive. Streams stored attachment; self-heals via single-flight ingestAttachment on miss; sets Content-Type, Content-Disposition (via dispositionFor), X-Content-Type-Options, Cache-Control, X-Content-Source headers. - test/unit/attachment-source-endpoint.test.js: 4 cases (inline serve, dl=1 download, 400 missing u, deterministic 404 disallowed host). Red-by-design: route not registered until Task 11.
srv/lib/attachment-ingest-handler.js — bytes-in POST endpoint mirroring image-ingest-handler.js. Adds ext-MIME fallback (extToMime when content-type absent/octet-stream), filename extraction, and 25MB cap. test/unit/attachment-ingest-endpoint.test.js — RED by design until Task 11 registers POST /content/attachment in server.js.
Replace unanchored regex /raw\.githubusercontent\.com/.test(u) with isRawGithubHost() which compares new URL(u).hostname exactly, preventing token leakage to lookalike hosts like raw.githubusercontent.com.evil.com. Export helper for unit-testability; add 4 isRawGithubHost tests.
…1931) Add GET /content/attachment-source and POST /content/attachment to ALLOWLIST_ONLY_ON_SRV in check-srv-qa-route-drift.ts. Both routes are dark-launched (no approuter proxy route yet); mirrors the existing image-source/image allowlist rationale.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements
docs/superpowers/specs/2026-08-21-tutorial-attachment-object-store-design.md.What & why
Fixes the #1931 follow-up: relative attachment links in tutorial markdown —
[doc](EX2_DDLX_ZRAP100_C_TRAVELTP.txt)— rendered as bare relative anchors and 404'd (the browser resolved them against the page path).resolveImageURLsrewrites images but not plain links, so repo attachments (.txt/.zip/.pdf/.csv/.json/...) were never served. This adds an attachment pipeline that mirrors the image pipeline minus resize, ingesting repo attachments into the object store and serving them through a CAP endpoint the approuter already routes.Scope of the bug: 44 relative attachment links across 24 tutorials.
How it works (end-to-end)
scripts/parsers/attachment-links.ts, wired incompose.ts): rewrites relative allowlisted[text](file.ext)links toraw.githubusercontent.com/...(fence-aware, idempotent, skips images/absolute/anchor/mailto/root/../).hugo/layouts/_default/_markup/render-link.html): wraps raw-GitHub attachment links to/content/attachment-source?u=<enc>plus a&dl=1download sibling; all other links pass through unchanged. No approuter change (existing^/content/(.*)$route carries it).db/tutorial-assets.cdsTutorialAssets+srv/lib/attachment-store.cjs):@cap-js/attachmentscomposition on the existing shared S3 binding, keyed by sourceUrl.attachment-ingest.cjs+attachment-ingest-handler.js,POST /content/attachment, CONTENT_API_KEY-gated) and warm on publish (content-publish-session.js, fire-and-forget).attachment-source-handler.js,GET /content/attachment-source, anonymous, self-heal single-flight): text inline, binaries download,.htmlneutered totext/plain+nosniff,?dl=1forces download.scripts/backfill-attachments.ts,npm run backfill-attachments).Decisions
TutorialAssetsentity (clean separation from images)..htmlnever executed.Testing
build:cdsemitsTutorialAssetstables cleanly. Full suite: only the ~2 known pre-existing selfie failures (none attachment-related). All srv-qa/route/public-endpoint guards green..txtto endpoint + download sibling; external/relative/anchor links unchanged.CONTENT_API_KEY): live e2e —npm run build:all, thenCAP_BASE_URL=<srv> CONTENT_API_KEY=<key> npm run backfill-attachments, thencurl -Ithe RAP100EX2_DDLX_ZRAP100_C_TRAVELTP.txtvia/content/attachment-source(expect 200text/plaininline;&dl=1gives attachment).Follow-ups (out of scope here)
scripts/backfill-images.ts:68has the same unanchored-hostname token-fallback flaw this PR fixed inbackfill-attachments.ts(isRawGithubHost) — port it over.## Prerequisitestables are not resolved (body links only, v1).