forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1package main
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "net/http"
8 "net/url"
9 "strconv"
10 "strings"
11
12 appbsky "github.com/bluesky-social/indigo/api/bsky"
13 "github.com/bluesky-social/indigo/atproto/syntax"
14
15 "github.com/labstack/echo/v4"
16)
17
18var ErrPostNotFound = errors.New("post not found")
19var ErrPostNotPublic = errors.New("post is not publicly accessible")
20
21func (srv *Server) getBlueskyPost(ctx context.Context, did syntax.DID, rkey syntax.RecordKey) (*appbsky.FeedDefs_PostView, error) {
22
23 // fetch the post post (with extra context)
24 uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", did, rkey)
25 tpv, err := appbsky.FeedGetPostThread(ctx, srv.xrpcc, 1, 0, uri)
26 if err != nil {
27 log.Warnf("failed to fetch post: %s\t%v", uri, err)
28 // TODO: detect 404, specifically?
29 return nil, ErrPostNotFound
30 }
31
32 if tpv.Thread.FeedDefs_BlockedPost != nil {
33 return nil, ErrPostNotPublic
34 } else if tpv.Thread.FeedDefs_ThreadViewPost.Post == nil {
35 return nil, ErrPostNotFound
36 }
37
38 postView := tpv.Thread.FeedDefs_ThreadViewPost.Post
39 for _, label := range postView.Author.Labels {
40 if label.Src == postView.Author.Did && label.Val == "!no-unauthenticated" {
41 return nil, ErrPostNotPublic
42 }
43 }
44 return postView, nil
45}
46
47func (srv *Server) WebHome(c echo.Context) error {
48 return c.Render(http.StatusOK, "home.html", nil)
49}
50
51type OEmbedResponse struct {
52 Type string `json:"type"`
53 Version string `json:"version"`
54 AuthorName string `json:"author_name,omitempty"`
55 AuthorURL string `json:"author_url,omitempty"`
56 ProviderName string `json:"provider_name,omitempty"`
57 ProviderURL string `json:"provider_url,omitempty"`
58 CacheAge int `json:"cache_age,omitempty"`
59 Width *int `json:"width"`
60 Height *int `json:"height"`
61 HTML string `json:"html,omitempty"`
62}
63
64func (srv *Server) parseBlueskyURL(ctx context.Context, raw string) (*syntax.ATURI, error) {
65
66 if raw == "" {
67 return nil, fmt.Errorf("empty url")
68 }
69
70 // first try simple AT-URI
71 uri, err := syntax.ParseATURI(raw)
72 if nil == err {
73 return &uri, nil
74 }
75
76 // then try bsky.app post URL
77 u, err := url.Parse(raw)
78 if err != nil {
79 return nil, err
80 }
81 if u.Hostname() != "bsky.app" {
82 return nil, fmt.Errorf("only bsky.app URLs currently supported")
83 }
84 pathParts := strings.Split(u.Path, "/") // NOTE: pathParts[0] will be empty string
85 if len(pathParts) != 5 || pathParts[1] != "profile" || pathParts[3] != "post" {
86 return nil, fmt.Errorf("only bsky.app post URLs currently supported")
87 }
88 atid, err := syntax.ParseAtIdentifier(pathParts[2])
89 if err != nil {
90 return nil, err
91 }
92 rkey, err := syntax.ParseRecordKey(pathParts[4])
93 if err != nil {
94 return nil, err
95 }
96 var did syntax.DID
97 if atid.IsHandle() {
98 ident, err := srv.dir.Lookup(ctx, *atid)
99 if err != nil {
100 return nil, err
101 }
102 did = ident.DID
103 } else {
104 did, err = atid.AsDID()
105 if err != nil {
106 return nil, err
107 }
108 }
109
110 // TODO: don't really need to re-parse here, if we had test coverage
111 aturi, err := syntax.ParseATURI(fmt.Sprintf("at://%s/app.bsky.feed.post/%s", did, rkey))
112 if err != nil {
113 return nil, err
114 } else {
115 return &aturi, nil
116 }
117}
118
119func (srv *Server) WebOEmbed(c echo.Context) error {
120 formatParam := c.QueryParam("format")
121 if formatParam != "" && formatParam != "json" {
122 return c.String(http.StatusNotImplemented, "Unsupported oEmbed format: "+formatParam)
123 }
124
125 // TODO: do we actually do something with width?
126 width := 600
127 maxWidthParam := c.QueryParam("maxwidth")
128 if maxWidthParam != "" {
129 maxWidthInt, err := strconv.Atoi(maxWidthParam)
130 if err != nil {
131 return c.String(http.StatusBadRequest, "Invalid maxwidth (expected integer)")
132 }
133 if maxWidthInt < 220 {
134 width = 220
135 } else if maxWidthInt > 600 {
136 width = 600
137 } else {
138 width = maxWidthInt
139 }
140 }
141 // NOTE: maxheight ignored
142
143 aturi, err := srv.parseBlueskyURL(c.Request().Context(), c.QueryParam("url"))
144 if err != nil {
145 return c.String(http.StatusBadRequest, fmt.Sprintf("Expected 'url' to be bsky.app URL or AT-URI: %v", err))
146 }
147 if aturi.Collection() != syntax.NSID("app.bsky.feed.post") {
148 return c.String(http.StatusNotImplemented, "Only posts (app.bsky.feed.post records) can be embedded currently")
149 }
150 did, err := aturi.Authority().AsDID()
151 if err != nil {
152 return err
153 }
154
155 post, err := srv.getBlueskyPost(c.Request().Context(), did, aturi.RecordKey())
156 if err == ErrPostNotFound {
157 return c.String(http.StatusNotFound, fmt.Sprintf("%v", err))
158 } else if err == ErrPostNotPublic {
159 return c.String(http.StatusForbidden, fmt.Sprintf("%v", err))
160 } else if err != nil {
161 return c.String(http.StatusInternalServerError, fmt.Sprintf("%v", err))
162 }
163
164 html, err := srv.postEmbedHTML(post)
165 if err != nil {
166 return c.String(http.StatusInternalServerError, fmt.Sprintf("%v", err))
167 }
168 data := OEmbedResponse{
169 Type: "rich",
170 Version: "1.0",
171 AuthorName: "@" + post.Author.Handle,
172 AuthorURL: fmt.Sprintf("https://bsky.app/profile/%s", post.Author.Handle),
173 ProviderName: "Bluesky Social",
174 ProviderURL: "https://bsky.app",
175 CacheAge: 86400,
176 Width: &width,
177 Height: nil,
178 HTML: html,
179 }
180 if post.Author.DisplayName != nil {
181 data.AuthorName = fmt.Sprintf("%s (@%s)", *post.Author.DisplayName, post.Author.Handle)
182 }
183 return c.JSON(http.StatusOK, data)
184}
185
186func (srv *Server) WebPostEmbed(c echo.Context) error {
187
188 // sanity check arguments. don't 4xx, just let app handle if not expected format
189 rkeyParam := c.Param("rkey")
190 rkey, err := syntax.ParseRecordKey(rkeyParam)
191 if err != nil {
192 return c.String(http.StatusBadRequest, fmt.Sprintf("Invalid RecordKey: %v", err))
193 }
194 didParam := c.Param("did")
195 did, err := syntax.ParseDID(didParam)
196 if err != nil {
197 return c.String(http.StatusBadRequest, fmt.Sprintf("Invalid DID: %v", err))
198 }
199 _ = rkey
200 _ = did
201
202 // NOTE: this request was't really necessary; the JS will do the same fetch
203 /*
204 postView, err := srv.getBlueskyPost(ctx, did, rkey)
205 if err == ErrPostNotFound {
206 return c.String(http.StatusNotFound, fmt.Sprintf("%v", err))
207 } else if err == ErrPostNotPublic {
208 return c.String(http.StatusForbidden, fmt.Sprintf("%v", err))
209 } else if err != nil {
210 return c.String(http.StatusInternalServerError, fmt.Sprintf("%v", err))
211 }
212 */
213
214 return c.Render(http.StatusOK, "postEmbed.html", nil)
215}