[very crude, wip] post to bsky without the distraction of feeds
at main 7.4 kB view raw
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}