diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..f21dadb --- /dev/null +++ b/auth.go @@ -0,0 +1,83 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "log/slog" + "os" + + "github.com/adrg/xdg" + "github.com/bluesky-social/indigo/atproto/client" +) + +var ErrNoAuthSession = errors.New("no auth session found") + +var SESSION_DATA_PATH = "halide/auth.json" + +func persistAuthSession(d client.PasswordSessionData) error { + + fPath, err := xdg.StateFile(SESSION_DATA_PATH) + if err != nil { + return err + } + + f, err := os.OpenFile(fPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return err + } + defer f.Close() + + authBytes, err := json.MarshalIndent(d, "", " ") + if err != nil { + return err + } + _, err = f.Write(authBytes) + return err +} + +func wipeAuthSession() error { + + fPath, err := xdg.SearchStateFile(SESSION_DATA_PATH) + if err != nil { + fmt.Printf("no auth session found (already logged out)") + return nil + } + return os.Remove(fPath) +} + +func loadAuthSession() (*client.PasswordSessionData, error) { + + fPath, err := xdg.SearchStateFile(SESSION_DATA_PATH) + if err != nil { + return nil, ErrNoAuthSession + } + + fBytes, err := ioutil.ReadFile(fPath) + if err != nil { + return nil, err + } + + var sess client.PasswordSessionData + err = json.Unmarshal(fBytes, &sess) + if err != nil { + return nil, err + } + return &sess, nil +} + +func loadClient() (*client.APIClient, error) { + d, err := loadAuthSession() + if err != nil { + return nil, err + } + + c := client.ResumePasswordSession(*d, func(ctx context.Context, data client.PasswordSessionData) { + if err := persistAuthSession(data); err != nil { + slog.Error("saving refreshed auth session", "err", err) + } + }) + return c, nil +} diff --git a/go.mod b/go.mod index aa2aba7..2ab83bd 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module tangled.sh/bnewbold.net/halide go 1.24.0 require ( + github.com/adrg/xdg v0.5.0 github.com/bluesky-social/indigo v0.0.0-20250621010046-488d1b91889b github.com/carlmjohnson/versioninfo v0.22.5 github.com/joho/godotenv v1.5.1 diff --git a/go.sum b/go.sum index 551601e..b14478e 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/adrg/xdg v0.5.0 h1:dDaZvhMXatArP1NPHhnfaQUqWBLBsmx1h1HXQdMoFCY= +github.com/adrg/xdg v0.5.0/go.mod h1:dDdY4M4DF9Rjy4kHPeNL+ilVF+p2lK8IdM9/rTSGcI4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bluesky-social/indigo v0.0.0-20250621010046-488d1b91889b h1:QniihTdfvYFr8oJZgltN0VyWSWa28v/0DiIVFHy6nfg= diff --git a/main.go b/main.go index 57ad174..e8afa21 100644 --- a/main.go +++ b/main.go @@ -1,15 +1,15 @@ package main import ( - "fmt" - "os" "bytes" - "log" "context" - "encoding/json" + "fmt" + "log" + "os" _ "github.com/joho/godotenv/autoload" + agnostic "github.com/bluesky-social/indigo/api/agnostic" comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/client" "github.com/bluesky-social/indigo/atproto/identity" @@ -19,46 +19,74 @@ import ( ) func main() { + cmd := &cli.Command{ - Name: "halide", - Usage: "CLI tool for for grain.social (atproto)", + Name: "halide", + Usage: "CLI tool for for grain.social (atproto)", Version: versioninfo.Short(), - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "username", - Aliases: []string{"u"}, - Required: true, - Sources: cli.EnvVars("ATP_USERNAME"), - }, - &cli.StringFlag{ - Name: "password", - Aliases: []string{"p"}, - Required: true, - Sources: cli.EnvVars("ATP_PASSWORD"), - }, - }, - } - cmd.Commands = []*cli.Command{ - { - Name: "upload", - Usage: "upload a photo", - ArgsUsage: ``, - Flags: []cli.Flag{}, - Action: runUpload, + Commands: []*cli.Command{ + { + Name: "login", + Usage: "create auth session", + Action: runLogin, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "username", + Aliases: []string{"u"}, + Required: true, + Sources: cli.EnvVars("ATP_USERNAME"), + }, + &cli.StringFlag{ + Name: "password", + Aliases: []string{"p"}, + Required: true, + Sources: cli.EnvVars("ATP_PASSWORD"), + }, + }, + }, + { + Name: "photo", + Commands: []*cli.Command{ + { + Name: "list", + Aliases: []string{"ls"}, + Usage: "enumerate photos", + Action: runPhotoList, + }, + { + Name: "upload", + Usage: "upload a photo", + ArgsUsage: ``, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "alt", + Usage: "image alt text (for accessibility)", + }, + }, + Action: runPhotoUpload, + }, + }, + }, + { + Name: "gallery", + Commands: []*cli.Command{ + { + Name: "list", + Aliases: []string{"ls"}, + Usage: "enumerate galleries", + Action: runGalleryList, + }, + }, + }, }, } if err := cmd.Run(context.Background(), os.Args); err != nil { - log.Fatal(err) - } -} - -func runUpload(ctx context.Context, cmd *cli.Command) error { - photoPath := cmd.Args().First() - if photoPath == "" { - return fmt.Errorf("need to provide file path as an argument") + log.Fatal(err) } +} +func runLogin(ctx context.Context, cmd *cli.Command) error { atid, err := syntax.ParseAtIdentifier(cmd.String("username")) if err != nil { return err @@ -70,6 +98,22 @@ func runUpload(ctx context.Context, cmd *cli.Command) error { return err } + pa := c.Auth.(*client.PasswordAuth) + return persistAuthSession(pa.Session) +} + +func runPhotoUpload(ctx context.Context, cmd *cli.Command) error { + photoPath := cmd.Args().First() + if photoPath == "" { + return fmt.Errorf("need to provide file path as an argument") + } + + c, err := loadClient() + if err != nil { + return err + } + + // TODO: refactor to do upload with c.Do(), streaming from file fileBytes, err := os.ReadFile(photoPath) if err != nil { return err @@ -80,11 +124,60 @@ func runUpload(ctx context.Context, cmd *cli.Command) error { return err } - b, err := json.MarshalIndent(resp.Blob, "", " ") + photo := make(map[string]any) + photo["$type"] = "social.grain.photo" + photo["createdAt"] = syntax.DatetimeNow() + photo["photo"] = resp.Blob + // TODO: if alt was not required, this would be behind an 'if' + photo["alt"] = cmd.String("alt") + + res, err := agnostic.RepoCreateRecord(ctx, c, &agnostic.RepoCreateRecord_Input{ + Collection: "social.grain.photo", + Record: photo, + Repo: c.AccountDID.String(), + }) if err != nil { return err } - fmt.Println(string(b)) + fmt.Printf("%s\t%s\n", res.Uri, res.Cid) + return nil +} + +func runPhotoList(ctx context.Context, cmd *cli.Command) error { + c, err := loadClient() + if err != nil { + return err + } + + out, err := agnostic.RepoListRecords(ctx, c, "social.grain.photo", "", 100, c.AccountDID.String(), false) + if err != nil { + return err + } + + for _, rec := range out.Records { + // TODO: parse rec.Value to lexgen struct type + fmt.Printf("%s\n", rec.Uri) + } + + return nil +} + +func runGalleryList(ctx context.Context, cmd *cli.Command) error { + c, err := loadClient() + if err != nil { + return err + } + + out, err := agnostic.RepoListRecords(ctx, c, "social.grain.gallery", "", 100, c.AccountDID.String(), false) + if err != nil { + return err + } + + for _, rec := range out.Records { + // TODO: parse rec.Value to lexgen struct type + fmt.Printf("%s\n", rec.Uri) + } + return nil }