Full cleanup when revoking feed publish status

When setting publish_status to deny, now performs complete cleanup:
- Deletes all posts from the feed account on PDS
- Deletes the PDS account itself
- Clears published_at timestamps on all items
- Clears the publish_account field on the feed

Added Publisher methods:
- DeleteAllPosts: lists and deletes all posts from an account
- DeleteRecord: deletes a single AT Protocol record
- DeleteAccount: deletes account via PDS admin API

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
primal
2026-01-29 13:37:02 -05:00
parent a5af4e14c3
commit 5908a8c03e
2 changed files with 242 additions and 5 deletions
+105 -5
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"html/template"
"net/http"
"os"
"strings"
"time"
@@ -1723,6 +1724,7 @@ func (c *Crawler) handleAPIPublishCandidates(w http.ResponseWriter, r *http.Requ
// handleAPISetPublishStatus sets the publish status for a feed
// status must be 'pass', 'deny', or 'held'
// When setting to 'deny', performs full cleanup: delete posts, delete account, clear published_at
func (c *Crawler) handleAPISetPublishStatus(w http.ResponseWriter, r *http.Request) {
feedURL := r.URL.Query().Get("url")
status := r.URL.Query().Get("status")
@@ -1744,17 +1746,115 @@ func (c *Crawler) handleAPISetPublishStatus(w http.ResponseWriter, r *http.Reque
account = DeriveHandleFromFeed(feedURL)
}
result := map[string]interface{}{
"url": feedURL,
"status": status,
}
// If denying, perform full cleanup
if status == "deny" {
cleanup := c.cleanupFeedPublishing(feedURL)
result["cleanup"] = cleanup
account = "" // Clear account when denying
}
if err := c.SetPublishStatus(feedURL, status, account); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
result["account"] = account
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"url": feedURL,
"status": status,
"account": account,
})
json.NewEncoder(w).Encode(result)
}
// cleanupFeedPublishing removes all published content for a feed
// Returns a summary of what was cleaned up
func (c *Crawler) cleanupFeedPublishing(feedURL string) map[string]interface{} {
result := map[string]interface{}{
"posts_deleted": 0,
"account_deleted": false,
"items_cleared": 0,
}
// Get feed info to find the account
feed, err := c.getFeed(feedURL)
if err != nil || feed == nil {
result["error"] = "feed not found"
return result
}
if feed.PublishAccount == "" {
// No account associated, just clear items
itemsCleared, _ := c.db.Exec(`UPDATE items SET published_at = NULL WHERE feed_url = $1`, feedURL)
result["items_cleared"] = itemsCleared
return result
}
// Load PDS credentials
pdsHost := os.Getenv("PDS_HOST")
pdsAdminPassword := os.Getenv("PDS_ADMIN_PASSWORD")
feedPassword := os.Getenv("FEED_PASSWORD")
if pdsHost == "" {
// Try loading from pds.env
if envData, err := os.ReadFile("pds.env"); err == nil {
for _, line := range strings.Split(string(envData), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "PDS_HOST=") {
pdsHost = strings.TrimPrefix(line, "PDS_HOST=")
} else if strings.HasPrefix(line, "PDS_ADMIN_PASSWORD=") {
pdsAdminPassword = strings.TrimPrefix(line, "PDS_ADMIN_PASSWORD=")
} else if strings.HasPrefix(line, "FEED_PASSWORD=") {
feedPassword = strings.TrimPrefix(line, "FEED_PASSWORD=")
}
}
}
}
if pdsHost == "" || feedPassword == "" {
result["error"] = "PDS credentials not configured"
// Still clear items in database
itemsCleared, _ := c.db.Exec(`UPDATE items SET published_at = NULL WHERE feed_url = $1`, feedURL)
result["items_cleared"] = itemsCleared
return result
}
publisher := NewPublisher(pdsHost)
// Try to authenticate as the feed account
session, err := publisher.CreateSession(feed.PublishAccount, feedPassword)
if err == nil && session != nil {
// Delete all posts
deleted, err := publisher.DeleteAllPosts(session)
if err == nil {
result["posts_deleted"] = deleted
} else {
result["posts_delete_error"] = err.Error()
}
} else {
result["session_error"] = "could not authenticate to delete posts"
}
// Delete the account using admin API
if pdsAdminPassword != "" && session != nil {
err := publisher.DeleteAccount(pdsAdminPassword, session.DID)
if err == nil {
result["account_deleted"] = true
} else {
result["account_delete_error"] = err.Error()
}
}
// Clear published_at on all items
itemsCleared, _ := c.db.Exec(`UPDATE items SET published_at = NULL WHERE feed_url = $1`, feedURL)
result["items_cleared"] = itemsCleared
// Clear publish_account on feed
c.db.Exec(`UPDATE feeds SET publish_account = NULL WHERE url = $1`, feedURL)
return result
}
// handleAPIUnpublishedItems returns unpublished items for a feed
+137
View File
@@ -1136,6 +1136,143 @@ func (p *Publisher) UpdateProfile(session *PDSSession, displayName, description
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
}
// FetchFavicon downloads a favicon/icon from a URL
func FetchFavicon(siteURL string) ([]byte, string, error) {
// Try common favicon locations