tangled
alpha
login
or
join now
bnewbold.net
/
halide
5
fork
atom
demo CLI tool for grain.social
5
fork
atom
overview
issues
pulls
pipelines
bit more progress
bnewbold.net
8 months ago
39773977
f5fd3b00
+217
-38
4 changed files
expand all
collapse all
unified
split
auth.go
go.mod
go.sum
main.go
+83
auth.go
···
1
1
+
package main
2
2
+
3
3
+
import (
4
4
+
"context"
5
5
+
"encoding/json"
6
6
+
"errors"
7
7
+
"fmt"
8
8
+
"io/ioutil"
9
9
+
"log/slog"
10
10
+
"os"
11
11
+
12
12
+
"github.com/adrg/xdg"
13
13
+
"github.com/bluesky-social/indigo/atproto/client"
14
14
+
)
15
15
+
16
16
+
var ErrNoAuthSession = errors.New("no auth session found")
17
17
+
18
18
+
var SESSION_DATA_PATH = "halide/auth.json"
19
19
+
20
20
+
func persistAuthSession(d client.PasswordSessionData) error {
21
21
+
22
22
+
fPath, err := xdg.StateFile(SESSION_DATA_PATH)
23
23
+
if err != nil {
24
24
+
return err
25
25
+
}
26
26
+
27
27
+
f, err := os.OpenFile(fPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
28
28
+
if err != nil {
29
29
+
return err
30
30
+
}
31
31
+
defer f.Close()
32
32
+
33
33
+
authBytes, err := json.MarshalIndent(d, "", " ")
34
34
+
if err != nil {
35
35
+
return err
36
36
+
}
37
37
+
_, err = f.Write(authBytes)
38
38
+
return err
39
39
+
}
40
40
+
41
41
+
func wipeAuthSession() error {
42
42
+
43
43
+
fPath, err := xdg.SearchStateFile(SESSION_DATA_PATH)
44
44
+
if err != nil {
45
45
+
fmt.Printf("no auth session found (already logged out)")
46
46
+
return nil
47
47
+
}
48
48
+
return os.Remove(fPath)
49
49
+
}
50
50
+
51
51
+
func loadAuthSession() (*client.PasswordSessionData, error) {
52
52
+
53
53
+
fPath, err := xdg.SearchStateFile(SESSION_DATA_PATH)
54
54
+
if err != nil {
55
55
+
return nil, ErrNoAuthSession
56
56
+
}
57
57
+
58
58
+
fBytes, err := ioutil.ReadFile(fPath)
59
59
+
if err != nil {
60
60
+
return nil, err
61
61
+
}
62
62
+
63
63
+
var sess client.PasswordSessionData
64
64
+
err = json.Unmarshal(fBytes, &sess)
65
65
+
if err != nil {
66
66
+
return nil, err
67
67
+
}
68
68
+
return &sess, nil
69
69
+
}
70
70
+
71
71
+
func loadClient() (*client.APIClient, error) {
72
72
+
d, err := loadAuthSession()
73
73
+
if err != nil {
74
74
+
return nil, err
75
75
+
}
76
76
+
77
77
+
c := client.ResumePasswordSession(*d, func(ctx context.Context, data client.PasswordSessionData) {
78
78
+
if err := persistAuthSession(data); err != nil {
79
79
+
slog.Error("saving refreshed auth session", "err", err)
80
80
+
}
81
81
+
})
82
82
+
return c, nil
83
83
+
}
+1
go.mod
···
3
3
go 1.24.0
4
4
5
5
require (
6
6
+
github.com/adrg/xdg v0.5.0
6
7
github.com/bluesky-social/indigo v0.0.0-20250621010046-488d1b91889b
7
8
github.com/carlmjohnson/versioninfo v0.22.5
8
9
github.com/joho/godotenv v1.5.1
+2
go.sum
···
1
1
+
github.com/adrg/xdg v0.5.0 h1:dDaZvhMXatArP1NPHhnfaQUqWBLBsmx1h1HXQdMoFCY=
2
2
+
github.com/adrg/xdg v0.5.0/go.mod h1:dDdY4M4DF9Rjy4kHPeNL+ilVF+p2lK8IdM9/rTSGcI4=
1
3
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
2
4
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
3
5
github.com/bluesky-social/indigo v0.0.0-20250621010046-488d1b91889b h1:QniihTdfvYFr8oJZgltN0VyWSWa28v/0DiIVFHy6nfg=
+131
-38
main.go
···
1
1
package main
2
2
3
3
import (
4
4
-
"fmt"
5
5
-
"os"
6
4
"bytes"
7
7
-
"log"
8
5
"context"
9
9
-
"encoding/json"
6
6
+
"fmt"
7
7
+
"log"
8
8
+
"os"
10
9
11
10
_ "github.com/joho/godotenv/autoload"
12
11
12
12
+
agnostic "github.com/bluesky-social/indigo/api/agnostic"
13
13
comatproto "github.com/bluesky-social/indigo/api/atproto"
14
14
"github.com/bluesky-social/indigo/atproto/client"
15
15
"github.com/bluesky-social/indigo/atproto/identity"
···
19
19
)
20
20
21
21
func main() {
22
22
+
22
23
cmd := &cli.Command{
23
23
-
Name: "halide",
24
24
-
Usage: "CLI tool for for grain.social (atproto)",
24
24
+
Name: "halide",
25
25
+
Usage: "CLI tool for for grain.social (atproto)",
25
26
Version: versioninfo.Short(),
26
26
-
Flags: []cli.Flag{
27
27
-
&cli.StringFlag{
28
28
-
Name: "username",
29
29
-
Aliases: []string{"u"},
30
30
-
Required: true,
31
31
-
Sources: cli.EnvVars("ATP_USERNAME"),
32
32
-
},
33
33
-
&cli.StringFlag{
34
34
-
Name: "password",
35
35
-
Aliases: []string{"p"},
36
36
-
Required: true,
37
37
-
Sources: cli.EnvVars("ATP_PASSWORD"),
38
38
-
},
27
27
+
Commands: []*cli.Command{
28
28
+
{
29
29
+
Name: "login",
30
30
+
Usage: "create auth session",
31
31
+
Action: runLogin,
32
32
+
Flags: []cli.Flag{
33
33
+
&cli.StringFlag{
34
34
+
Name: "username",
35
35
+
Aliases: []string{"u"},
36
36
+
Required: true,
37
37
+
Sources: cli.EnvVars("ATP_USERNAME"),
38
38
+
},
39
39
+
&cli.StringFlag{
40
40
+
Name: "password",
41
41
+
Aliases: []string{"p"},
42
42
+
Required: true,
43
43
+
Sources: cli.EnvVars("ATP_PASSWORD"),
44
44
+
},
45
45
+
},
46
46
+
},
47
47
+
{
48
48
+
Name: "photo",
49
49
+
Commands: []*cli.Command{
50
50
+
{
51
51
+
Name: "list",
52
52
+
Aliases: []string{"ls"},
53
53
+
Usage: "enumerate photos",
54
54
+
Action: runPhotoList,
55
55
+
},
56
56
+
{
57
57
+
Name: "upload",
58
58
+
Usage: "upload a photo",
59
59
+
ArgsUsage: `<file>`,
60
60
+
Flags: []cli.Flag{
61
61
+
&cli.StringFlag{
62
62
+
Name: "alt",
63
63
+
Usage: "image alt text (for accessibility)",
64
64
+
},
65
65
+
},
66
66
+
Action: runPhotoUpload,
67
67
+
},
68
68
+
},
69
69
+
},
70
70
+
{
71
71
+
Name: "gallery",
72
72
+
Commands: []*cli.Command{
73
73
+
{
74
74
+
Name: "list",
75
75
+
Aliases: []string{"ls"},
76
76
+
Usage: "enumerate galleries",
77
77
+
Action: runGalleryList,
78
78
+
},
79
79
+
},
80
80
+
},
39
81
},
40
82
}
41
41
-
cmd.Commands = []*cli.Command{
42
42
-
{
43
43
-
Name: "upload",
44
44
-
Usage: "upload a photo",
45
45
-
ArgsUsage: `<file>`,
46
46
-
Flags: []cli.Flag{},
47
47
-
Action: runUpload,
48
48
-
},
83
83
+
84
84
+
if err := cmd.Run(context.Background(), os.Args); err != nil {
85
85
+
log.Fatal(err)
86
86
+
}
87
87
+
}
88
88
+
89
89
+
func runLogin(ctx context.Context, cmd *cli.Command) error {
90
90
+
atid, err := syntax.ParseAtIdentifier(cmd.String("username"))
91
91
+
if err != nil {
92
92
+
return err
49
93
}
50
94
51
51
-
if err := cmd.Run(context.Background(), os.Args); err != nil {
52
52
-
log.Fatal(err)
53
53
-
}
95
95
+
dir := identity.DefaultDirectory()
96
96
+
c, err := client.LoginWithPassword(ctx, dir, *atid, cmd.String("password"), "", nil)
97
97
+
if err != nil {
98
98
+
return err
99
99
+
}
100
100
+
101
101
+
pa := c.Auth.(*client.PasswordAuth)
102
102
+
return persistAuthSession(pa.Session)
54
103
}
55
104
56
56
-
func runUpload(ctx context.Context, cmd *cli.Command) error {
105
105
+
func runPhotoUpload(ctx context.Context, cmd *cli.Command) error {
57
106
photoPath := cmd.Args().First()
58
107
if photoPath == "" {
59
108
return fmt.Errorf("need to provide file path as an argument")
60
109
}
61
110
62
62
-
atid, err := syntax.ParseAtIdentifier(cmd.String("username"))
111
111
+
c, err := loadClient()
63
112
if err != nil {
64
113
return err
65
114
}
66
115
67
67
-
dir := identity.DefaultDirectory()
68
68
-
c, err := client.LoginWithPassword(ctx, dir, *atid, cmd.String("password"), "", nil)
116
116
+
// TODO: refactor to do upload with c.Do(), streaming from file
117
117
+
fileBytes, err := os.ReadFile(photoPath)
69
118
if err != nil {
70
119
return err
71
120
}
72
121
73
73
-
fileBytes, err := os.ReadFile(photoPath)
122
122
+
resp, err := comatproto.RepoUploadBlob(ctx, c, bytes.NewReader(fileBytes))
74
123
if err != nil {
75
124
return err
76
125
}
77
126
78
78
-
resp, err := comatproto.RepoUploadBlob(ctx, c, bytes.NewReader(fileBytes))
127
127
+
photo := make(map[string]any)
128
128
+
photo["$type"] = "social.grain.photo"
129
129
+
photo["createdAt"] = syntax.DatetimeNow()
130
130
+
photo["photo"] = resp.Blob
131
131
+
// TODO: if alt was not required, this would be behind an 'if'
132
132
+
photo["alt"] = cmd.String("alt")
133
133
+
134
134
+
res, err := agnostic.RepoCreateRecord(ctx, c, &agnostic.RepoCreateRecord_Input{
135
135
+
Collection: "social.grain.photo",
136
136
+
Record: photo,
137
137
+
Repo: c.AccountDID.String(),
138
138
+
})
79
139
if err != nil {
80
140
return err
81
141
}
82
142
83
83
-
b, err := json.MarshalIndent(resp.Blob, "", " ")
143
143
+
fmt.Printf("%s\t%s\n", res.Uri, res.Cid)
144
144
+
return nil
145
145
+
}
146
146
+
147
147
+
func runPhotoList(ctx context.Context, cmd *cli.Command) error {
148
148
+
c, err := loadClient()
84
149
if err != nil {
85
150
return err
86
151
}
87
152
88
88
-
fmt.Println(string(b))
153
153
+
out, err := agnostic.RepoListRecords(ctx, c, "social.grain.photo", "", 100, c.AccountDID.String(), false)
154
154
+
if err != nil {
155
155
+
return err
156
156
+
}
157
157
+
158
158
+
for _, rec := range out.Records {
159
159
+
// TODO: parse rec.Value to lexgen struct type
160
160
+
fmt.Printf("%s\n", rec.Uri)
161
161
+
}
162
162
+
163
163
+
return nil
164
164
+
}
165
165
+
166
166
+
func runGalleryList(ctx context.Context, cmd *cli.Command) error {
167
167
+
c, err := loadClient()
168
168
+
if err != nil {
169
169
+
return err
170
170
+
}
171
171
+
172
172
+
out, err := agnostic.RepoListRecords(ctx, c, "social.grain.gallery", "", 100, c.AccountDID.String(), false)
173
173
+
if err != nil {
174
174
+
return err
175
175
+
}
176
176
+
177
177
+
for _, rec := range out.Records {
178
178
+
// TODO: parse rec.Value to lexgen struct type
179
179
+
fmt.Printf("%s\n", rec.Uri)
180
180
+
}
181
181
+
89
182
return nil
90
183
}