From 666247ab9c47cf33800a28ea50c24611d7b98a4e Mon Sep 17 00:00:00 2001 From: bryan newbold Date: Fri, 12 Apr 2024 13:51:27 -0700 Subject: [PATCH] progress on embedr --- bskyweb/cmd/embedr/handlers.go | 223 +++++++++++++++++++++++++ bskyweb/cmd/embedr/server.go | 90 +--------- bskyweb/embedr-static/iframe-resize.js | 1 + bskyweb/embedr-templates/home.html | 9 +- bskyweb/static.go | 3 + 5 files changed, 240 insertions(+), 86 deletions(-) create mode 100644 bskyweb/cmd/embedr/handlers.go create mode 100644 bskyweb/embedr-static/iframe-resize.js diff --git a/bskyweb/cmd/embedr/handlers.go b/bskyweb/cmd/embedr/handlers.go new file mode 100644 index 0000000000..0c51ba204a --- /dev/null +++ b/bskyweb/cmd/embedr/handlers.go @@ -0,0 +1,223 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + appbsky "github.com/bluesky-social/indigo/api/bsky" + "github.com/bluesky-social/indigo/atproto/syntax" + + "github.com/labstack/echo/v4" +) + +var ErrPostNotFound = errors.New("post not found") +var ErrPostNotPublic = errors.New("post is not publicly accessible") + +func (srv *Server) getBlueskyPost(ctx context.Context, did syntax.DID, rkey syntax.RecordKey) (*appbsky.FeedDefs_PostView, error) { + + // requires two fetches: first fetch profile + pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, did.String()) + if err != nil { + log.Warnf("failed to fetch profile for: %s\t%v", did, err) + // TODO: detect 404, specifically? + return nil, ErrPostNotFound + } + for _, label := range pv.Labels { + if label.Src == pv.Did && label.Val == "!no-unauthenticated" { + return nil, ErrPostNotPublic + } + } + + // then fetch the post thread (with extra context) + uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", did, rkey) + tpv, err := appbsky.FeedGetPostThread(ctx, srv.xrpcc, 1, 0, uri) + if err != nil { + log.Warnf("failed to fetch post: %s\t%v", uri, err) + // TODO: detect 404, specifically? + return nil, ErrPostNotFound + } + + if tpv.Thread.FeedDefs_BlockedPost != nil { + return nil, ErrPostNotPublic + } else if tpv.Thread.FeedDefs_ThreadViewPost.Post == nil { + return nil, ErrPostNotFound + } + return tpv.Thread.FeedDefs_ThreadViewPost.Post, nil +} + +func (srv *Server) WebHome(c echo.Context) error { + return c.Render(http.StatusOK, "home.html", nil) +} + +type OEmbedResponse struct { + Type string `json:"type"` + Version string `json:"version"` + AuthorName string `json:"author_name,omitempty"` + AuthorURL string `json:"author_url,omitempty"` + ProviderName string `json:"provider_url,omitempty"` + CacheAge int `json:"cache_age,omitempty"` + Width int `json:"width,omitempty"` + Height *int `json:"height,omitempty"` + HTML string `json:"html,omitempty"` +} + +func (srv *Server) parseBlueskyURL(ctx context.Context, raw string) (*syntax.ATURI, error) { + + if raw == "" { + return nil, fmt.Errorf("empty url") + } + + // first try simple AT-URI + uri, err := syntax.ParseATURI(raw) + if nil == err { + return &uri, nil + } + + // then try bsky.app post URL + u, err := url.Parse(raw) + if err != nil { + return nil, err + } + if u.Hostname() != "bsky.app" { + return nil, fmt.Errorf("only bsky.app URLs currently supported") + } + pathParts := strings.Split(u.Path, "/") // NOTE: pathParts[0] will be empty string + if len(pathParts) != 5 || pathParts[1] != "profile" || pathParts[3] != "post" { + return nil, fmt.Errorf("only bsky.app post URLs currently supported") + } + atid, err := syntax.ParseAtIdentifier(pathParts[2]) + if err != nil { + return nil, err + } + rkey, err := syntax.ParseRecordKey(pathParts[4]) + if err != nil { + return nil, err + } + var did syntax.DID + if atid.IsHandle() { + ident, err := srv.dir.Lookup(ctx, *atid) + if err != nil { + return nil, err + } + did = ident.DID + } else { + did, err = atid.AsDID() + if err != nil { + return nil, err + } + } + + // TODO: don't really need to re-parse here, if we had test coverage + aturi, err := syntax.ParseATURI(fmt.Sprintf("at://%s/app.bsky.feed.post/%s", did, rkey)) + if err != nil { + return nil, err + } else { + return &aturi, nil + } +} + +func (srv *Server) postEmbedHTML(post *appbsky.FeedDefs_PostView) string { + aturi, err := syntax.ParseATURI(post.Uri) + if err != nil { + log.Error("bad AT-URI in reponse", "aturi", aturi) + } + // TODO: could add language, maybe other fiels here? + // XXX: should actually use html/template for this render + return fmt.Sprintf( + "", + fmt.Sprintf("https://embed.bsky.app/embed/%s/app.bsky.feed.post/%s", post.Author.Did, aturi.RecordKey()), + post.Uri, + post.Cid, + "https://embed.bsky.app/iframe-resize.js", + ) +} + +func (srv *Server) WebOEmbed(c echo.Context) error { + formatParam := c.QueryParam("format") + if formatParam != "" && formatParam != "json" { + return c.String(http.StatusNotImplemented, "Unsupported oEmbed format: "+formatParam) + } + + width := 550 + maxWidthParam := c.QueryParam("maxwidth") + if maxWidthParam != "" { + maxWidthInt, err := strconv.Atoi(maxWidthParam) + if err != nil || maxWidthInt < 220 || maxWidthInt > 550 { + return c.String(http.StatusBadRequest, "Invalid maxwidth (expected integer between 220 and 550)") + } + width = maxWidthInt + } + // NOTE: maxheight ignored + + aturi, err := srv.parseBlueskyURL(c.Request().Context(), c.QueryParam("url")) + if err != nil { + return c.String(http.StatusBadRequest, fmt.Sprintf("Expected 'url' to be bsky.app URL or AT-URI: %v", err)) + } + if aturi.Collection() != syntax.NSID("app.bsky.feed.post") { + return c.String(http.StatusNotImplemented, "Only posts (app.bsky.feed.post records) can be embedded currently") + } + did, err := aturi.Authority().AsDID() + if err != nil { + return err + } + + post, err := srv.getBlueskyPost(c.Request().Context(), did, aturi.RecordKey()) + if err == ErrPostNotFound { + return c.String(http.StatusNotFound, fmt.Sprintf("%v", err)) + } else if err == ErrPostNotPublic { + return c.String(http.StatusForbidden, fmt.Sprintf("%v", err)) + } else if err != nil { + return c.String(http.StatusInternalServerError, fmt.Sprintf("%v", err)) + } + + data := OEmbedResponse{ + Type: "rich", + Version: "1.0", + AuthorName: "@" + post.Author.Handle, + AuthorURL: fmt.Sprintf("https://bsky.app/profile/%s", post.Author.Handle), + ProviderName: "Bluesky Social", + CacheAge: 86400, + Width: width, + Height: nil, + HTML: srv.postEmbedHTML(post), + } + if post.Author.DisplayName != nil { + data.AuthorName = fmt.Sprintf("%s (@%s)", *post.Author.DisplayName, post.Author.Handle) + } + return c.JSON(http.StatusOK, data) +} + +func (srv *Server) WebPostEmbed(c echo.Context) error { + ctx := c.Request().Context() + + // sanity check arguments. don't 4xx, just let app handle if not expected format + rkeyParam := c.QueryParam("rkey") + rkey, err := syntax.ParseRecordKey(rkeyParam) + if err != nil { + return c.String(http.StatusBadRequest, fmt.Sprintf("Invalid RecordKey: %v", err)) + } + didParam := c.QueryParam("did") + did, err := syntax.ParseDID(didParam) + if err != nil { + return c.String(http.StatusBadRequest, fmt.Sprintf("Invalid DID: %v", err)) + } + + postView, err := srv.getBlueskyPost(ctx, did, rkey) + if err == ErrPostNotFound { + return c.String(http.StatusNotFound, fmt.Sprintf("%v", err)) + } else if err == ErrPostNotPublic { + return c.String(http.StatusForbidden, fmt.Sprintf("%v", err)) + } else if err != nil { + return c.String(http.StatusInternalServerError, fmt.Sprintf("%v", err)) + } + + data := map[string]interface{}{ + "post": postView, + } + return c.Render(http.StatusOK, "postEmbed.html", data) +} diff --git a/bskyweb/cmd/embedr/server.go b/bskyweb/cmd/embedr/server.go index 709cd009fd..a5398300c4 100644 --- a/bskyweb/cmd/embedr/server.go +++ b/bskyweb/cmd/embedr/server.go @@ -13,8 +13,7 @@ import ( "syscall" "time" - appbsky "github.com/bluesky-social/indigo/api/bsky" - "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/bluesky-social/indigo/atproto/identity" "github.com/bluesky-social/indigo/util/cliutil" "github.com/bluesky-social/indigo/xrpc" "github.com/bluesky-social/social-app/bskyweb" @@ -30,6 +29,7 @@ type Server struct { echo *echo.Echo httpd *http.Server xrpcc *xrpc.Client + dir identity.Directory } func serve(cctx *cli.Context) error { @@ -71,6 +71,7 @@ func serve(cctx *cli.Context) error { server := &Server{ echo: e, xrpcc: xrpcc, + dir: identity.DefaultDirectory(), } // Create the HTTP server. @@ -140,7 +141,7 @@ func serve(cctx *cli.Context) error { log.Debugf("serving static file from the local file system") return http.FS(os.DirFS("static")) } - fsys, err := fs.Sub(bskyweb.StaticFS, "static") + fsys, err := fs.Sub(bskyweb.EmbedrStaticFS, "static") if err != nil { log.Fatal(err) } @@ -172,6 +173,7 @@ func serve(cctx *cli.Context) error { // actual routes e.GET("/", server.WebHome) + e.GET("/iframe-resize.js", echo.WrapHandler(staticHandler)) e.GET("/embed.js", echo.WrapHandler(staticHandler)) e.GET("/oembed", server.WebOEmbed) e.GET("/embed/:did/app.bsky.feed.post/:rkey", server.WebPostEmbed) @@ -232,85 +234,3 @@ func (srv *Server) errorHandler(err error, c echo.Context) { } c.Render(code, "error.html", data) } - -func (srv *Server) WebHome(c echo.Context) error { - data := map[string]interface{}{} - return c.Render(http.StatusOK, "home.html", data) -} - -func (srv *Server) WebOEmbed(c echo.Context) error { - data := map[string]interface{}{} - return c.Render(http.StatusOK, "oembed.html", data) -} - -func (srv *Server) WebPostEmbed(c echo.Context) error { - ctx := c.Request().Context() - data := map[string]interface{}{} - - // sanity check arguments. don't 4xx, just let app handle if not expected format - rkeyParam := c.Param("rkey") - rkey, err := syntax.ParseRecordKey(rkeyParam) - if err != nil { - return c.Render(http.StatusOK, "postEmbed.html", data) - } - didParam := c.Param("did") - did, err := syntax.ParseDID(didParam) - if err != nil { - return c.Render(http.StatusOK, "postEmbed.html", data) - } - - // requires two fetches: first fetch profile (!) - pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, did.String()) - if err != nil { - log.Warnf("failed to fetch profile for: %s\t%v", did, err) - return c.Render(http.StatusOK, "postEmbed.html", data) - } - unauthedViewingOkay := true - for _, label := range pv.Labels { - if label.Src == pv.Did && label.Val == "!no-unauthenticated" { - unauthedViewingOkay = false - } - } - - if !unauthedViewingOkay { - return c.Render(http.StatusOK, "postEmbed.html", data) - } - data["did"] = did.String() - - // then fetch the post thread (with extra context) - uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", did, rkey) - tpv, err := appbsky.FeedGetPostThread(ctx, srv.xrpcc, 1, 0, uri) - if err != nil { - log.Warnf("failed to fetch post: %s\t%v", uri, err) - return c.Render(http.StatusOK, "postEmbed.html", data) - } - req := c.Request() - postView := tpv.Thread.FeedDefs_ThreadViewPost.Post - data["postView"] = postView - data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path) - if postView.Embed != nil { - if postView.Embed.EmbedImages_View != nil { - var thumbUrls []string - for i := range postView.Embed.EmbedImages_View.Images { - thumbUrls = append(thumbUrls, postView.Embed.EmbedImages_View.Images[i].Thumb) - } - data["imgThumbUrls"] = thumbUrls - } else if postView.Embed.EmbedRecordWithMedia_View != nil && postView.Embed.EmbedRecordWithMedia_View.Media != nil && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View != nil { - var thumbUrls []string - for i := range postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images { - thumbUrls = append(thumbUrls, postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images[i].Thumb) - } - data["imgThumbUrls"] = thumbUrls - } - } - - if postView.Record != nil { - postRecord, ok := postView.Record.Val.(*appbsky.FeedPost) - if ok { - _ = postRecord - data["postText"] = "" // XXX - } - } - - return c.Render(http.StatusOK, "postEmbed.html", data) -} diff --git a/bskyweb/embedr-static/iframe-resize.js b/bskyweb/embedr-static/iframe-resize.js new file mode 100644 index 0000000000..6bf2793df5 --- /dev/null +++ b/bskyweb/embedr-static/iframe-resize.js @@ -0,0 +1 @@ +/* script to resize embed ifame would go here? */ diff --git a/bskyweb/embedr-templates/home.html b/bskyweb/embedr-templates/home.html index 6da3381246..f938c32d6e 100644 --- a/bskyweb/embedr-templates/home.html +++ b/bskyweb/embedr-templates/home.html @@ -1 +1,8 @@ -embed homepage: could redirect to bsky.app, or show a "create embed" widget + + + + +

embed.bsky.app homepage

+

could redirect to bsky.app? or show a "create embed" widget? + + diff --git a/bskyweb/static.go b/bskyweb/static.go index a67d189f57..38adb83335 100644 --- a/bskyweb/static.go +++ b/bskyweb/static.go @@ -4,3 +4,6 @@ import "embed" //go:embed static/* var StaticFS embed.FS + +//go:embed embedr-static/* +var EmbedrStaticFS embed.FS