package main import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" ) // BlobRef represents a blob reference for profile images type BlobRef struct { Type string `json:"$type"` Ref Link `json:"ref"` MimeType string `json:"mimeType"` Size int64 `json:"size"` } type Link struct { Link string `json:"$link"` } // UploadBlob uploads an image to the PDS and returns a blob reference func (p *Publisher) UploadBlob(session *PDSSession, data []byte, mimeType string) (*BlobRef, error) { req, err := http.NewRequest("POST", p.pdsHost+"/xrpc/com.atproto.repo.uploadBlob", bytes.NewReader(data)) if err != nil { return nil, err } req.Header.Set("Content-Type", mimeType) req.Header.Set("Authorization", "Bearer "+session.AccessJwt) resp, err := p.httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("upload blob failed: %s - %s", resp.Status, string(respBody)) } var result struct { Blob BlobRef `json:"blob"` } if err := json.Unmarshal(respBody, &result); err != nil { return nil, err } return &result.Blob, nil } // UpdateProfile updates the profile for an account func (p *Publisher) UpdateProfile(session *PDSSession, displayName, description string, avatar *BlobRef) error { // First, get the current profile to preserve any existing fields getReq, err := http.NewRequest("GET", p.pdsHost+"/xrpc/com.atproto.repo.getRecord?repo="+session.DID+"&collection=app.bsky.actor.profile&rkey=self", nil) if err != nil { return err } getReq.Header.Set("Authorization", "Bearer "+session.AccessJwt) getResp, err := p.httpClient.Do(getReq) var existingCID string profile := map[string]interface{}{ "$type": "app.bsky.actor.profile", } if err == nil && getResp.StatusCode == http.StatusOK { defer getResp.Body.Close() var existing struct { CID string `json:"cid"` Value map[string]interface{} `json:"value"` } if json.NewDecoder(getResp.Body).Decode(&existing) == nil { existingCID = existing.CID profile = existing.Value } } else if getResp != nil { getResp.Body.Close() } // Update fields if displayName != "" { profile["displayName"] = displayName } if description != "" { profile["description"] = description } if avatar != nil { profile["avatar"] = avatar } // Put the record payload := map[string]interface{}{ "repo": session.DID, "collection": "app.bsky.actor.profile", "rkey": "self", "record": profile, } if existingCID != "" { payload["swapRecord"] = existingCID } body, err := json.Marshal(payload) if err != nil { return err } req, err := http.NewRequest("POST", p.pdsHost+"/xrpc/com.atproto.repo.putRecord", bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+session.AccessJwt) resp, err := p.httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return fmt.Errorf("update profile failed: %s - %s", resp.Status, string(respBody)) } return nil } // DeleteAllPosts deletes all posts from an account func (p *Publisher) DeleteAllPosts(session *PDSSession) (int, error) { deleted := 0 cursor := "" for { // List records listURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=app.bsky.feed.post&limit=100", p.pdsHost, session.DID) if cursor != "" { listURL += "&cursor=" + url.QueryEscape(cursor) } req, err := http.NewRequest("GET", listURL, nil) if err != nil { return deleted, err } req.Header.Set("Authorization", "Bearer "+session.AccessJwt) resp, err := p.httpClient.Do(req) if err != nil { return deleted, err } var result struct { Records []struct { URI string `json:"uri"` } `json:"records"` Cursor string `json:"cursor"` } respBody, _ := io.ReadAll(resp.Body) resp.Body.Close() if resp.StatusCode != http.StatusOK { return deleted, fmt.Errorf("list records failed: %s - %s", resp.Status, string(respBody)) } if err := json.Unmarshal(respBody, &result); err != nil { return deleted, err } if len(result.Records) == 0 { break } // Delete each record for _, record := range result.Records { // Extract rkey from URI: at://did:plc:xxx/app.bsky.feed.post/rkey parts := strings.Split(record.URI, "/") if len(parts) < 2 { continue } rkey := parts[len(parts)-1] if err := p.DeleteRecord(session, "app.bsky.feed.post", rkey); err != nil { // Continue deleting other records even if one fails continue } deleted++ } cursor = result.Cursor if cursor == "" { break } } return deleted, nil } // DeleteRecord deletes a single record from an account func (p *Publisher) DeleteRecord(session *PDSSession, collection, rkey string) error { payload := map[string]interface{}{ "repo": session.DID, "collection": collection, "rkey": rkey, } body, err := json.Marshal(payload) if err != nil { return err } req, err := http.NewRequest("POST", p.pdsHost+"/xrpc/com.atproto.repo.deleteRecord", bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+session.AccessJwt) resp, err := p.httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("delete record failed: %s - %s", resp.Status, string(respBody)) } return nil } // DeleteAccount deletes an account using PDS admin API func (p *Publisher) DeleteAccount(adminPassword, did string) error { payload := map[string]interface{}{ "did": did, } body, err := json.Marshal(payload) if err != nil { return err } req, err := http.NewRequest("POST", p.pdsHost+"/xrpc/com.atproto.admin.deleteAccount", bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.SetBasicAuth("admin", adminPassword) resp, err := p.httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("delete account failed: %s - %s", resp.Status, string(respBody)) } return nil } // TakedownAccount applies a takedown to an account (hides content, preserves data) func (p *Publisher) TakedownAccount(adminPassword, did, reason string) error { payload := map[string]interface{}{ "subject": map[string]interface{}{ "$type": "com.atproto.admin.defs#repoRef", "did": did, }, "takedown": map[string]interface{}{ "applied": true, "ref": reason, }, } body, err := json.Marshal(payload) if err != nil { return err } req, err := http.NewRequest("POST", p.pdsHost+"/xrpc/com.atproto.admin.updateSubjectStatus", bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.SetBasicAuth("admin", adminPassword) resp, err := p.httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("takedown account failed: %s - %s", resp.Status, string(respBody)) } return nil } // RestoreAccount removes a takedown from an account (makes content visible again) func (p *Publisher) RestoreAccount(adminPassword, did string) error { payload := map[string]interface{}{ "subject": map[string]interface{}{ "$type": "com.atproto.admin.defs#repoRef", "did": did, }, "takedown": map[string]interface{}{ "applied": false, }, } body, err := json.Marshal(payload) if err != nil { return err } req, err := http.NewRequest("POST", p.pdsHost+"/xrpc/com.atproto.admin.updateSubjectStatus", bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.SetBasicAuth("admin", adminPassword) resp, err := p.httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("restore account failed: %s - %s", resp.Status, string(respBody)) } return nil }