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) { // fetch the post post (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 } postView := tpv.Thread.FeedDefs_ThreadViewPost.Post for _, label := range postView.Author.Labels { if label.Src == postView.Author.Did && label.Val == "!no-unauthenticated" { return nil, ErrPostNotPublic } } return postView, 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"` Height *int `json:"height"` 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) WebOEmbed(c echo.Context) error { formatParam := c.QueryParam("format") if formatParam != "" && formatParam != "json" { return c.String(http.StatusNotImplemented, "Unsupported oEmbed format: "+formatParam) } // TODO: do we actually do something with width? width := 600 maxWidthParam := c.QueryParam("maxwidth") if maxWidthParam != "" { maxWidthInt, err := strconv.Atoi(maxWidthParam) if err != nil { return c.String(http.StatusBadRequest, "Invalid maxwidth (expected integer)") } if maxWidthInt < 220 { width = 220 } else if maxWidthInt > 600 { width = 600 } else { 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)) } html, err := srv.postEmbedHTML(post) 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: html, } 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 { // 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.String(http.StatusBadRequest, fmt.Sprintf("Invalid RecordKey: %v", err)) } didParam := c.Param("did") did, err := syntax.ParseDID(didParam) if err != nil { return c.String(http.StatusBadRequest, fmt.Sprintf("Invalid DID: %v", err)) } _ = rkey _ = did // NOTE: this request was't really necessary; the JS will do the same fetch /* 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)) } */ return c.Render(http.StatusOK, "postEmbed.html", nil) }