This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

appview: upload images as blobs (issues)

Signed-off-by: Wilhelm Berggren <wilhelmberggren@gmail.com>
Signed-off-by: Seongmin Lee <git@boltless.me>

author
Wilhelm Berggren
committer
Seongmin Lee
date (Jul 29, 2026, 1:37 AM +0900) commit 7dcf032e parent 20cd0602 change-id ymxrmrxw
+399 -30
+11
appview/issues/issues.go
··· 284 284 return 285 285 } 286 286 287 + // merge existing pins with new uploads, dropping any removed from body 288 + var existingBlobs []*lexutil.LexBlob 289 + if ex.Value != nil { 290 + if prev, ok := ex.Value.Val.(*tangled.RepoIssue); ok { 291 + existingBlobs = prev.Blobs 292 + } 293 + } 294 + newRecord.Blobs = models.MergeBlobs(existingBlobs, r.PostForm["blobs"], newIssue.Body) 295 + 287 296 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 288 297 Collection: tangled.RepoIssueNSID, 289 298 Repo: user.Did, ··· 784 793 rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err)) 785 794 return 786 795 } 796 + 797 + issue.Blobs = models.ParseBlobs(r.PostForm["blobs"], body) 787 798 788 799 record := issue.AsRecord() 789 800
+61
appview/models/blobs.go
··· 1 + package models 2 + 3 + import ( 4 + "encoding/json" 5 + "strings" 6 + 7 + lexutil "github.com/bluesky-social/indigo/lex/util" 8 + ) 9 + 10 + // decodeBlob parses a single JSON-encoded LexBlob as submitted by the markdown 11 + // editor. Returns ok=false for malformed JSON or a blob without a CID. 12 + func decodeBlob(s string) (*lexutil.LexBlob, bool) { 13 + var b lexutil.LexBlob 14 + if err := json.Unmarshal([]byte(s), &b); err != nil { 15 + return nil, false 16 + } 17 + if !b.Ref.Defined() { 18 + return nil, false 19 + } 20 + return &b, true 21 + } 22 + 23 + // ParseBlobs decodes the blob refs submitted by the editor, keeping only those 24 + // still referenced in body. Referencing them on the record pins them against 25 + // PDS garbage collection; the body filter drops orphans the user removed. 26 + func ParseBlobs(raw []string, body string) []*lexutil.LexBlob { 27 + return MergeBlobs(nil, raw, body) 28 + } 29 + 30 + // MergeBlobs unions already-committed blobs with newly-submitted ones, keeping 31 + // only CIDs still in body. Used on edit to preserve earlier images. 32 + func MergeBlobs(existing []*lexutil.LexBlob, raw []string, body string) []*lexutil.LexBlob { 33 + seen := make(map[string]struct{}) 34 + var out []*lexutil.LexBlob 35 + 36 + keep := func(b *lexutil.LexBlob) { 37 + if b == nil || !b.Ref.Defined() { 38 + return 39 + } 40 + cid := b.Ref.String() 41 + if _, dup := seen[cid]; dup { 42 + return 43 + } 44 + if !strings.Contains(body, cid) { 45 + return 46 + } 47 + seen[cid] = struct{}{} 48 + out = append(out, b) 49 + } 50 + 51 + for _, b := range existing { 52 + keep(b) 53 + } 54 + for _, s := range raw { 55 + if b, ok := decodeBlob(s); ok { 56 + keep(b) 57 + } 58 + } 59 + 60 + return out 61 + }
+112
appview/models/blobs_test.go
··· 1 + package models 2 + 3 + import ( 4 + "encoding/json" 5 + "testing" 6 + 7 + "github.com/ipfs/go-cid" 8 + 9 + lexutil "github.com/bluesky-social/indigo/lex/util" 10 + ) 11 + 12 + // two distinct, valid CIDv1 strings for use as blob refs 13 + const ( 14 + cidA = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi" 15 + cidB = "bafybeihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku" 16 + ) 17 + 18 + func blobJSON(t *testing.T, cidStr, mime string) string { 19 + t.Helper() 20 + c, err := cid.Decode(cidStr) 21 + if err != nil { 22 + t.Fatalf("decode cid: %v", err) 23 + } 24 + b := lexutil.LexBlob{Ref: lexutil.LexLink(c), MimeType: mime, Size: 1234} 25 + raw, err := json.Marshal(b) 26 + if err != nil { 27 + t.Fatalf("marshal blob: %v", err) 28 + } 29 + return string(raw) 30 + } 31 + 32 + func cids(blobs []*lexutil.LexBlob) []string { 33 + out := make([]string, len(blobs)) 34 + for i, b := range blobs { 35 + out[i] = b.Ref.String() 36 + } 37 + return out 38 + } 39 + 40 + func TestParseBlobs(t *testing.T) { 41 + ja := blobJSON(t, cidA, "image/png") 42 + jb := blobJSON(t, cidB, "image/jpeg") 43 + body := "look: ![a](blob+at://did:plc:abc/" + cidA + ") and nothing else" 44 + 45 + t.Run("keeps referenced, drops unreferenced", func(t *testing.T) { 46 + got := ParseBlobs([]string{ja, jb}, body) 47 + if len(got) != 1 || got[0].Ref.String() != cidA { 48 + t.Fatalf("got %v, want [%s]", cids(got), cidA) 49 + } 50 + }) 51 + 52 + t.Run("dedups repeated cid", func(t *testing.T) { 53 + got := ParseBlobs([]string{ja, ja}, body) 54 + if len(got) != 1 { 55 + t.Fatalf("got %d blobs, want 1", len(got)) 56 + } 57 + }) 58 + 59 + t.Run("ignores malformed json", func(t *testing.T) { 60 + got := ParseBlobs([]string{"not json", ja}, body) 61 + if len(got) != 1 { 62 + t.Fatalf("got %d blobs, want 1", len(got)) 63 + } 64 + }) 65 + 66 + t.Run("empty input", func(t *testing.T) { 67 + if got := ParseBlobs(nil, body); got != nil { 68 + t.Fatalf("got %v, want nil", cids(got)) 69 + } 70 + }) 71 + } 72 + 73 + func TestMergeBlobs(t *testing.T) { 74 + ja := blobJSON(t, cidA, "image/png") 75 + jb := blobJSON(t, cidB, "image/jpeg") 76 + 77 + ca, _ := cid.Decode(cidA) 78 + existing := []*lexutil.LexBlob{{Ref: lexutil.LexLink(ca), MimeType: "image/png", Size: 1234}} 79 + 80 + // body references both A (existing) and B (new upload) 81 + body := "![a](blob+at://did:plc:abc/" + cidA + ") ![b](blob+at://did:plc:abc/" + cidB + ")" 82 + 83 + t.Run("union of existing and new, both referenced", func(t *testing.T) { 84 + got := ParseBlobsSet(MergeBlobs(existing, []string{jb}, body)) 85 + if !got[cidA] || !got[cidB] || len(got) != 2 { 86 + t.Fatalf("got %v, want {%s,%s}", got, cidA, cidB) 87 + } 88 + }) 89 + 90 + t.Run("drops existing no longer in body", func(t *testing.T) { 91 + bodyOnlyB := "![b](blob+at://did:plc:abc/" + cidB + ")" 92 + got := MergeBlobs(existing, []string{jb}, bodyOnlyB) 93 + if len(got) != 1 || got[0].Ref.String() != cidB { 94 + t.Fatalf("got %v, want [%s]", cids(got), cidB) 95 + } 96 + }) 97 + 98 + t.Run("no double-count when new equals existing", func(t *testing.T) { 99 + got := MergeBlobs(existing, []string{ja}, body) 100 + if len(got) != 1 || got[0].Ref.String() != cidA { 101 + t.Fatalf("got %v, want [%s]", cids(got), cidA) 102 + } 103 + }) 104 + } 105 + 106 + func ParseBlobsSet(blobs []*lexutil.LexBlob) map[string]bool { 107 + m := make(map[string]bool, len(blobs)) 108 + for _, b := range blobs { 109 + m[b.Ref.String()] = true 110 + } 111 + return m 112 + }
+4
appview/models/issue.go
··· 6 6 "time" 7 7 8 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 + lexutil "github.com/bluesky-social/indigo/lex/util" 9 10 "tangled.org/core/api/tangled" 10 11 "tangled.org/core/appview/pages/markup/sanitizer" 11 12 ) ··· 24 25 Open bool 25 26 Mentions []syntax.DID 26 27 References []syntax.ATURI 28 + // images embedded in Body; referenced on the record to pin them against GC 29 + Blobs []*lexutil.LexBlob 27 30 28 31 // optionally, populate this when querying for reverse mappings 29 32 // like comment counts, parent repo etc. ··· 52 55 Mentions: mentions, 53 56 References: references, 54 57 CreatedAt: i.Created.Format(time.RFC3339), 58 + Blobs: i.Blobs, 55 59 } 56 60 return rec 57 61 }
+101 -27
appview/pages/static/markdown-editor.js
··· 19 19 } 20 20 21 21 #dragHoverClass = "drag-hover"; 22 + #uploadCounter = 0; 23 + // cid -> object URL, to preview an image before its blob is committed 24 + #objectUrls = new Map(); 22 25 23 26 constructor() { 24 27 super(); ··· 40 43 }); 41 44 }); 42 45 43 - // TODO: blob upload support 44 - // this.textarea.addEventListener("paste", (ev) => this.#onPaste(ev)); 45 - // this.textarea.addEventListener("dragover", (ev) => this.#onDragOver(ev)); 46 - // this.textarea.addEventListener("dragleave", (ev) => this.#onDragLeave(ev)); 47 - // this.textarea.addEventListener("drop", (ev) => this.#onDrop(ev)); 46 + this.textarea.addEventListener("paste", (ev) => this.#onPaste(ev)); 47 + this.textarea.addEventListener("dragover", (ev) => this.#onDragOver(ev)); 48 + this.textarea.addEventListener("dragleave", (ev) => this.#onDragLeave(ev)); 49 + this.textarea.addEventListener("drop", (ev) => this.#onDrop(ev)); 50 + 51 + // swap local object URLs into rendered previews (getBlob can't serve uncommitted blobs) 52 + this.addEventListener("htmx:afterSwap", () => this.#hydratePreview()); 53 + } 54 + 55 + disconnectedCallback() { 56 + for (const url of this.#objectUrls.values()) URL.revokeObjectURL(url); 57 + this.#objectUrls.clear(); 58 + } 59 + 60 + #hydratePreview() { 61 + this.querySelectorAll("[data-md-preview] img[data-blob-cid]").forEach(img => { 62 + const url = this.#objectUrls.get(img.dataset.blobCid); 63 + if (url) img.src = url; 64 + }); 65 + } 66 + 67 + // name of the hidden input carrying blob refs back to the form 68 + get #blobName() { 69 + return this.getAttribute("blob-name") || "blobs"; 48 70 } 49 71 50 72 async insertFile() { ··· 52 74 input.type = "file"; 53 75 input.accept = "image/*"; 54 76 input.multiple = true; 55 - input.style.display = "none"; 56 77 input.addEventListener("change", () => { 57 78 if (!input.files) return; 58 79 for (const file of input.files) { 59 80 this.#handleFile(file); 60 81 } 61 82 }); 62 - this.appendChild(input); 63 83 input.click(); 64 - this.removeChild(input); 65 84 } 66 85 67 86 /** @param {ClipboardEvent} ev */ ··· 111 130 const textarea = this.textarea; 112 131 if (!textarea) return; 113 132 114 - const placeholder = `<!-- Uploading "${file.name}"... -->`; 133 + if (!file || !file.type.startsWith("image/")) { 134 + console.warn("skipping non-image file", file && file.name); 135 + return; 136 + } 137 + 138 + let bytes; 139 + try { 140 + bytes = await file.arrayBuffer(); 141 + } catch (e) { 142 + console.error("failed to read file", e); 143 + return; 144 + } 145 + if (bytes.byteLength === 0) { 146 + console.error("skipping empty file", file.name); 147 + return; 148 + } 149 + 150 + const token = ++this.#uploadCounter; 151 + const placeholder = `<!-- Uploading "${file.name}" (#${token})... -->`; 115 152 116 153 this.#insertTextAtCursor(placeholder); 117 154 118 - let blob; 155 + let result; 119 156 try { 120 - blob = await this.#upload(file); 157 + result = await this.#upload(bytes, file.type); 121 158 } catch (e) { 122 - console.error("failed to upload blob", e) 123 - textarea.value = textarea.value.replace(placeholder, `<!-- Failed to upload "${file.name}". -->`); 124 - return 159 + console.error("failed to upload blob", e); 160 + this.#replaceInTextarea(placeholder, `<!-- Failed to upload "${file.name}": ${e.message} -->`); 161 + return; 125 162 } 126 163 127 - // TODO: insert blob itself to form 164 + this.#replaceInTextarea(placeholder, `![Image](${result.uri})`); 165 + this.#addBlobInput(result.blob); 128 166 129 - const cid = blob.ref["$link"] 130 - textarea.value = textarea.value.replace(placeholder, `![Image](blob://${cid})`); 167 + // stash a local object URL keyed by cid (echoed back as data-blob-cid) for Preview 168 + const cid = result.uri.split("/").pop(); 169 + if (cid) { 170 + const prev = this.#objectUrls.get(cid); 171 + if (prev) URL.revokeObjectURL(prev); 172 + this.#objectUrls.set(cid, URL.createObjectURL(new Blob([bytes], { type: file.type }))); 173 + } 131 174 } 132 175 133 176 /** @param {string} text */ ··· 149 192 const newPos = start + text.length; 150 193 textarea.selectionStart = textarea.selectionEnd = newPos; 151 194 195 + this.#fireInput(text); 196 + } 197 + 198 + /** @param {string} needle @param {string} replacement */ 199 + #replaceInTextarea(needle, replacement) { 200 + const textarea = this.textarea; 201 + if (!textarea) return; 202 + // function replacer so `$` in the filename isn't read as a substitution pattern 203 + textarea.value = textarea.value.replace(needle, () => replacement); 204 + this.#fireInput(replacement); 205 + } 206 + 207 + #fireInput(data = "") { 208 + const textarea = this.textarea; 209 + if (!textarea) return; 152 210 textarea.dispatchEvent( 153 - new InputEvent("input", { bubbles: true, inputType: "insertText", data: text }) 211 + new InputEvent("input", { bubbles: true, inputType: "insertText", data }) 154 212 ); 155 - // textarea.dispatchEvent(new Event("input", { bubbles: true })); 156 213 textarea.dispatchEvent(new Event("change", { bubbles: true })); 157 214 } 158 215 159 - /** @param {File} file */ 160 - async #upload(file) { 161 - await new Promise(r => setTimeout(r, 500)); 216 + /** @param {object} blob */ 217 + #addBlobInput(blob) { 218 + const input = document.createElement("input"); 219 + input.type = "hidden"; 220 + input.name = this.#blobName; 221 + input.value = JSON.stringify(blob); 222 + this.appendChild(input); 223 + } 162 224 225 + /** @param {ArrayBuffer} bytes @param {string} contentType */ 226 + async #upload(bytes, contentType) { 163 227 const host = this.getAttribute("host") ?? ""; 164 - const res = await fetch(host + "/xrpc/com.atproto.repo.uploadBlob", { 228 + const res = await fetch(host + "/markup/upload", { 165 229 method: "POST", 166 - body: file, 230 + body: bytes, 167 231 headers: { 168 - "Content-Type": file.type, 232 + "Content-Type": contentType, 169 233 }, 170 234 }); 171 - const output = await res.json(); 172 - return output.blob; 235 + if (!res.ok) { 236 + let msg = `upload failed (${res.status})`; 237 + try { 238 + const err = await res.json(); 239 + if (err && err.error) msg = err.error; 240 + } catch { 241 + // non-JSON error body; keep the status-based message 242 + } 243 + throw new Error(msg); 244 + } 245 + // { blob, did, uri } 246 + return await res.json(); 173 247 } 174 248 }
+1 -1
appview/pages/templates/repo/issues/fragments/newComment.html
··· 10 10 class="group/form " 11 11 > 12 12 <input name="subject-uri" type="hidden" value="{{ .Issue.AtUri }}"> 13 - <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full border border-gray-200 dark:border-gray-700" hx-on:keyup="updateCommentForm()"> 13 + <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full border border-gray-200 dark:border-gray-700" hx-on:input="updateCommentForm()"> 14 14 <div class="text-sm pb-2 text-gray-500 dark:text-gray-400"> 15 15 {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} 16 16 </div>
+18 -1
appview/state/comment.go
··· 5 5 "fmt" 6 6 "net/http" 7 7 "strconv" 8 + "strings" 8 9 "time" 9 10 10 11 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 119 120 markdownBody := tangled.MarkupMarkdown{ 120 121 Text: normalizedBody, 121 122 Original: &body, 122 - Blobs: nil, 123 + Blobs: models.ParseBlobs(r.PostForm["blobs"], normalizedBody), 123 124 } 124 125 125 126 subjectUri, err := syntax.ParseATURI(r.FormValue("subject-uri")) ··· 369 370 s.pages.Notice(w, noticeId, "Failed to create comment. try again later.") 370 371 return 371 372 } 373 + 374 + var existingBlobs []*lexutil.LexBlob 375 + if strings.Contains(normalizedBody, "blob+at://") { 376 + ex, err := comatproto.RepoGetRecord(ctx, client, "", newComment.Collection.String(), newComment.Did.String(), newComment.Rkey.String()) 377 + if err != nil { 378 + l.Error("failed to read existing comment record for blob pinning", "err", err) 379 + s.pages.Notice(w, noticeId, "Failed to update comment, try again later.") 380 + return 381 + } 382 + if ex.Value != nil { 383 + if prev, ok := ex.Value.Val.(*tangled.FeedComment); ok && prev.Body != nil && prev.Body.MarkupMarkdown != nil { 384 + existingBlobs = prev.Body.MarkupMarkdown.Blobs 385 + } 386 + } 387 + } 388 + newComment.Body.Blobs = models.MergeBlobs(existingBlobs, r.PostForm["blobs"], normalizedBody) 372 389 373 390 // update the record first 374 391 exCid := comment.Cid.String()
+81 -1
appview/state/markup.go
··· 1 1 package state 2 2 3 - import "net/http" 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "strings" 8 + 9 + comatproto "github.com/bluesky-social/indigo/api/atproto" 10 + "tangled.org/core/xrpc" 11 + ) 12 + 13 + const maxBlobSize = 1_000_000 4 14 5 15 func (s *State) MarkdownPreview(w http.ResponseWriter, r *http.Request) { 6 16 body := r.FormValue("body") 7 17 s.pages.MarkdownPreviewFragment(w, body) 8 18 } 19 + 20 + // MarkupUpload proxies an image upload to the user's PDS via uploadBlob and 21 + // returns the blob ref plus a blob+at://<did>/<cid> URI. The browser can't call 22 + // uploadBlob directly (the DPoP key lives server-side), so this is the bridge. 23 + func (s *State) MarkupUpload(w http.ResponseWriter, r *http.Request) { 24 + l := s.logger.With("handler", "MarkupUpload") 25 + 26 + user := s.oauth.GetMultiAccountUser(r) 27 + if user == nil { 28 + writeUploadError(w, http.StatusUnauthorized, "not logged in") 29 + return 30 + } 31 + l = l.With("did", user.Did) 32 + 33 + contentType := r.Header.Get("Content-Type") 34 + if !strings.HasPrefix(contentType, "image/") { 35 + writeUploadError(w, http.StatusUnsupportedMediaType, "only image uploads are allowed") 36 + return 37 + } 38 + 39 + // cap the body at the lexicon's maxSize (MaxBytesReader errors past it) 40 + r.Body = http.MaxBytesReader(w, r.Body, maxBlobSize) 41 + defer r.Body.Close() 42 + 43 + client, err := s.oauth.AuthorizedClient(r) 44 + if err != nil { 45 + l.Error("failed to get authorized client", "err", err) 46 + writeUploadError(w, http.StatusBadGateway, "failed to connect to your PDS") 47 + return 48 + } 49 + 50 + // pre-warm DPoP nonce 51 + if _, err := comatproto.ServerGetSession(r.Context(), client); err != nil { 52 + l.Error("failed to pre-warm session", "err", err) 53 + writeUploadError(w, http.StatusInternalServerError, "failed to pre-warm session") 54 + return 55 + } 56 + 57 + resp, err := xrpc.RepoUploadBlob(r.Context(), client, r.Body, contentType) 58 + if err != nil { 59 + // MaxBytesReader's over-limit error surfaces through LexDo 60 + if strings.Contains(err.Error(), "request body too large") { 61 + l.Warn("upload exceeds size limit") 62 + writeUploadError(w, http.StatusRequestEntityTooLarge, "image too large (max 1MB)") 63 + return 64 + } 65 + l.Error("failed to upload blob", "err", err) 66 + writeUploadError(w, http.StatusBadGateway, "failed to upload image to your PDS") 67 + return 68 + } 69 + 70 + blob := resp.Blob 71 + cid := blob.Ref.String() 72 + l.Info("uploaded blob", "cid", cid, "size", blob.Size) 73 + 74 + w.Header().Set("Content-Type", "application/json") 75 + if err := json.NewEncoder(w).Encode(map[string]any{ 76 + "blob": blob, 77 + "did": user.Did, 78 + "uri": fmt.Sprintf("blob+at://%s/%s", user.Did, cid), 79 + }); err != nil { 80 + l.Error("failed to encode upload response", "err", err) 81 + } 82 + } 83 + 84 + func writeUploadError(w http.ResponseWriter, status int, msg string) { 85 + w.Header().Set("Content-Type", "application/json") 86 + w.WriteHeader(status) 87 + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) 88 + }
+1
appview/state/router.go
··· 262 262 263 263 r.With(middleware.AuthMiddleware(s.oauth)).Route("/markup", func(r chi.Router) { 264 264 r.Post("/preview", s.MarkdownPreview) 265 + r.Post("/upload", s.MarkupUpload) 265 266 }) 266 267 r.Get("/profile/popover", s.ProfilePopover) 267 268
+9
input.css
··· 1270 1270 } 1271 1271 } 1272 1272 1273 + markdown-editor.drag-hover textarea { 1274 + outline: dashed 2px #ccc; 1275 + outline-offset: -0.5em; 1276 + } 1277 + markdown-editor:not(.drag-hover) button > .condensed, 1278 + markdown-editor.drag-hover button > .spacious { 1279 + display: none; 1280 + } 1281 + 1273 1282 @layer utilities { 1274 1283 .hit-area { 1275 1284 position: relative;