package main import ( "encoding/json" "fmt" "io" "io/ioutil" "log/slog" "net/http" "net/url" "os" "path/filepath" "time" auth "git.coopcloud.tech/decentral1se/gtslib-auth-keyring" "git.coopcloud.tech/decentral1se/gtslib/client/accounts" "git.coopcloud.tech/decentral1se/gtslib/client/statuses" "git.coopcloud.tech/decentral1se/gtslib/models" "github.com/peterhellberg/link" "github.com/pkg/errors" "github.com/spf13/cobra" ) // getAccount returns the currently authenticated account. func getAccount(authClient *auth.Client) (*models.Account, error) { err := authClient.Wait() if err != nil { return nil, err } resp, err := authClient.Client.Accounts.AccountVerify(nil, authClient.Auth) if err != nil { return nil, errors.WithStack(err) } return resp.GetPayload(), nil } func main() { if err := rootCmd.Execute(); err != nil { slog.Error("woops, something went wrong", "error", err) } } var rootCmd = &cobra.Command{ Use: "blurp", Short: "A GoToSocial status deletion tool", } var authLoginCmd = &cobra.Command{ Use: "login", Short: "Log in", RunE: func(cmd *cobra.Command, args []string) error { return auth.Login(user, auth.WithName("blurp")) }, } var archiveCmd = &cobra.Command{ Use: "archive", Short: "Archive statuses", RunE: func(cmd *cobra.Command, args []string) error { authClient, err := auth.NewAuthClient(user) if err != nil { slog.Error("unable to create auth client", "error", err) } acc, err := getAccount(authClient) if err != nil { slog.Error("unable to retrieve account", "error", err) } pagedRequester := &statusPagedRequester{accID: acc.ID} statuses, err := ReadAllPaged(authClient, pagedRequester) if err != nil { slog.Error("unable to download paged response", "error", err) } basePath := filepath.Join(".", "archive") if err := os.MkdirAll(basePath, 0755); err != nil { slog.Error("unable to create status directory", "error", err) } for _, status := range statuses { basePath = filepath.Join(".", "archive") if len(status.MediaAttachments) > 0 { basePath = filepath.Join(basePath, status.ID) if err := os.MkdirAll(basePath, 0755); err != nil { slog.Error("unable to create status directory", "error", err) } for _, media := range status.MediaAttachments { parsed, err := url.Parse(media.URL) if err != nil { slog.Error("unable to parse media URL", "error", err) } imagePath := filepath.Join(basePath, filepath.Base(parsed.Path)) if _, err := os.Stat(imagePath); errors.Is(err, os.ErrNotExist) { if err := httpGetFile(imagePath, media.URL); err != nil { slog.Error("unable to download file", "error", err) } slog.Info(fmt.Sprintf("archived %s", imagePath)) } } } payload, err := json.MarshalIndent(status, "", " ") if err != nil { slog.Error("unable to marshal", "error", err) } jsonPath := filepath.Join(basePath, fmt.Sprintf("%s.json", status.ID)) if _, err := os.Stat(jsonPath); errors.Is(err, os.ErrNotExist) { if err = ioutil.WriteFile(jsonPath, payload, 0644); err != nil { slog.Error("unable to write JSON file", "error", err) } slog.Info(fmt.Sprintf("archived %s", jsonPath)) } } return nil }, } // httpGetFile downloads a file from the internet. func httpGetFile(filepath, url string) error { out, err := os.Create(filepath) if err != nil { return fmt.Errorf("httpGetFile: unable to create '%s': %s", filepath, err) } defer out.Close() resp, err := http.Get(url) if err != nil { return fmt.Errorf("httpGetFile: unable to HTTP GET '%s'", url) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("httpGetFile: HTTP GET response code %v for '%s'", resp.StatusCode, url) } _, err = io.Copy(out, resp.Body) if err != nil { return fmt.Errorf("httpGetFile: unable to copy HTTP GET response to disk: %s", err) } return nil } type PagedRequester[Response PagedResponse[Element], Element any] interface { Request(authClient *auth.Client, maxID *string) (Response, error) } type PagedResponse[Element any] interface { Link() string Elements() []Element } // ParseLinkMaxID extracts the `max_id` from the `next` link for paging to older items. func ParseLinkMaxID(linkHeader string) (*string, error) { next := link.Parse(linkHeader)["next"] if next == nil { // No link header in that direction means end of results. return nil, nil } nextUrl, err := url.Parse(next.URI) if err != nil { return nil, errors.Wrap(err, "couldn't parse next page URL") } nextMaxID := nextUrl.Query().Get("max_id") if nextMaxID == "" { return nil, errors.New("couldn't find next page max ID") } return &nextMaxID, err } func ReadAllPaged[ Requester PagedRequester[Response, Element], Response PagedResponse[Element], Element any]( authClient *auth.Client, pagedRequester Requester) ([]Element, error) { var all []Element var maxID *string for { err := authClient.Wait() if err != nil { return all, errors.WithStack(err) } pagedResponse, err := pagedRequester.Request(authClient, maxID) if err != nil { slog.Error("error fetching page", "error", err) return all, errors.WithStack(err) } maxID, err = ParseLinkMaxID(pagedResponse.Link()) if err != nil { slog.Error("error parsing Link header", "error", err) return all, errors.WithStack(err) } if maxID == nil { // End of pages. break } all = append(all, pagedResponse.Elements()...) } return all, nil } type statusPagedRequester struct { accID string } type statusPagedResponse struct { resp *accounts.AccountStatusesOK } func (pagedResponse *statusPagedResponse) Link() string { return pagedResponse.resp.Link } func (pagedResponse *statusPagedResponse) Elements() []*models.Status { return pagedResponse.resp.GetPayload() } func (pagedRequester *statusPagedRequester) Request( authClient *auth.Client, maxID *string) (*statusPagedResponse, error) { resp, err := authClient.Client.Accounts.AccountStatuses(&accounts.AccountStatusesParams{ ID: pagedRequester.accID, MaxID: maxID, }, authClient.Auth) if err != nil { return nil, err } return &statusPagedResponse{resp}, nil } var deleteCmd = &cobra.Command{ Use: "delete", Short: "Delete statuses like tears in the rain", RunE: func(cmd *cobra.Command, args []string) error { authClient, err := auth.NewAuthClient(user) if err != nil { slog.Error("unable to create auth client", err) } acc, err := getAccount(authClient) if err != nil { slog.Error("unable to retrieve account", err) } pagedRequester := &statusPagedRequester{accID: acc.ID} allStatuses, err := ReadAllPaged(authClient, pagedRequester) if err != nil { slog.Error("unable to download paged response", err) } ISO8601 := "2006-01-02T15:04:05.000Z" for _, status := range allStatuses { t, err := time.Parse(ISO8601, status.CreatedAt) if err != nil { slog.Error("unable to parse status 'CreatedAt' value", err) } // NOTE(d1): 336 hours = 2 weeks if t.Before(time.Now().Add(-time.Hour * 336)) { _, err := authClient.Client.Statuses.StatusDelete(&statuses.StatusDeleteParams{ ID: status.ID, }, authClient.Auth) if err != nil { slog.Error("unable to delete status", err) } slog.Info(fmt.Sprintf("deleted %s (created: %s)", status.ID, t.Format(time.DateOnly))) } else { slog.Info(fmt.Sprintf("keeping %s (created: %s)", status.ID, t.Format(time.DateOnly))) } } return nil }, } var user string func init() { rootCmd.PersistentFlags().StringVarP( &user, "user", "u", "", "username@domain of account", ) rootCmd.AddCommand(authLoginCmd) rootCmd.AddCommand(archiveCmd) rootCmd.AddCommand(deleteCmd) }