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:
+105
-5
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user