All checks were successful
continuous-integration/drone/push Build is passing
299 lines
7.6 KiB
Go
299 lines
7.6 KiB
Go
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)
|
|
}
|