diff --git a/dashboard.go b/dashboard.go index 1b863a6..61e8b53 100644 --- a/dashboard.go +++ b/dashboard.go @@ -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 diff --git a/publisher.go b/publisher.go index b00b950..797429b 100644 --- a/publisher.go +++ b/publisher.go @@ -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