[feature] [plan] Should be able to paste an image in to the comments and description of issues. #120
Labels
No labels
actions
bug
cli
duplicate
enhancement
fork
forum
git storage
help wanted
insights
invalid
issues
moderation
oidc
ownership transfer
packages
pr/merge protection rules
projects
pull requests
question
releases
sponsorships
tags
webhooks
wiki
wontfix
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
crueber/walhub#120
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Plan this out before you implement it. It's a bigger feature. You need to be able to accept a file dropped on to the textarea, or allow the user to hit the paste shortcut on their keyboard ctrl+v, or command+v. That should upload the file automatically, and insert a markdown style link to the image at the cursor. Given that it'll store as an object, it may need a pass through api to be able to pick up the image. As I said, make sure it's planned before implementing. Get a good review afterward too.
Plan: paste/drop image upload in issue comments/descriptions (Forgejo #120)
0. Objective / scope
Objective: In the issue create form and the issue thread comment composer, a user can
paste (Ctrl+V / Cmd+V) or drag-drop an image file onto the
<textarea>. The file uploadsautomatically and a markdown link
is inserted at the cursor position.In scope:
web/src/pages/IssueNew.jsx(issue description textarea),web/src/pages/Issue.jsx(thread comment textarea).description/comment composers (
web/src/pages/Pull.jsx:134textarea) get it free laterby adopting the same helper — PR adoption is explicitly out of scope for this change
(issue #120 names issues only), but the helper must not be issue-specific.
SVG is REJECTED (scriptable XML; see §9 open question — decision: reject, don't sanitize).
attachments.max_image_bytes, default 8 MiB →413over cap(precedent:
lfs.max_object_bytes16 GiB, releasemax_asset_bytes2 GiB (spec-only),settings 16 KiB; 8 MiB keeps a comment body ≤ 64 KiB meaningful while covering screenshots).
docs/features/02_issues.md§1.2 — images are storedobjects referenced by markdown links, never inline
data:URIs (the sanitizerweb/src/lib/sanitize.js:25-31dropsdata:URLs anyway, so inline paste could never render).Out of scope: video/audio/files, PR bodies, release bodies, avatar uploads, editing/deleting
attachments after upload, thumbnailing, EXIF stripping (note as follow-up), quota per repo
beyond the per-file cap.
1. Storage decision — new bucket family (do NOT reuse release assets)
Decision: new family
repos/<o>/<r>/attachments/<sha256hex>/<name>, raw bytes,Create-only.Justification:
internal/releasesdoes not exist yet (07 is spec-only;ls internal/shows no releasespackage) — there is nothing to reuse, and release assets are tag-bound
(
releases/<tag.json>/assets/<name>) while attachments are tag-independent.Create(create-if-absent); a 412 with matching sha = idempotent success, mismatching shaunder the same key is impossible by construction (key embeds the hash). No CAS loop, no header
object, no locks — the CAS arbitrator pattern from 07 §1.2 reduces to a single
Create.<name>= sanitized original filename (single segment, 1–200 bytes, no/, no leading.,percent-decoded once per 07_api §2 — same rule as 07 §1.2 asset names) for a readable
alt text andContent-Dispositionfilename; the sha segment makes collisionsharmless.
Create-only immutable; removal (if ever offered) is an explicit store-delete like 07 assets.
bytes first, reference second; a crash between upload and comment-submit leaves unreferenced
bytes). No LIST/GC task in v1; a future
attachment-sweepmaintainer kind can reconcileagainst bodies if the family grows (open question §9).
2. API (Seam 1 route provider, both lanes)
New provider in the issues package (
internal/issues, which already fronts both lanes viaHandleper 02 Wave B notes — Seam 1 in code is theserver.ExtraRouteschain, notapi.Lanes; follow the existinginternal/issues/http.gopattern exactly):POST /{o}/{r}/api/attachmentsPOST …/issues/{num}/comments: any signed-in principal who can read the repo; anonymous blocked —requireAuthenticated,internal/issues/service.go:103precedent)Content-Length+X-Walgit-Attachment-Sha256(hex, client-computed viacrypto.subtle.digest— 07 §8 precedent) +Content-Typeadvisory →201 {name, size, sha256, content_type, url}whereurl= the GET byte path belowGET /{o}/{r}/attachments/<sha256>/<name>Upload flow (single-step, NOT the release two-step — images are ≤ 8 MiB, bounded, human-rate):
io.LimitReader(r.Body, max_image_bytes+1); one byte over →413plain text(06 §4.3/LFS §6.2 precedent: per-feature limits, no global body middleware).
<cache.dir>/attachments-spool/, LFS §6.2 pattern ininternal/server/lfs.go:408-457), hashing sha256 in the copy loop (io.TeeReader—no second pass, no full buffering).
X-Walgit-Attachment-Sha256matches computed (mismatch → 400), size ≤ cap,sniff type from magic bytes (
net/http.DetectContentTypeon the first 512 B + GIF/PNG/JPEG/WebPmagic check; stdlib only, dependency law) — mismatch with allowlist or SVG detected →
415plain text. Extension and client
Content-Typeare ignored for the verdict.Createthe object atattachments/<sha>/<sanitized-name>(dedup: 412 → re-GET, same bytes =idempotent
201with the same body; cannot mismatch since the key is the hash).at the cursor (see §7).Upload and comment-submit are decoupled (upload does not reference any issue number —
usable from both the new-issue form and thread comments, and from a form whose issue
doesn't exist yet).
Wire rules per 07_api §2: JSON success, plain-text errors,
[]never null (n/a here),RFC 3339 if timestamps added, arrays
[]nevernull. Cache class: POSTno-store;byte GET per §3. Add the new routes to the discovery
endpoints[](07 §8 / 14 §14.12 —adding a route without updating discovery is a bug).
3. Serving reads (auth-gated, private cache class)
Decision: auth-gated reads at read level, static contract with
privatecache class.GET /{o}/{r}/attachments/…requires read on the repo (P6 order; anonymous iffanonymous_read). Rationale: issue bodies inherit repo visibility (14 defers per-repoprivate-read ACLs but the
require_readhook is now specified per the Wave A amendment);serving issue images publicly while the issue text is gated would leak private-repo
screenshots via URL guessing. Content-addressed URLs are unguessable but not a security
boundary — gate them.
ETag= store version,If-None-Match→304,
Range/If-Range→ 206/416, HEAD supported,Accept-Ranges: bytes,Content-Type= sniffed type (never the client claim),X-Content-Type-Options: nosniff.Deviation from 06 §5 in one token:
Cache-Control: private, max-age=31536000, immutable(not
public) — immutable bytes, but authenticated reads must not sit in shared caches.renderMarkdown(web/src/lib/markdown.js:20-21)already emits
<img src="…">for, and the sanitizer (sanitize.js:13-16,25-31)allows
img[src,alt,title]with relative/same-origin URLs — a same-origin relativeattachment URL (
/<o>/<r>/attachments/<sha>/<name>) passes the allowlist and renderswith the browser's session cookies. No sanitizer change required.
4. Abuse controls
rejected with 415 (decision: reject — SVG is active content; sanitizing XML server-side
is a new attack surface and client-side re-encoding is out of scope).
attachments.max_image_bytes, host config, validated like othersize keys);
Content-Lengthrequired (missing → 411/400 plain text, LFS precedent);io.LimitReader+ drain-abandon above cap; never buffer the object (fixed 1 MiB copybuffer, hash in-loop).
progress UI is the composer's inline uploading state (law 7 — no silent spinner: placeholder
text
replaced on completion, error surfaced verbatim per the plain-texterror contract).
if abused.
5. Concurrency
store's create-if-absent on the content-addressed key — concurrent identical uploads both
succeed idempotently; distinct bytes never collide.
sequence is lock-free straight-line I/O on the request goroutine, bounded by the 8 MiB cap.
(≤ 8 MiB, single object, no striping) — control-plane transport is fine; no bulk-pool
involvement. The byte GETs are static-contract streams like LFS GETs.
### Concurrencysubsection required in the implementing doc amendment (law 3: everyconcurrent design carries one — here: "hazard: none beyond Create races; avoidance: content
addressing + idempotent 412 handling").
6. EVIDENCE sketch
No hot-path (git push/fetch) impact: uploads ride the collaboration surface (P5: LIST/index
rules are for pages, and this path uses neither). Evidence entry only if a claim is made:
docs/EVIDENCE.mdentry with harness ininternal/devtools/measuring upload round trips(expect: 1 PUT + 0 GET on dedup-hit… actually 1 Create attempt; 1 GET on 412-revalidate) —
optional, only if the change claims a budget.
7. Frontend (SolidJS SPA + Tailwind, D-WEB-6; dependency-free SDK)
web/sdk/src/attachments.js:uploadAttachment(file|Blob) → {url,…}(computes sha256 via
crypto.subtle.digest, streams raw POST with the sha header —07 §8
uploadAssetprecedent), wired throughReposClientlikeattachIssues.web/src/lib/attachUpload.js(pure logic, headless-testable per D-WEB-4):insertAtCursor(textarea, text)+uploadWithPlaceholder({file, signal})semantics —kept DOM-thin: the pages'
onPaste/onDrophandlers call it, it returns the markdown tosplice.
node --testunit tests for cursor-splice + placeholder replace (no DOM needed).IssueNew.jsx+Issue.jsxtextareas:onPaste(reade.clipboardData.files/ items, image-kind only),
onDrop(e.dataTransfer.files,preventDefault), per-file:insert
![uploading <name>…]()placeholder at cursor → upload → replace placeholder with; failure → replace with error text +reportError. Multiple files:sequential inserts (no unbounded fan-out; each upload is its own request).
renderMarkdown+sanitize) renders the image immediately once the markdownlands — no renderer change (see §3).
.markdown-body img { max-width: 100% }already exists (
web/css/repo.css:42/web/src/ui.css— verify the SolidJS-era rulecovers
prose-smcontainers; addmax-w-fullif not).8. Acceptance criteria
internal/issues(or newinternal/attachmentsprovider — decide at implementation;recommendation: live in
internal/issuessince auth gates + bucket prefix are the issuesfamily's, registered as its own
RouteProvider) holds ≥ 95% statement coverage(
make cover), table-driven httptest for every handler + every rejection (bad sha, overcap, SVG, non-image magic, missing length, anonymous 401, insufficient-read 403/404),
-raceclean.submit → thread page renders the image; same for thread comment composer; real Chromium
drive (AGENTS verification ladder #8: navigate, paste via CDP, screenshot, console clean)
in dark (default) and light themes.
data:-URI paste still rejected/dropped by the sanitizer (existing tests); SVG upload → 415.git statusclean of anything but the intended files; docs updated in the same change:docs/features/02_issues.md(attachments subsection + Decisions entry), the provider doc'swire table, discovery
endpoints[];DEVIATIONS.mduntouched unless a deviation is taken(private-cache-class token is a §5 clarification, recorded in 06's Decisions section).
make fmt && make vetclean; dependency budget untouched (stdlib only server-side, no newnpm deps).
9. Open questions / risks (need reviewer decision before implementation)
serving user SVGs same-origin is script execution in our origin. Confirm.
pasted then quoted into a public repo's issue breaks for readers without access to the
source repo (same as GitHub's behavior with private assets — acceptable?). Confirm no
signed-URL/public-alias scheme is wanted in v1.
v1 keeps them (harmless, Create-only). Is a future sweep/GC task acceptable tech debt, or
should upload be two-phase (spool → attach-on-comment-submit with server-side reference)?
Two-phase doubles the wire surface; recommend v1 as planned.
internal/issues(recommended — auth + prefix affinity) vs newinternal/attachments. Either satisfies Seam 1; reviewer picks.attachments.max_image_bytes— confirm thenumber and the section name (new
[attachments]section vs extending an existing one).srcthrough; browsersupport assumed — no server work. Not a risk, noted for the browser test matrix.
bytes exist by uploading and comparing URLs. Content is unreadable without read access
(§3), so the oracle reveals at most existence. Accept (same as release-asset dedup)?
10. Review ask
Reviewers: please confirm §9 items 1–5 explicitly (they pin wire shape, status codes, config
keys, and package home — all frozen-after-merge per 14 §14.12), plus whether an EVIDENCE
entry is wanted for the upload path.
Review: image paste/drop plan for #120 (review-only, no code changed)
Verdict: proceed-with-fixes — the architecture is sound and almost every factual claim I checked against CODE holds up. Three items are BLOCKING (all wire/shape-freeze or seam issues that must be decided before merge, per the plan's own §10 note that §9 items pin frozen shape), the rest are should-fix/nits to fold into the implementing change.
What I verified against code (not docs)
internal/releasesdoes not exist (ls internal/shows no releases package; onlyinternal/pullsetc.). The 07 "precedents" (X-Walgit-Asset-Sha256,uploadAsset, two-step flow) are spec-only (docs/features/07_releases_stars.md,08_ui_sdk.md);web/sdk/src/has noreleases.js/social.js. So: nothing to reuse, newrepos/<o>/<r>/attachments/<sha>/<name>family is the right call, and the "07 §8 precedent" citations should be relabeled spec-following rather than code-following. (nit)internal/server/lfs.go:228-244(lfsPut) is exactly the claimed shape:ensureDir(cacheDir("lfs-spool"))→os.CreateTemp→copyTee(io.LimitReader(...), tmp, hash)→ verify-before-write, 413 overlfs.max_object_bytes(default 16 GiB,internal/config/config.go:220,373). Reusable shape, correct citation (line numbers have drifted slightly from the plan's408-457, which is the read-through half — update refs).internal/issues/service.go:199-205(AddComment):requireAuthenticated+requireRead, matching the plan's "authenticated + read, anonymous blocked" upload gate. Reusing the same helpers gives parity by construction. Good.internal/issues/model.go:45,199-205(MaxBodyBytes = 64<<10,validateBody). Markdownlinks are ~80 chars, so the budget comfortably holds screenshots' worth of references.web/src/lib/markdown.js:20-21emits<img src alt title>;web/src/lib/sanitize.js:13-16,25-31allowsimg[src,alt,title]and dropsdata:/javascript:viasafeUrl. A same-origin relative attachment URL renders with session cookies. Thedata:-URI claim (§0) is correct.internal/issues/http.go:22-58:Handle(w,r) boolfronting the core mux on both lanes,decodeSegmentper-segment decoding — the plan's "followhttp.goexactly" instruction is actionable as written.StreamEvent(internal/issues/issues.go:100-106) is{Name, Repo, IssueNum}only; bodies travel exclusively in authed timeline GETs. So gating the byte GET at read level closes the leak: the only cross-boundary artifact is a URL string, and guessing it yields 401/403/404 without source-repo read. §3's rationale is correct.PutCreateis create-if-absent with 412-styleErrKindPreconditionFailedon exists (internal/store/store.go:61,118-153); hash-in-key means a 412 can only be same-bytes → idempotent 201 without re-read. No CAS loop needed. Correct.web/css/repo.css:42does NOT apply to the live UI. The SolidJS entry (web/src/index.jsx:6) imports onlyweb/src/ui.css, whose.markdown-bodyblock (lines 73-81) has noimgrule. The plan's hedge ("verify…; addmax-w-fullif not") resolves to: theif notbranch triggers — state it outright (should-fix #5 below).BLOCKING (must be resolved before merge — frozen shape/seam)
B1.
Content-Length: required → 411/400contradicts its cited precedent and breaks chunked clients.lfsPut(internal/server/lfs.go:212-274) never checksContent-Length; the cap is enforced purely byio.LimitReader(max+1)→ 413. There is no 411 anywhere ininternal/server. RequiringContent-Lengthbreaks chunked encodings for zero security benefit (the LimitReader already bounds the read). Ruling: drop the requirement; LimitReader + 413 exactly like LFS. Status codes freeze after merge — decide now.B2. "Add the new routes to the discovery
endpoints[]" is unimplementable as written. Core discovery is derived from theinternal/apiroute table (internal/api/discovery.go:23-37,routes.goExposeflags). Feature routes via theHandle(w,r) boolExtraRoutes chain (internal/issues,internal/identity) register nothing there — I verified neither package referencesExpose/discovery, so existing issue routes are already absent fromendpoints[]. The plan must pick one: (a) rule that repo-scoped feature routes are exempt from the 14 §14.12 discovery sentence (with a one-line Decisions entry saying so — my recommendation; the SDK builds repo-scoped paths statically anyway,web/sdk/src/issues.js), or (b) amend the discovery seam so extras can contribute templates. Cannot merge with the instruction in its current form since it demands something no feature does.B3. Name where the byte-GET static-contract code lives — it cannot be reused.
serveLFSObject/conditionals are methods onserver.Server(internal/server/static.go:15-60,135+);internal/issuesimports onlyserver/auth,store,identity,git— importingserverwould be an upward import into the package that wires it (law 8; likely a straight import cycle). So ETag/304/Range/206/416/HEAD handling must be reimplemented (~60 lines) insideinternal/issues(or a new leaf). The plan says "static contract otherwise verbatim" without naming the home. Ruling: implement in-package, table-test it like the LFS static tests, and say so in the plan. (Related: persist the sniffed type viaPutOptions.ContentTypeatCreate— the object "carries no metadata" per 07 §1.2, so the stored content type is the only way the GET can emit it. WithX-Content-Type-Options: nosniff, servingapplication/octet-streamwill get the<img>blocked in Chrome — this detail is load-bearing for rendering, not cosmetic.)Should-fix
S1. Filename → markdown injection.
built from an unsanitized filename breaks on],(,)in names (e.g.a](b.png). TheinsertAtCursorhelper must escape]/(/)in alt text (and the<name>URL segment must be percent-encoded at insert time). One-line rule + unit test in the headlessattachUpload.jssuite.S2. Spell out the non-lane GET branch in
Handle. The byte GET (/{o}/{r}/attachments/<sha>/<name>) is outsideapi|api-browser, but currentHandleonly claimssegs[2] == api|api-browserpaths (http.go:50-56). The plan needs the new branch + collision analysis (there is none: no git sub-path is literallyattachments, and it sits after{o}/{r}so no repo-name clash) + a fallthrough test (false → core 404, cf.final_test.go:393-396style).S3. Config key needs explicit
config.goscope.attachments.max_image_bytes(new[attachments]section — consistent with[lfs] max_object_bytes) means: struct +8<<20default + validation + docs, all in the same change (law 12). Noteinternal/setupdoes not exist in code yet, so no setup-schema work is possible today — but record the key in the Decisions entry as setup-schema-pending so it isn't lost. The 8 MiB value itself is fine (screenshot-covering, orphan-bounding, keeps bodies meaningful).S4.
crypto.subtleneeds a fallback position.crypto.subtle.digestis undefined on non-secure origins (http://LAN hosts — a real walhub deployment shape). If the sha header stays mandatory (400 on mismatch), uploads break there with no recourse. Ruling: makeX-Walgit-Attachment-Sha256optional-when-present (server always hashes the spool; verifies only if the client sent a value) — strictly weaker client requirement, same server-side integrity, and it keeps the 07-spec shape for secure contexts. Decide before merge since it changes the 400 contract.S5. Just say
ui.cssneeds the img rule. Verification above: add.markdown-body img { @apply max-w-full h-auto; }(dark-theme check rides the existing Chromium drive). Delete theweb/css/repo.csscitation — that file isn't loaded by the SPA and citing it will confuse the implementer.S6. PR adoption is not "free later".
web/src/pages/Pull.jsx:124renders comment bodies as plain text (whitespace-pre-wrap), not markdown — adopting the upload helper there later also requires switching PR rendering torenderMarkdown+sanitize(plus the release-body surface, spec-only). One line in §0/§7 noting the dependency; otherwise the "must not be issue-specific" requirement is still right and sufficient. (Third composerPull.jsx:134is correctly identified;Setup.jsx/Keys.jsx/Settings.jsxtextareas are config/keys/paste surfaces — correctly excluded.)S7. Abuse ceiling belongs in the doc. Authenticated-user orphan flood (≤8 MiB × attempts) is the same class as comment spam — no new anonymous vector since upload reuses the comment gate. Accept v1 as planned, but write the ceiling down (expected bytes/day at human rate) so the future
attachment-sweepmaintainer kind has a trigger criterion. Retention math: even 100 abandoned uploads/day = ≤800 MiB/day worst case, human-rate reality is orders of magnitude less; Create-only immutable objects need no frozen-list entry (14 §14.11 rule 2 covers overwritable keys only — the plan is right).Nits
07 §8citations are spec-following, not code precedent (nointernal/releases, nouploadAssetin the SDK) — relabel to avoid sending the implementer hunting for code that isn't there. LFS-spool line refs should point atlfs.go:228-244, not408-457(the latter is the read-through half).repos/<o>/<r>/…), so cross-repo probing is impossible; a same-repo prober already holds read+auth and learns existence-only, never bytes. One sentence covers it.Rulings on §9 (explicit, as asked)
internal/issues— auth-gate affinity, one coverage gate, no new wiring. Either satisfies Seam 1; this one is cheaper.[attachments] max_image_bytes— confirmed, with the S3 config-scope note.Acceptance delta (add to §8)
private, immutable+ sniffedContent-Type+nosniff(B3); 411-must-not-exist (B1).node --testfor alt-text escaping + placeholder replace (S1);ui.cssimg rule + dark/light Chromium paste-drive with console clean (S5).make cover≥95% on the touched package;make fmt && make vet; docs updated same-change (02_issues attachments subsection + Decisions entries in 02/06/14-as-touched).Review method: read the plan end to end, then verified each load-bearing claim against code (
internal/issues/service.go,http.go,model.go,issues.go;internal/server/lfs.go,static.go;internal/store/store.go;internal/api/discovery.go;web/src/lib/markdown.js,sanitize.js,ui.css,index.jsx;web/src/pages/IssueNew.jsx,Issue.jsx,Pull.jsx;web/sdk/src/core.js,issues.js;internal/config/config.go). No production code written, nothing committed,git statusuntouched (pre-existing worktree modifications left as found).Plan revision R1 (incorporates plan-review findings — R1 wins on conflict)
Blocking rulings (normative)
Content-Length-required/411. Cap enforced purely byio.LimitReader(max+1)→ 413, exactly likelfsPut. No 411 anywhere.endpoints[]; the SDK builds repo-scoped paths statically). No discovery-seam amendment.internal/issues; importingserverwould be upward/cycle). Table-test 304/206/416/HEAD +private, immutable+ sniffedContent-Type+nosniff. Persist the sniffed type viaPutOptions.ContentTypeatCreate(else Chrome blocks<img>under nosniff). Add the non-laneattachments/<sha>/<name>branch inHandle+ collision analysis + fallthrough test.Should-fix adoptions (all)
]/(/)in alt text, percent-encode name segment at insert; unit tests inattachUpload.jssuite.[attachments] max_image_bytes= 8 MiB: struct + default + validation + docs same change; record as setup-schema-pending in Decisions (nointernal/setupin code yet).X-Walgit-Attachment-Sha256OPTIONAL-when-present (server always hashes spool; verifies only if sent) — keepshttp://LAN hosts working..markdown-body img { max-w-full h-auto }toweb/src/ui.css; drop theweb/css/repo.csscitation (not loaded by the SPA).Pull.jsxrendering to markdown+sanitize (dependency, out of scope).attachment-sweeptrigger in Decisions; orphans kept in v1 (no two-phase).§9 rulings (all confirmed)
internal/issues. 5. Cap/key confirmed (8 MiB,[attachments] max_image_bytes). 6. GIF/WebP in browser matrix, no server work. 7. Dedup oracle accepted (repo-scoped, existence-only).Nits folded in
07 citations relabeled spec-following; LFS refs →
lfs.go:228-244; composer pre-checks projected 64 KiB length before upload (or surfaces 400 verbatim); no EVIDENCE entry.Implemented in PR #129 (#129) — ready for review, not merged.
Highlights: POST /{o}/{r}/api/attachments + GET|HEAD byte route (ChainRepo/HandleRepo seam — one correction from the review: a non-lane Handle branch would be dead code, verified live), [attachments] max_image_bytes = 8MiB, SDK + shared composer helper, glue in IssueNew + CommentComposer (Issue.jsx only). Verification: 96.1% cover on internal/issues (-race), 288/288 node tests, real-Chromium paste drive dark+light with rendered images and zero console errors. Deviations from plan: none (R1 followed verbatim); one seam refinement documented in the 02 Decisions entry.
Review: PR #129 (feat/issue-120) vs R1 spec — PASS with 2 micro-fixes (pushed as
69c759a).R1 blocking rulings
Routing seam verdict (law 8)
Sanctioned seam, not invented: 07 amendment explicitly blesses 'ExtraRoutes chain for lanes plus ChainRepo for the byte family'. Composition wires both (cmd/walhub/collab.go:166-179). No new seam, no doc amendment needed beyond the 02 Decisions entry (present). The B3/S2 'Handle branch' wording is superseded by the documented correction.
§9 + S1–S7
SVG->415 (sniffImageType rejects; tests: svg/xml/truncated/riff-not-webp), client sha optional-when-present + server always hashes (normalizeSHA256/S4 test), 8 MiB cap + [attachments] struct/default/generic-ByteSize validation + TOML round-trip test (config.go:263-264,430-432; validate_test.go:327-353), read-gated bytes via requireRead (anon 401 upload; private-repo anon 401 / stranger 403 on GET+POST; anon public-GET 200 = read-gate parity — attachments_test.go:212-260, 523-547), S1 escapeAlt + PathEscape URL + headless tests (attachUpload.js:46-63, attach-upload.test.js:21-29), 64 KiB projectedLengthOk pre-check + verbatim reportError (attachUpload.js:97-100,143-170), placeholder->replacePlaceholder flow, sanitizer/markdown untouched (not in diff), ui.css .markdown-body img max-w-full h-auto, no new deps (stdlib-only backend; package.json untouched), auth = AddComment parity (requireAuthenticated+requireRead), orphans-kept + 800 MiB/day ceiling + attachment-sweep named in Decisions, docs accurate (wire tables in 02 §7/§12, 06 Decisions private-cache entry, 11 config row).
Reviewer fixes pushed (
69c759a, re-tested)Verification (scratch worktree /tmp/wt120; main worktree untouched read-only)
MERGE RECOMMENDATION: ready to merge.
Implemented per plan+R1 in PR #129 (review: R1 compliance verified point-by-point, routing seam sanctioned; 96.1%/95.5% + 288/288), merged. Closing.