Files
2026-02-02 15:30:15 -05:00

343 lines
7.9 KiB
Go

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 {
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()
}
if displayName != "" {
profile["displayName"] = displayName
}
if description != "" {
profile["description"] = description
}
if avatar != nil {
profile["avatar"] = avatar
}
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 {
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
}
for _, record := range result.Records {
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
}
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
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
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
}