From 7dcf032e75d30b0b15176468efa596f07a4a1f17 Mon Sep 17 00:00:00 2001 From: Wilhelm Berggren Date: Sun, 26 Jul 2026 19:01:04 +0000 Subject: [PATCH] appview: upload images as blobs (issues) Signed-off-by: Wilhelm Berggren Signed-off-by: Seongmin Lee --- input.css | 9 +++++++++ appview/issues/issues.go | 11 +++++++++++ appview/models/blobs.go | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ appview/models/blobs_test.go | 112 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ appview/models/issue.go | 4 ++++ appview/state/comment.go | 19 ++++++++++++++++++- appview/state/markup.go | 82 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- appview/state/router.go | 1 + appview/pages/static/markdown-editor.js | 128 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------- appview/pages/templates/repo/issues/fragments/newComment.html | 2 +- 10 file(s) changed, 399 insertion(s)(+), 30 deletion(s)(-) diff --git a/input.css b/input.css --- a/input.css +++ b/input.css @@ -1270,6 +1270,15 @@ } } +markdown-editor.drag-hover textarea { + outline: dashed 2px #ccc; + outline-offset: -0.5em; +} +markdown-editor:not(.drag-hover) button > .condensed, +markdown-editor.drag-hover button > .spacious { + display: none; +} + @layer utilities { .hit-area { position: relative; diff --git a/appview/issues/issues.go b/appview/issues/issues.go --- a/appview/issues/issues.go +++ b/appview/issues/issues.go @@ -284,6 +284,15 @@ return } + // merge existing pins with new uploads, dropping any removed from body + var existingBlobs []*lexutil.LexBlob + if ex.Value != nil { + if prev, ok := ex.Value.Val.(*tangled.RepoIssue); ok { + existingBlobs = prev.Blobs + } + } + newRecord.Blobs = models.MergeBlobs(existingBlobs, r.PostForm["blobs"], newIssue.Body) + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoIssueNSID, Repo: user.Did, @@ -784,6 +793,8 @@ rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err)) return } + + issue.Blobs = models.ParseBlobs(r.PostForm["blobs"], body) record := issue.AsRecord() diff --git a/appview/models/blobs.go b/appview/models/blobs.go new file mode 100644 --- /dev/null +++ b/appview/models/blobs.go @@ -0,0 +1,61 @@ +package models + +import ( + "encoding/json" + "strings" + + lexutil "github.com/bluesky-social/indigo/lex/util" +) + +// decodeBlob parses a single JSON-encoded LexBlob as submitted by the markdown +// editor. Returns ok=false for malformed JSON or a blob without a CID. +func decodeBlob(s string) (*lexutil.LexBlob, bool) { + var b lexutil.LexBlob + if err := json.Unmarshal([]byte(s), &b); err != nil { + return nil, false + } + if !b.Ref.Defined() { + return nil, false + } + return &b, true +} + +// ParseBlobs decodes the blob refs submitted by the editor, keeping only those +// still referenced in body. Referencing them on the record pins them against +// PDS garbage collection; the body filter drops orphans the user removed. +func ParseBlobs(raw []string, body string) []*lexutil.LexBlob { + return MergeBlobs(nil, raw, body) +} + +// MergeBlobs unions already-committed blobs with newly-submitted ones, keeping +// only CIDs still in body. Used on edit to preserve earlier images. +func MergeBlobs(existing []*lexutil.LexBlob, raw []string, body string) []*lexutil.LexBlob { + seen := make(map[string]struct{}) + var out []*lexutil.LexBlob + + keep := func(b *lexutil.LexBlob) { + if b == nil || !b.Ref.Defined() { + return + } + cid := b.Ref.String() + if _, dup := seen[cid]; dup { + return + } + if !strings.Contains(body, cid) { + return + } + seen[cid] = struct{}{} + out = append(out, b) + } + + for _, b := range existing { + keep(b) + } + for _, s := range raw { + if b, ok := decodeBlob(s); ok { + keep(b) + } + } + + return out +} diff --git a/appview/models/blobs_test.go b/appview/models/blobs_test.go new file mode 100644 --- /dev/null +++ b/appview/models/blobs_test.go @@ -0,0 +1,112 @@ +package models + +import ( + "encoding/json" + "testing" + + "github.com/ipfs/go-cid" + + lexutil "github.com/bluesky-social/indigo/lex/util" +) + +// two distinct, valid CIDv1 strings for use as blob refs +const ( + cidA = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi" + cidB = "bafybeihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku" +) + +func blobJSON(t *testing.T, cidStr, mime string) string { + t.Helper() + c, err := cid.Decode(cidStr) + if err != nil { + t.Fatalf("decode cid: %v", err) + } + b := lexutil.LexBlob{Ref: lexutil.LexLink(c), MimeType: mime, Size: 1234} + raw, err := json.Marshal(b) + if err != nil { + t.Fatalf("marshal blob: %v", err) + } + return string(raw) +} + +func cids(blobs []*lexutil.LexBlob) []string { + out := make([]string, len(blobs)) + for i, b := range blobs { + out[i] = b.Ref.String() + } + return out +} + +func TestParseBlobs(t *testing.T) { + ja := blobJSON(t, cidA, "image/png") + jb := blobJSON(t, cidB, "image/jpeg") + body := "look: ![a](blob+at://did:plc:abc/" + cidA + ") and nothing else" + + t.Run("keeps referenced, drops unreferenced", func(t *testing.T) { + got := ParseBlobs([]string{ja, jb}, body) + if len(got) != 1 || got[0].Ref.String() != cidA { + t.Fatalf("got %v, want [%s]", cids(got), cidA) + } + }) + + t.Run("dedups repeated cid", func(t *testing.T) { + got := ParseBlobs([]string{ja, ja}, body) + if len(got) != 1 { + t.Fatalf("got %d blobs, want 1", len(got)) + } + }) + + t.Run("ignores malformed json", func(t *testing.T) { + got := ParseBlobs([]string{"not json", ja}, body) + if len(got) != 1 { + t.Fatalf("got %d blobs, want 1", len(got)) + } + }) + + t.Run("empty input", func(t *testing.T) { + if got := ParseBlobs(nil, body); got != nil { + t.Fatalf("got %v, want nil", cids(got)) + } + }) +} + +func TestMergeBlobs(t *testing.T) { + ja := blobJSON(t, cidA, "image/png") + jb := blobJSON(t, cidB, "image/jpeg") + + ca, _ := cid.Decode(cidA) + existing := []*lexutil.LexBlob{{Ref: lexutil.LexLink(ca), MimeType: "image/png", Size: 1234}} + + // body references both A (existing) and B (new upload) + body := "![a](blob+at://did:plc:abc/" + cidA + ") ![b](blob+at://did:plc:abc/" + cidB + ")" + + t.Run("union of existing and new, both referenced", func(t *testing.T) { + got := ParseBlobsSet(MergeBlobs(existing, []string{jb}, body)) + if !got[cidA] || !got[cidB] || len(got) != 2 { + t.Fatalf("got %v, want {%s,%s}", got, cidA, cidB) + } + }) + + t.Run("drops existing no longer in body", func(t *testing.T) { + bodyOnlyB := "![b](blob+at://did:plc:abc/" + cidB + ")" + got := MergeBlobs(existing, []string{jb}, bodyOnlyB) + if len(got) != 1 || got[0].Ref.String() != cidB { + t.Fatalf("got %v, want [%s]", cids(got), cidB) + } + }) + + t.Run("no double-count when new equals existing", func(t *testing.T) { + got := MergeBlobs(existing, []string{ja}, body) + if len(got) != 1 || got[0].Ref.String() != cidA { + t.Fatalf("got %v, want [%s]", cids(got), cidA) + } + }) +} + +func ParseBlobsSet(blobs []*lexutil.LexBlob) map[string]bool { + m := make(map[string]bool, len(blobs)) + for _, b := range blobs { + m[b.Ref.String()] = true + } + return m +} diff --git a/appview/models/issue.go b/appview/models/issue.go --- a/appview/models/issue.go +++ b/appview/models/issue.go @@ -6,6 +6,7 @@ "time" "github.com/bluesky-social/indigo/atproto/syntax" + lexutil "github.com/bluesky-social/indigo/lex/util" "tangled.org/core/api/tangled" "tangled.org/core/appview/pages/markup/sanitizer" ) @@ -24,6 +25,8 @@ Open bool Mentions []syntax.DID References []syntax.ATURI + // images embedded in Body; referenced on the record to pin them against GC + Blobs []*lexutil.LexBlob // optionally, populate this when querying for reverse mappings // like comment counts, parent repo etc. @@ -52,6 +55,7 @@ Mentions: mentions, References: references, CreatedAt: i.Created.Format(time.RFC3339), + Blobs: i.Blobs, } return rec } diff --git a/appview/state/comment.go b/appview/state/comment.go --- a/appview/state/comment.go +++ b/appview/state/comment.go @@ -5,6 +5,7 @@ "fmt" "net/http" "strconv" + "strings" "time" comatproto "github.com/bluesky-social/indigo/api/atproto" @@ -119,7 +120,7 @@ markdownBody := tangled.MarkupMarkdown{ Text: normalizedBody, Original: &body, - Blobs: nil, + Blobs: models.ParseBlobs(r.PostForm["blobs"], normalizedBody), } subjectUri, err := syntax.ParseATURI(r.FormValue("subject-uri")) @@ -369,6 +370,22 @@ s.pages.Notice(w, noticeId, "Failed to create comment. try again later.") return } + + var existingBlobs []*lexutil.LexBlob + if strings.Contains(normalizedBody, "blob+at://") { + ex, err := comatproto.RepoGetRecord(ctx, client, "", newComment.Collection.String(), newComment.Did.String(), newComment.Rkey.String()) + if err != nil { + l.Error("failed to read existing comment record for blob pinning", "err", err) + s.pages.Notice(w, noticeId, "Failed to update comment, try again later.") + return + } + if ex.Value != nil { + if prev, ok := ex.Value.Val.(*tangled.FeedComment); ok && prev.Body != nil && prev.Body.MarkupMarkdown != nil { + existingBlobs = prev.Body.MarkupMarkdown.Blobs + } + } + } + newComment.Body.Blobs = models.MergeBlobs(existingBlobs, r.PostForm["blobs"], normalizedBody) // update the record first exCid := comment.Cid.String() diff --git a/appview/state/markup.go b/appview/state/markup.go --- a/appview/state/markup.go +++ b/appview/state/markup.go @@ -1,8 +1,88 @@ package state -import "net/http" +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + comatproto "github.com/bluesky-social/indigo/api/atproto" + "tangled.org/core/xrpc" +) + +const maxBlobSize = 1_000_000 func (s *State) MarkdownPreview(w http.ResponseWriter, r *http.Request) { body := r.FormValue("body") s.pages.MarkdownPreviewFragment(w, body) +} + +// MarkupUpload proxies an image upload to the user's PDS via uploadBlob and +// returns the blob ref plus a blob+at:/// URI. The browser can't call +// uploadBlob directly (the DPoP key lives server-side), so this is the bridge. +func (s *State) MarkupUpload(w http.ResponseWriter, r *http.Request) { + l := s.logger.With("handler", "MarkupUpload") + + user := s.oauth.GetMultiAccountUser(r) + if user == nil { + writeUploadError(w, http.StatusUnauthorized, "not logged in") + return + } + l = l.With("did", user.Did) + + contentType := r.Header.Get("Content-Type") + if !strings.HasPrefix(contentType, "image/") { + writeUploadError(w, http.StatusUnsupportedMediaType, "only image uploads are allowed") + return + } + + // cap the body at the lexicon's maxSize (MaxBytesReader errors past it) + r.Body = http.MaxBytesReader(w, r.Body, maxBlobSize) + defer r.Body.Close() + + client, err := s.oauth.AuthorizedClient(r) + if err != nil { + l.Error("failed to get authorized client", "err", err) + writeUploadError(w, http.StatusBadGateway, "failed to connect to your PDS") + return + } + + // pre-warm DPoP nonce + if _, err := comatproto.ServerGetSession(r.Context(), client); err != nil { + l.Error("failed to pre-warm session", "err", err) + writeUploadError(w, http.StatusInternalServerError, "failed to pre-warm session") + return + } + + resp, err := xrpc.RepoUploadBlob(r.Context(), client, r.Body, contentType) + if err != nil { + // MaxBytesReader's over-limit error surfaces through LexDo + if strings.Contains(err.Error(), "request body too large") { + l.Warn("upload exceeds size limit") + writeUploadError(w, http.StatusRequestEntityTooLarge, "image too large (max 1MB)") + return + } + l.Error("failed to upload blob", "err", err) + writeUploadError(w, http.StatusBadGateway, "failed to upload image to your PDS") + return + } + + blob := resp.Blob + cid := blob.Ref.String() + l.Info("uploaded blob", "cid", cid, "size", blob.Size) + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]any{ + "blob": blob, + "did": user.Did, + "uri": fmt.Sprintf("blob+at://%s/%s", user.Did, cid), + }); err != nil { + l.Error("failed to encode upload response", "err", err) + } +} + +func writeUploadError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) } diff --git a/appview/state/router.go b/appview/state/router.go --- a/appview/state/router.go +++ b/appview/state/router.go @@ -262,6 +262,7 @@ r.With(middleware.AuthMiddleware(s.oauth)).Route("/markup", func(r chi.Router) { r.Post("/preview", s.MarkdownPreview) + r.Post("/upload", s.MarkupUpload) }) r.Get("/profile/popover", s.ProfilePopover) diff --git a/appview/pages/static/markdown-editor.js b/appview/pages/static/markdown-editor.js --- a/appview/pages/static/markdown-editor.js +++ b/appview/pages/static/markdown-editor.js @@ -19,6 +19,9 @@ } #dragHoverClass = "drag-hover"; + #uploadCounter = 0; + // cid -> object URL, to preview an image before its blob is committed + #objectUrls = new Map(); constructor() { super(); @@ -40,11 +43,30 @@ }); }); - // TODO: blob upload support - // this.textarea.addEventListener("paste", (ev) => this.#onPaste(ev)); - // this.textarea.addEventListener("dragover", (ev) => this.#onDragOver(ev)); - // this.textarea.addEventListener("dragleave", (ev) => this.#onDragLeave(ev)); - // this.textarea.addEventListener("drop", (ev) => this.#onDrop(ev)); + this.textarea.addEventListener("paste", (ev) => this.#onPaste(ev)); + this.textarea.addEventListener("dragover", (ev) => this.#onDragOver(ev)); + this.textarea.addEventListener("dragleave", (ev) => this.#onDragLeave(ev)); + this.textarea.addEventListener("drop", (ev) => this.#onDrop(ev)); + + // swap local object URLs into rendered previews (getBlob can't serve uncommitted blobs) + this.addEventListener("htmx:afterSwap", () => this.#hydratePreview()); + } + + disconnectedCallback() { + for (const url of this.#objectUrls.values()) URL.revokeObjectURL(url); + this.#objectUrls.clear(); + } + + #hydratePreview() { + this.querySelectorAll("[data-md-preview] img[data-blob-cid]").forEach(img => { + const url = this.#objectUrls.get(img.dataset.blobCid); + if (url) img.src = url; + }); + } + + // name of the hidden input carrying blob refs back to the form + get #blobName() { + return this.getAttribute("blob-name") || "blobs"; } async insertFile() { @@ -52,16 +74,13 @@ input.type = "file"; input.accept = "image/*"; input.multiple = true; - input.style.display = "none"; input.addEventListener("change", () => { if (!input.files) return; for (const file of input.files) { this.#handleFile(file); } }); - this.appendChild(input); input.click(); - this.removeChild(input); } /** @param {ClipboardEvent} ev */ @@ -111,23 +130,47 @@ const textarea = this.textarea; if (!textarea) return; - const placeholder = ``; + if (!file || !file.type.startsWith("image/")) { + console.warn("skipping non-image file", file && file.name); + return; + } + + let bytes; + try { + bytes = await file.arrayBuffer(); + } catch (e) { + console.error("failed to read file", e); + return; + } + if (bytes.byteLength === 0) { + console.error("skipping empty file", file.name); + return; + } + + const token = ++this.#uploadCounter; + const placeholder = ``; this.#insertTextAtCursor(placeholder); - let blob; + let result; try { - blob = await this.#upload(file); + result = await this.#upload(bytes, file.type); } catch (e) { - console.error("failed to upload blob", e) - textarea.value = textarea.value.replace(placeholder, ``); - return + console.error("failed to upload blob", e); + this.#replaceInTextarea(placeholder, ``); + return; } - // TODO: insert blob itself to form + this.#replaceInTextarea(placeholder, `![Image](${result.uri})`); + this.#addBlobInput(result.blob); - const cid = blob.ref["$link"] - textarea.value = textarea.value.replace(placeholder, `![Image](blob://${cid})`); + // stash a local object URL keyed by cid (echoed back as data-blob-cid) for Preview + const cid = result.uri.split("/").pop(); + if (cid) { + const prev = this.#objectUrls.get(cid); + if (prev) URL.revokeObjectURL(prev); + this.#objectUrls.set(cid, URL.createObjectURL(new Blob([bytes], { type: file.type }))); + } } /** @param {string} text */ @@ -149,26 +192,57 @@ const newPos = start + text.length; textarea.selectionStart = textarea.selectionEnd = newPos; + this.#fireInput(text); + } + + /** @param {string} needle @param {string} replacement */ + #replaceInTextarea(needle, replacement) { + const textarea = this.textarea; + if (!textarea) return; + // function replacer so `$` in the filename isn't read as a substitution pattern + textarea.value = textarea.value.replace(needle, () => replacement); + this.#fireInput(replacement); + } + + #fireInput(data = "") { + const textarea = this.textarea; + if (!textarea) return; textarea.dispatchEvent( - new InputEvent("input", { bubbles: true, inputType: "insertText", data: text }) + new InputEvent("input", { bubbles: true, inputType: "insertText", data }) ); - // textarea.dispatchEvent(new Event("input", { bubbles: true })); textarea.dispatchEvent(new Event("change", { bubbles: true })); } - /** @param {File} file */ - async #upload(file) { - await new Promise(r => setTimeout(r, 500)); + /** @param {object} blob */ + #addBlobInput(blob) { + const input = document.createElement("input"); + input.type = "hidden"; + input.name = this.#blobName; + input.value = JSON.stringify(blob); + this.appendChild(input); + } + /** @param {ArrayBuffer} bytes @param {string} contentType */ + async #upload(bytes, contentType) { const host = this.getAttribute("host") ?? ""; - const res = await fetch(host + "/xrpc/com.atproto.repo.uploadBlob", { + const res = await fetch(host + "/markup/upload", { method: "POST", - body: file, + body: bytes, headers: { - "Content-Type": file.type, + "Content-Type": contentType, }, }); - const output = await res.json(); - return output.blob; + if (!res.ok) { + let msg = `upload failed (${res.status})`; + try { + const err = await res.json(); + if (err && err.error) msg = err.error; + } catch { + // non-JSON error body; keep the status-based message + } + throw new Error(msg); + } + // { blob, did, uri } + return await res.json(); } } diff --git a/appview/pages/templates/repo/issues/fragments/newComment.html b/appview/pages/templates/repo/issues/fragments/newComment.html --- a/appview/pages/templates/repo/issues/fragments/newComment.html +++ b/appview/pages/templates/repo/issues/fragments/newComment.html @@ -10,7 +10,7 @@ class="group/form " > -
+
{{ template "user/fragments/picHandleLink" .LoggedInUser.Did }}
-- tangled.sh