[very crude, wip] post to bsky without the distraction of feeds
1package srv
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "log/slog"
10 "net/http"
11 "net/url"
12 "strings"
13 "time"
14 "unicode/utf8"
15
16 "srv.exe.dev/db/dbgen"
17)
18
19// HandlePost creates a new post or thread.
20func (s *Server) HandlePost(w http.ResponseWriter, r *http.Request) {
21 ctx := r.Context()
22
23 // Get session
24 cookie, err := r.Cookie(SessionCookie)
25 if err != nil || cookie.Value == "" {
26 http.Redirect(w, r, "/?error="+url.QueryEscape("Not logged in"), http.StatusSeeOther)
27 return
28 }
29
30 q := dbgen.New(s.DB)
31 session, err := q.GetOAuthSession(ctx, cookie.Value)
32 if err != nil {
33 http.Redirect(w, r, "/?error="+url.QueryEscape("Session expired"), http.StatusSeeOther)
34 return
35 }
36
37 // Check if token needs refresh
38 if time.Now().After(session.ExpiresAt) {
39 http.Redirect(w, r, "/?error="+url.QueryEscape("Session expired, please log in again"), http.StatusSeeOther)
40 return
41 }
42
43 // Get post content - can be multiple posts for a thread
44 if err := r.ParseForm(); err != nil {
45 http.Redirect(w, r, "/?error="+url.QueryEscape("Invalid form data"), http.StatusSeeOther)
46 return
47 }
48
49 posts := r.Form["post"]
50 if len(posts) == 0 || (len(posts) == 1 && strings.TrimSpace(posts[0]) == "") {
51 http.Redirect(w, r, "/?error="+url.QueryEscape("Post cannot be empty"), http.StatusSeeOther)
52 return
53 }
54
55 // Validate all posts
56 for i, post := range posts {
57 post = strings.TrimSpace(post)
58 if post == "" {
59 continue
60 }
61 if utf8.RuneCountInString(post) > BskyCharLimit {
62 http.Redirect(w, r, "/?error="+url.QueryEscape(fmt.Sprintf("Post %d exceeds %d character limit", i+1, BskyCharLimit)), http.StatusSeeOther)
63 return
64 }
65 }
66
67 // Restore DPoP key (if present)
68 var dpopKey *DPoPKey
69 if session.DpopPrivateKey != "" {
70 dpopKey, err = UnmarshalPrivateKey(session.DpopPrivateKey)
71 if err != nil {
72 slog.Error("unmarshal DPoP key", "error", err)
73 http.Redirect(w, r, "/?error="+url.QueryEscape("Internal error"), http.StatusSeeOther)
74 return
75 }
76 }
77
78 // Create posts
79 var threadRootURI, threadRootCID string
80 var lastPostURI, lastPostCID string
81 var createdCount int
82
83 for _, postText := range posts {
84 postText = strings.TrimSpace(postText)
85 if postText == "" {
86 continue
87 }
88
89 var replyRef *ReplyRef
90 if lastPostURI != "" {
91 replyRef = &ReplyRef{
92 Root: PostRef{
93 URI: threadRootURI,
94 CID: threadRootCID,
95 },
96 Parent: PostRef{
97 URI: lastPostURI,
98 CID: lastPostCID,
99 },
100 }
101 }
102
103 uri, cid, err := s.createPost(ctx, session, dpopKey, postText, replyRef)
104 if err != nil {
105 slog.Error("create post", "error", err)
106 if createdCount > 0 {
107 http.Redirect(w, r, "/?error="+url.QueryEscape(fmt.Sprintf("Created %d posts but failed on post %d: %s", createdCount, createdCount+1, err.Error())), http.StatusSeeOther)
108 } else {
109 http.Redirect(w, r, "/?error="+url.QueryEscape("Failed to create post: "+err.Error()), http.StatusSeeOther)
110 }
111 return
112 }
113
114 // Track thread
115 if threadRootURI == "" {
116 threadRootURI = uri
117 threadRootCID = cid
118 }
119 lastPostURI = uri
120 lastPostCID = cid
121
122 // Store in database
123 var rootPtr, parentPtr *string
124 if replyRef != nil {
125 rootPtr = &replyRef.Root.URI
126 parentPtr = &replyRef.Parent.URI
127 }
128
129 _, err = q.CreatePost(ctx, dbgen.CreatePostParams{
130 SessionID: session.ID,
131 Did: session.Did,
132 Handle: session.Handle,
133 Uri: uri,
134 Cid: cid,
135 Text: postText,
136 ThreadRootUri: rootPtr,
137 ReplyParentUri: parentPtr,
138 CreatedAt: time.Now(),
139 })
140 if err != nil {
141 slog.Error("store post", "error", err)
142 // Don't fail the request, post was created on bsky
143 }
144
145 createdCount++
146 }
147
148 if createdCount == 1 {
149 http.Redirect(w, r, "/?success="+url.QueryEscape("Post created!"), http.StatusSeeOther)
150 } else {
151 http.Redirect(w, r, "/?success="+url.QueryEscape(fmt.Sprintf("Thread created with %d posts!", createdCount)), http.StatusSeeOther)
152 }
153}
154
155type PostRef struct {
156 URI string `json:"uri"`
157 CID string `json:"cid"`
158}
159
160type ReplyRef struct {
161 Root PostRef `json:"root"`
162 Parent PostRef `json:"parent"`
163}
164
165type createRecordRequest struct {
166 Repo string `json:"repo"`
167 Collection string `json:"collection"`
168 Record interface{} `json:"record"`
169}
170
171type postRecord struct {
172 Type string `json:"$type"`
173 Text string `json:"text"`
174 CreatedAt string `json:"createdAt"`
175 Reply *ReplyRef `json:"reply,omitempty"`
176 Facets []Facet `json:"facets,omitempty"`
177}
178
179type Facet struct {
180 Index FacetIndex `json:"index"`
181 Features []FacetFeature `json:"features"`
182}
183
184type FacetIndex struct {
185 ByteStart int `json:"byteStart"`
186 ByteEnd int `json:"byteEnd"`
187}
188
189type FacetFeature struct {
190 Type string `json:"$type"`
191 URI string `json:"uri,omitempty"`
192 DID string `json:"did,omitempty"`
193 Tag string `json:"tag,omitempty"`
194}
195
196func (s *Server) createPost(ctx context.Context, session dbgen.OauthSession, dpopKey *DPoPKey, text string, reply *ReplyRef) (uri, cid string, err error) {
197 // Check if we're using DPoP or Bearer auth
198 useDPoP := session.DpopPrivateKey != "" && dpopKey != nil
199
200 record := postRecord{
201 Type: "app.bsky.feed.post",
202 Text: text,
203 CreatedAt: time.Now().UTC().Format(time.RFC3339),
204 Reply: reply,
205 Facets: extractFacets(text),
206 }
207
208 reqBody := createRecordRequest{
209 Repo: session.Did,
210 Collection: "app.bsky.feed.post",
211 Record: record,
212 }
213
214 bodyJSON, err := json.Marshal(reqBody)
215 if err != nil {
216 return "", "", fmt.Errorf("marshal request: %w", err)
217 }
218
219 endpoint := session.PdsUrl + "/xrpc/com.atproto.repo.createRecord"
220
221 // Try with nonce retry (for DPoP) or single attempt (for Bearer)
222 var nonce string
223 maxRetries := 1
224 if useDPoP {
225 maxRetries = 3
226 }
227
228 for i := 0; i < maxRetries; i++ {
229 req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(bodyJSON))
230 if err != nil {
231 return "", "", fmt.Errorf("create request: %w", err)
232 }
233 req.Header.Set("Content-Type", "application/json")
234
235 if useDPoP {
236 dpopProof, err := dpopKey.CreateDPoPProof("POST", endpoint, session.AccessToken, nonce)
237 if err != nil {
238 return "", "", fmt.Errorf("create DPoP proof: %w", err)
239 }
240 req.Header.Set("Authorization", "DPoP "+session.AccessToken)
241 req.Header.Set("DPoP", dpopProof)
242 } else {
243 req.Header.Set("Authorization", "Bearer "+session.AccessToken)
244 }
245
246 client := &http.Client{Timeout: 30 * time.Second}
247 resp, err := client.Do(req)
248 if err != nil {
249 return "", "", fmt.Errorf("request failed: %w", err)
250 }
251
252 // Check for DPoP nonce
253 if useDPoP {
254 if newNonce := resp.Header.Get("DPoP-Nonce"); newNonce != "" {
255 nonce = newNonce
256 }
257
258 if resp.StatusCode == http.StatusUnauthorized && nonce != "" && i < maxRetries-1 {
259 resp.Body.Close()
260 slog.Info("retrying createRecord with nonce")
261 continue
262 }
263 }
264
265 if resp.StatusCode != http.StatusOK {
266 body, _ := io.ReadAll(resp.Body)
267 resp.Body.Close()
268 return "", "", fmt.Errorf("createRecord failed (%d): %s", resp.StatusCode, string(body))
269 }
270
271 var result struct {
272 URI string `json:"uri"`
273 CID string `json:"cid"`
274 }
275 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
276 resp.Body.Close()
277 return "", "", fmt.Errorf("decode response: %w", err)
278 }
279 resp.Body.Close()
280
281 return result.URI, result.CID, nil
282 }
283
284 return "", "", fmt.Errorf("failed after retries")
285}