diff --git a/input.css b/input.css --- a/input.css +++ b/input.css @@ -1232,6 +1232,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/ingester.go b/appview/ingester.go --- a/appview/ingester.go +++ b/appview/ingester.go @@ -1648,7 +1648,7 @@ var references []syntax.ATURI if comment.Body.Original != nil { - _, references = i.MentionsResolver.Resolve(ctx, *comment.Body.Original) + _, references, _ = i.MentionsResolver.Resolve(ctx, *comment.Body.Original) } tx, err := i.Db.Begin() diff --git a/appview/issues/issues.go b/appview/issues/issues.go --- a/appview/issues/issues.go +++ b/appview/issues/issues.go @@ -204,7 +204,7 @@ newIssue := issue newIssue.Title = r.FormValue("title") newIssue.Body = r.FormValue("body") - newIssue.Mentions, newIssue.References = rp.mentionsResolver.Resolve(r.Context(), newIssue.Body) + newIssue.Mentions, newIssue.References, _ = rp.mentionsResolver.Resolve(r.Context(), newIssue.Body) if err := rp.validator.ValidateIssue(newIssue); err != nil { l.Error("validation error", "err", err) @@ -665,7 +665,7 @@ }) case http.MethodPost: body := r.FormValue("body") - mentions, references := rp.mentionsResolver.Resolve(r.Context(), body) + mentions, references, _ := rp.mentionsResolver.Resolve(r.Context(), body) issue := &models.Issue{ RepoDid: syntax.DID(f.RepoDid), diff --git a/appview/mentions/resolver.go b/appview/mentions/resolver.go --- a/appview/mentions/resolver.go +++ b/appview/mentions/resolver.go @@ -33,10 +33,10 @@ } } -func (r *Resolver) Resolve(ctx context.Context, source string) ([]syntax.DID, []syntax.ATURI) { +func (r *Resolver) Resolve(ctx context.Context, source string) ([]syntax.DID, []syntax.ATURI, []syntax.CID) { l := r.logger.With("method", "Resolve") - rawMentions, rawRefs := markup.FindReferences(r.config.Core.AppviewHost, source) + rawMentions, rawRefs, blobs := markup.FindReferences(r.config.Core.AppviewHost, source) l.Debug("found possible references", "mentions", rawMentions, "refs", rawRefs) idents := r.idResolver.ResolveIdents(ctx, rawMentions) @@ -63,5 +63,5 @@ } l.Debug("found references", "refs", aturiRefs) - return mentions, aturiRefs + return mentions, aturiRefs, blobs } diff --git a/appview/pulls/create.go b/appview/pulls/create.go --- a/appview/pulls/create.go +++ b/appview/pulls/create.go @@ -261,7 +261,7 @@ } } - mentions, references := s.mentionsResolver.Resolve(r.Context(), body) + mentions, references, _ := s.mentionsResolver.Resolve(r.Context(), body) rkey := tid.TID() @@ -484,7 +484,7 @@ } rkey := tid.TID() - mentions, references := s.mentionsResolver.Resolve(ctx, body) + mentions, references, _ := s.mentionsResolver.Resolve(ctx, body) now := time.Now() diff --git a/appview/pulls/edit.go b/appview/pulls/edit.go --- a/appview/pulls/edit.go +++ b/appview/pulls/edit.go @@ -36,7 +36,7 @@ newPull := *pull newPull.Title = r.FormValue("title") newPull.Body = r.FormValue("body") - newPull.Mentions, newPull.References = s.mentionsResolver.Resolve(ctx, newPull.Body) + newPull.Mentions, newPull.References, _ = s.mentionsResolver.Resolve(ctx, newPull.Body) // edit an atproto record client, err := s.oauth.AuthorizedClient(r) diff --git a/appview/state/comment.go b/appview/state/comment.go --- a/appview/state/comment.go +++ b/appview/state/comment.go @@ -2,8 +2,10 @@ import ( "bytes" + "encoding/json" "fmt" "net/http" + "slices" "strconv" "time" @@ -102,12 +104,30 @@ // TODO(boltless): normalize markdown body normalizedBody := body - mentions, references := s.mentionsResolver.Resolve(ctx, body) + mentions, references, blobCIDs := s.mentionsResolver.Resolve(ctx, body) + + rawBlobs := r.Form["blob"] + blobs := make([]*lexutil.LexBlob, 0, len(rawBlobs)) + if len(rawBlobs) > 0 { + blobs = make([]*lexutil.LexBlob, len(rawBlobs)); + for _, rawBlob := range rawBlobs { + var blob lexutil.LexBlob + if err := json.Unmarshal([]byte(rawBlob), &blob); err != nil { + l.Error("failed to decode blob", "err", err) + s.pages.Notice(w, noticeId, "Failed to decode blob object. Please try again later.") + return + } + if !slices.Contains(blobCIDs, syntax.CID(blob.Ref.String())) { + continue + } + blobs = append(blobs, &blob) + } + } markdownBody := tangled.MarkupMarkdown{ Text: normalizedBody, Original: &body, - Blobs: nil, + Blobs: blobs, } subjectUri, err := syntax.ParseATURI(r.FormValue("subject-uri")) @@ -332,14 +352,32 @@ // TODO(boltless): normalize markdown body normalizedBody := body - _, references := s.mentionsResolver.Resolve(ctx, body) + _, references, blobCIDs := s.mentionsResolver.Resolve(ctx, body) + + rawBlobs := r.Form["blob"] + blobs := make([]*lexutil.LexBlob, 0, len(rawBlobs)) + if len(rawBlobs) > 0 { + blobs = make([]*lexutil.LexBlob, len(rawBlobs)); + for _, rawBlob := range rawBlobs { + var blob lexutil.LexBlob + if err := json.Unmarshal([]byte(rawBlob), &blob); err != nil { + l.Error("failed to decode blob", "err", err) + s.pages.Notice(w, noticeId, "Failed to decode blob object. Please try again later.") + return + } + if !slices.Contains(blobCIDs, syntax.CID(blob.Ref.String())) { + continue + } + blobs = append(blobs, &blob) + } + } now := time.Now() newComment := comment newComment.Body = tangled.MarkupMarkdown{ Text: normalizedBody, Original: &body, - Blobs: nil, + Blobs: blobs, } newComment.Edited = &now if err := newComment.Validate(); err != nil { diff --git a/appview/state/router.go b/appview/state/router.go --- a/appview/state/router.go +++ b/appview/state/router.go @@ -234,6 +234,10 @@ r.With(middleware.AuthMiddleware(s.oauth)).Route("/markup", func(r chi.Router) { r.Post("/preview", s.MarkdownPreview) }) + r.Route("/xrpc", func(r chi.Router) { + r.With(middleware.AuthMiddleware(s.oauth)).Post("/com.atproto.repo.uploadBlob", s.XrpcComAtprotoRepoUploadBlob) + }) + r.Get("/profile/popover", s.ProfilePopover) r.Route("/profile", func(r chi.Router) { diff --git a/appview/state/xrpc.go b/appview/state/xrpc.go new file mode 100644 --- /dev/null +++ b/appview/state/xrpc.go @@ -0,0 +1,71 @@ +package state + +import ( + "encoding/json" + "errors" + "net/http" + + comatproto "github.com/bluesky-social/indigo/api/atproto" + indigoxrpc "github.com/bluesky-social/indigo/xrpc" + "tangled.org/core/xrpc" +) + +// proxy requests to {pds}/xrpc/com.atproto.repo.uploadBlob +func (s *State) XrpcComAtprotoRepoUploadBlob(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + defer r.Body.Close() + + l := s.logger.With("handler", "XrpcComAtprotoRepoUploadBlob") + + client, err := s.oauth.AuthorizedClient(r) + if err != nil { + if err := json.NewEncoder(w).Encode(&indigoxrpc.XRPCError{ + ErrStr: "AuthMissing", + Message: "Authentication Required", + }); err != nil { + l.Error("failed to encode json", "err", err) + } + return + } + + // pre-warm DPoP nonce + if _, err := comatproto.ServerGetSession(ctx, client); err != nil { + l.Error("failed to pre-warm session", "err", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + out, err := xrpc.RepoUploadBlob(ctx, client, r.Body, r.Header.Get("Content-Type")) + if err != nil { + var xrpcErr *indigoxrpc.Error + if ok := errors.As(err, &xrpcErr); ok { + l.Error("xrpc response", "err", xrpcErr) + + w.WriteHeader(xrpcErr.StatusCode) + var xrpcBody *indigoxrpc.XRPCError + if ok := errors.As(xrpcErr.Wrapped, &xrpcBody); ok { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(xrpcBody); err != nil { + l.Error("failed to encode json", "err", err) + } + } + } else { + l.Error("failed to parse xrpc error body", "err", err) + + w.WriteHeader(http.StatusInternalServerError) + if err := json.NewEncoder(w).Encode(&indigoxrpc.XRPCError{ + ErrStr: "InternalError", + Message: "Internal server error", + }); err != nil { + l.Error("failed to encode json", "err", err) + } + } + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(out); err != nil { + l.Error("failed to encode json", "err", err) + } +} diff --git a/appview/pages/markup/reference_link.go b/appview/pages/markup/reference_link.go --- a/appview/pages/markup/reference_link.go +++ b/appview/pages/markup/reference_link.go @@ -19,10 +19,11 @@ // like issues, PRs, comments or even @-mentions // This function doesn't actually check for the existence of records in the DB // or the PDS; it merely returns a list of what are presumed to be references. -func FindReferences(host string, source string) ([]string, []models.ReferenceLink) { +func FindReferences(host string, source string) ([]string, []models.ReferenceLink, []syntax.CID) { var ( refLinkSet = make(map[models.ReferenceLink]struct{}) mentionsSet = make(map[string]struct{}) + blobsSet = make(map[syntax.CID]struct{}) md = NewMarkdown(host) sourceBytes = []byte(source) root = md.Parser().Parse(text.NewReader(sourceBytes)) @@ -54,12 +55,20 @@ } } return ast.WalkSkipChildren, nil + case ast.KindImage: + n := n.(*ast.Image) + if rawCid, found := strings.CutPrefix(string(n.Destination), "blob://"); found { + if cid, err := syntax.ParseCID(rawCid); err == nil { + blobsSet[cid] = struct{}{} + } + } } return ast.WalkContinue, nil }) mentions := slices.Collect(maps.Keys(mentionsSet)) references := slices.Collect(maps.Keys(refLinkSet)) - return mentions, references + blobs := slices.Collect(maps.Keys(blobsSet)) + return mentions, references, blobs } func parseTangledLink(baseHost string, urlStr string) *models.ReferenceLink { diff --git a/appview/pages/markup/reference_link_test.go b/appview/pages/markup/reference_link_test.go --- a/appview/pages/markup/reference_link_test.go +++ b/appview/pages/markup/reference_link_test.go @@ -34,7 +34,7 @@ } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - handles, refLinks := markup.FindReferences("127.0.0.1:3000", tt.source) + handles, refLinks, _ := markup.FindReferences("127.0.0.1:3000", tt.source) assert.ElementsMatch(t, tt.wantHandles, handles) assert.ElementsMatch(t, tt.wantRefLinks, refLinks) }) 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 @@ -40,10 +40,10 @@ }); }); - // 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)); } async insertFile() { @@ -114,19 +114,25 @@ this.#insertTextAtCursor(placeholder); - let blob; try { - blob = await this.#upload(file); + const blob = await this.#upload(file); + + // append json-encoded blob to form + const input = document.createElement("input"); + input.name = "blob"; + input.type = "text"; + input.hidden = true; + input.value = JSON.stringify(blob); + this.appendChild(input); + + // insert image to markdown + const cid = blob.ref["$link"] + textarea.value = textarea.value.replace(placeholder, `![Image](blob://${cid})`); } catch (e) { console.error("failed to upload blob", e) textarea.value = textarea.value.replace(placeholder, ``); return } - - // TODO: insert blob itself to form - - const cid = blob.ref["$link"] - textarea.value = textarea.value.replace(placeholder, `![Image](blob://${cid})`); } /** @param {string} text */ @@ -168,6 +174,7 @@ }, }); const output = await res.json(); + console.warn("com.atproto.repo.uploadBlob out:", output); return output.blob; } }