+9
-3
Makefile
+9
-3
Makefile
···
1
+
.PHONY: build install build-windows
1
2
2
-
PHONY: build-windows
3
+
build:
4
+
go build -o ./blup ./cmd/blup
5
+
6
+
install:
7
+
go install ./cmd/blup
8
+
3
9
build-windows:
4
-
@GOOS=windows go build -o ./blup.exe ./cmd/cli/main.go
5
-
@osslsigncode sign -pkcs12 ../mycert.pfx -askpass -n "Blup" -i "https://blup.imgs.blue" -in ./blup.exe -out ./blup-signed.exe
10
+
GOOS=windows go build -o ./blup.exe ./cmd/blup
11
+
osslsigncode sign -pkcs12 ../mycert.pfx -askpass -n "Blup" -i "https://blup.imgs.blue" -in ./blup.exe -out ./blup-signed.exe
+16
-30
cmd/blup/main.go
+16
-30
cmd/blup/main.go
···
18
18
"github.com/spf13/cobra"
19
19
"tangled.sh/evan.jarrett.net/blup/internal/auth"
20
20
"tangled.sh/evan.jarrett.net/blup/internal/clipboard"
21
-
"tangled.sh/evan.jarrett.net/blup/internal/config"
22
21
"tangled.sh/evan.jarrett.net/blup/internal/screenshot"
23
22
"tangled.sh/evan.jarrett.net/blup/internal/util"
24
23
)
···
48
47
Short: "Log in with AT Protocol",
49
48
RunE: runAuth,
50
49
}
51
-
loginCmd.Flags().StringVar(&authHandle, "handle", "", "Bluesky (ATProto) handle")
50
+
loginCmd.Flags().StringVar(&authHandle, "handle", "", "ATProto handle")
52
51
53
52
var statusCmd = &cobra.Command{
54
53
Use: "status",
···
111
110
func runAuth(cmd *cobra.Command, args []string) error {
112
111
var handle string
113
112
if authHandle == "" {
114
-
fmt.Print("Enter your Bluesky handle: ")
113
+
fmt.Print("Enter your ATProto handle: ")
115
114
fmt.Scanln(&handle)
116
115
} else {
117
-
fmt.Printf("Using %s as bluesky handle\n", authHandle)
116
+
fmt.Printf("Using %s as ATProto handle\n", authHandle)
118
117
handle = authHandle
119
118
}
120
119
121
-
cfg, err := config.SaveConfig(&config.Config{
122
-
AuthserverIss: "https://bsky.social",
123
-
Handle: handle,
124
-
})
125
-
if err != nil {
126
-
return err
127
-
}
120
+
handle = strings.TrimPrefix(handle, "@")
128
121
129
-
if _, err := auth.RefreshTokens(cfg); err != nil {
122
+
if _, err := auth.RefreshTokens(handle); err != nil {
130
123
return err
131
124
}
132
125
return nil
···
149
142
}
150
143
151
144
func runLogout(cmd *cobra.Command, args []string) error {
152
-
cfg, err := config.LoadConfig()
153
-
if err != nil {
154
-
// No config, nothing to logout
155
-
fmt.Println("Not logged in")
156
-
return nil
157
-
}
158
-
159
-
if err := auth.Logout(cfg); err != nil {
145
+
if err := auth.Logout(); err != nil {
160
146
return err
161
147
}
162
148
fmt.Println("Logged out successfully")
···
203
189
204
190
// uploadImage handles the core upload logic and returns the CDN URL
205
191
func uploadImage(imagePath string) (string, error) {
206
-
// Load config
207
-
cfg, err := config.LoadConfig()
208
-
if err != nil {
209
-
return "", fmt.Errorf("not authenticated, run '%s auth' first: %w", Name, err)
210
-
}
211
-
212
192
ctx := context.Background()
213
193
214
194
// Open file
···
246
226
return "", fmt.Errorf("error seeking file: %w", err)
247
227
}
248
228
249
-
// Get authenticated session
250
-
sess, err := auth.RefreshTokens(cfg)
229
+
// Get authenticated session (will re-auth if needed using saved login identifier)
230
+
sess, err := auth.RefreshTokens("")
251
231
if err != nil {
252
-
return "", err
232
+
return "", fmt.Errorf("not authenticated, run '%s login' first: %w", Name, err)
253
233
}
254
234
255
235
// Get API client from session
···
283
263
return "", fmt.Errorf("failed to decode upload response: %w", err)
284
264
}
285
265
266
+
// Resolve handle from DID for CDN URL
267
+
handle, err := auth.ResolveHandle(ctx, sess.Data.AccountDID)
268
+
if err != nil {
269
+
return "", fmt.Errorf("failed to resolve handle: %w", err)
270
+
}
271
+
286
272
// Create record
287
273
record := map[string]interface{}{
288
274
"$type": fmt.Sprintf("blue.imgs.%s.image", Name),
···
317
303
return "", err
318
304
}
319
305
320
-
return fmt.Sprintf("%s/%s/%s", CDN, cfg.Handle, converted), nil
306
+
return fmt.Sprintf("%s/%s/%s", CDN, handle, converted), nil
321
307
}
322
308
323
309
func setupLogging() {
+50
-47
internal/auth/oauth.go
+50
-47
internal/auth/oauth.go
···
12
12
"time"
13
13
14
14
"github.com/bluesky-social/indigo/atproto/auth/oauth"
15
+
"github.com/bluesky-social/indigo/atproto/identity"
16
+
"github.com/bluesky-social/indigo/atproto/syntax"
15
17
"github.com/pkg/browser"
16
-
"tangled.sh/evan.jarrett.net/blup/internal/config"
17
18
)
18
19
19
20
type OAuthFlow struct {
20
-
app *oauth.ClientApp
21
-
cfg *config.Config
22
-
store *KeyringAuthStore
23
-
authSuccess chan *oauth.ClientSessionData
24
-
authError chan error
25
-
savedState string
21
+
app *oauth.ClientApp
22
+
loginIdentifier string
23
+
store *KeyringAuthStore
24
+
authSuccess chan *oauth.ClientSessionData
25
+
authError chan error
26
+
savedState string
26
27
}
27
28
28
-
func NewOAuthFlow(cfg *config.Config) (*OAuthFlow, error) {
29
+
func NewOAuthFlow(loginIdentifier string) (*OAuthFlow, error) {
29
30
store := NewKeyringAuthStore()
30
31
clientConfig := GetClientConfig()
31
32
···
41
42
app := oauth.NewClientApp(&clientConfig, store)
42
43
43
44
return &OAuthFlow{
44
-
app: app,
45
-
cfg: cfg,
46
-
store: store,
47
-
authSuccess: make(chan *oauth.ClientSessionData, 1),
48
-
authError: make(chan error, 1),
45
+
app: app,
46
+
loginIdentifier: loginIdentifier,
47
+
store: store,
48
+
authSuccess: make(chan *oauth.ClientSessionData, 1),
49
+
authError: make(chan error, 1),
49
50
}, nil
50
51
}
51
52
52
53
func (f *OAuthFlow) Authenticate() (*oauth.ClientSessionData, error) {
53
54
ctx := context.Background()
54
-
cfg := f.cfg
55
55
56
-
if cfg.Handle == "" {
57
-
return nil, fmt.Errorf("handle is required")
56
+
if f.loginIdentifier == "" {
57
+
return nil, fmt.Errorf("login identifier is required")
58
58
}
59
59
60
60
// Start the OAuth flow - this handles handle resolution, PAR, etc.
61
61
// Note: StartAuthFlow internally calls SaveAuthRequestInfo which stores the state
62
-
redirectURL, err := f.app.StartAuthFlow(ctx, cfg.Handle)
62
+
redirectURL, err := f.app.StartAuthFlow(ctx, f.loginIdentifier)
63
63
if err != nil {
64
64
return nil, fmt.Errorf("failed to start auth flow: %w", err)
65
65
}
···
92
92
return nil, fmt.Errorf("failed to save current session: %w", err)
93
93
}
94
94
95
-
// Update config with PDS host
96
-
cfg.PDSHost = sess.HostURL
97
-
cfg.AuthserverIss = sess.AuthServerURL
98
-
config.SaveConfig(cfg)
95
+
// Save login identifier for re-authentication
96
+
if err := f.store.SetLoginIdentifier(f.loginIdentifier); err != nil {
97
+
return nil, fmt.Errorf("failed to save login identifier: %w", err)
98
+
}
99
99
100
100
return sess, nil
101
101
case err := <-f.authError:
···
189
189
f.authSuccess <- sess
190
190
}
191
191
192
+
// ResolveHandle resolves a handle from a DID using the identity directory
193
+
func ResolveHandle(ctx context.Context, did syntax.DID) (string, error) {
194
+
dir := identity.DefaultDirectory()
195
+
ident, err := dir.LookupDID(ctx, did)
196
+
if err != nil {
197
+
return "", fmt.Errorf("failed to resolve DID: %w", err)
198
+
}
199
+
return ident.Handle.String(), nil
200
+
}
201
+
192
202
// GetSession retrieves the current session, refreshing tokens if needed
193
-
func GetSession(cfg *config.Config) (*oauth.ClientSession, error) {
203
+
func GetSession() (*oauth.ClientSession, error) {
194
204
ctx := context.Background()
195
205
store := NewKeyringAuthStore()
196
206
···
213
223
}
214
224
215
225
// RefreshTokens refreshes the access token if needed and returns the session
216
-
func RefreshTokens(cfg *config.Config) (*oauth.ClientSession, error) {
226
+
// If loginIdentifier is provided, it will be used for initial auth if no session exists
227
+
// If empty, it will use the saved login identifier from keyring
228
+
func RefreshTokens(loginIdentifier string) (*oauth.ClientSession, error) {
217
229
ctx := context.Background()
218
230
store := NewKeyringAuthStore()
219
231
220
-
// Check for legacy tokens and migrate
221
-
if HasLegacyTokens() {
222
-
DeleteLegacyTokens()
223
-
fmt.Println("Your stored credentials are from an older version.")
224
-
fmt.Println("Please re-authenticate.")
225
-
// Trigger new auth flow
226
-
flow, err := NewOAuthFlow(cfg)
227
-
if err != nil {
228
-
return nil, err
229
-
}
230
-
sess, err := flow.Authenticate()
231
-
if err != nil {
232
-
return nil, err
233
-
}
234
-
// Resume the session to return a ClientSession
235
-
clientConfig := GetClientConfig()
236
-
app := oauth.NewClientApp(&clientConfig, store)
237
-
return app.ResumeSession(ctx, sess.AccountDID, sess.SessionID)
238
-
}
239
-
240
232
// Check for current session
241
233
sessData, err := store.GetCurrentSession(ctx)
242
234
if err != nil {
243
235
// No session, need to authenticate
244
-
flow, err := NewOAuthFlow(cfg)
236
+
if loginIdentifier == "" {
237
+
return nil, fmt.Errorf("no active session and no login identifier provided")
238
+
}
239
+
flow, err := NewOAuthFlow(loginIdentifier)
245
240
if err != nil {
246
241
return nil, err
247
242
}
···
262
257
sess, err := app.ResumeSession(ctx, sessData.AccountDID, sessData.SessionID)
263
258
if err != nil {
264
259
// Session invalid, need to re-authenticate
265
-
flow, err := NewOAuthFlow(cfg)
260
+
// Get login identifier from keyring if not provided
261
+
if loginIdentifier == "" {
262
+
loginIdentifier, err = store.GetLoginIdentifier()
263
+
if err != nil {
264
+
return nil, fmt.Errorf("session expired and no saved login identifier: %w", err)
265
+
}
266
+
}
267
+
flow, err := NewOAuthFlow(loginIdentifier)
266
268
if err != nil {
267
269
return nil, err
268
270
}
···
277
279
}
278
280
279
281
// Logout revokes tokens and clears the session
280
-
func Logout(cfg *config.Config) error {
282
+
func Logout() error {
281
283
ctx := context.Background()
282
284
store := NewKeyringAuthStore()
283
285
···
296
298
fmt.Printf("Warning: failed to revoke tokens: %v\n", err)
297
299
}
298
300
299
-
// Clear current session reference
301
+
// Clear current session reference and login identifier
302
+
store.ClearLoginIdentifier()
300
303
return store.ClearCurrentSession()
301
304
}
+17
-17
internal/auth/storage.go
+17
-17
internal/auth/storage.go
···
11
11
)
12
12
13
13
const (
14
-
keyringService = "blup"
15
-
currentSessionKey = "current-session"
16
-
sessionKeyPrefix = "session:"
17
-
authRequestPrefix = "auth-request:"
18
-
pendingAuthStateKey = "pending-auth-state"
19
-
legacyTokensKey = "oauth-tokens"
20
-
legacyJWKSKey = "oauth-jwks"
14
+
keyringService = "blup"
15
+
currentSessionKey = "current-session"
16
+
sessionKeyPrefix = "session:"
17
+
authRequestPrefix = "auth-request:"
18
+
pendingAuthStateKey = "pending-auth-state"
19
+
loginIdentifierKey = "login-identifier"
21
20
)
22
21
23
22
// KeyringAuthStore implements oauth.ClientAuthStore using the system keyring
···
159
158
return keyring.Delete(keyringService, currentSessionKey)
160
159
}
161
160
162
-
// HasLegacyTokens checks if old-format tokens exist (for migration)
163
-
func HasLegacyTokens() bool {
164
-
_, err := keyring.Get(keyringService, legacyTokensKey)
165
-
return err == nil
161
+
// GetLoginIdentifier retrieves the stored login identifier (handle or PDS URL)
162
+
func (s *KeyringAuthStore) GetLoginIdentifier() (string, error) {
163
+
return keyring.Get(keyringService, loginIdentifierKey)
164
+
}
165
+
166
+
// SetLoginIdentifier stores the login identifier for re-authentication
167
+
func (s *KeyringAuthStore) SetLoginIdentifier(id string) error {
168
+
return keyring.Set(keyringService, loginIdentifierKey, id)
166
169
}
167
170
168
-
// DeleteLegacyTokens removes old-format tokens and JWKS
169
-
func DeleteLegacyTokens() error {
170
-
// Ignore errors - keys might not exist
171
-
keyring.Delete(keyringService, legacyTokensKey)
172
-
keyring.Delete(keyringService, legacyJWKSKey)
173
-
return nil
171
+
// ClearLoginIdentifier removes the stored login identifier
172
+
func (s *KeyringAuthStore) ClearLoginIdentifier() error {
173
+
return keyring.Delete(keyringService, loginIdentifierKey)
174
174
}
-53
internal/config/config.go
-53
internal/config/config.go
···
1
-
package config
2
-
3
-
import (
4
-
"encoding/json"
5
-
"os"
6
-
"path/filepath"
7
-
"strings"
8
-
)
9
-
10
-
type Config struct {
11
-
AuthserverIss string `json:"auth_server"`
12
-
PDSHost string `json:"pds_host"`
13
-
Handle string `json:"handle"`
14
-
}
15
-
16
-
func LoadConfig() (*Config, error) {
17
-
configPath := GetConfigFile()
18
-
19
-
// Default configuration
20
-
cfg := &Config{
21
-
AuthserverIss: "https://bsky.social",
22
-
}
23
-
24
-
data, err := os.ReadFile(configPath)
25
-
if err != nil {
26
-
return nil, err
27
-
}
28
-
29
-
if err := json.Unmarshal(data, cfg); err != nil {
30
-
return nil, err
31
-
}
32
-
33
-
return cfg, nil
34
-
}
35
-
36
-
func SaveConfig(cfg *Config) (*Config, error) {
37
-
if cfg.Handle != "" {
38
-
cfg.Handle = strings.TrimPrefix(cfg.Handle, "@")
39
-
}
40
-
41
-
configPath := GetConfigFile()
42
-
data, err := json.MarshalIndent(cfg, "", " ")
43
-
if err != nil {
44
-
return cfg, err
45
-
}
46
-
47
-
return cfg, os.WriteFile(configPath, data, 0600)
48
-
}
49
-
50
-
func GetConfigFile() string {
51
-
config, _ := os.UserConfigDir()
52
-
return filepath.Join(config, ".blup.json")
53
-
}
+4
-7
lexicons/blue.imgs.blup.image.json
+4
-7
lexicons/blue.imgs.blup.image.json
···
33
33
},
34
34
"contentType": {
35
35
"type": "string",
36
-
"description": "MIME type of the image"
36
+
"description": "MIME type of the image",
37
+
"maxLength": 128
37
38
},
38
39
"size": {
39
40
"type": "integer",
40
41
"description": "Size of the image in bytes"
41
42
},
42
43
"metadata": {
43
-
"type": "object",
44
-
"description": "Additional metadata",
45
-
"properties": {
46
-
"type": "string",
47
-
"maxLength": 1000
48
-
}
44
+
"type": "unknown",
45
+
"description": "Additional metadata"
49
46
}
50
47
}
51
48
}
-16
tnyshoot
-16
tnyshoot
···
1
-
#!/bin/bash
2
-
function uploadImage {
3
-
./blup upload $1
4
-
}
5
-
6
-
sleep 0.2
7
-
img="/tmp/shot.png"
8
-
gnome-screenshot -af $img
9
-
clip=$(uploadImage $img)
10
-
#echo $clip | xclip -selection c
11
-
rm $img
12
-
# clip=https://imgs.blue/evan.jarrett.net/1TpTO2IcOkZLCV0ND6yxtza3jyrK39A1iOgOkzGvTorMD03w
13
-
# response=$(notify-send -a "TnyClick" "Image Uploaded" "$clip" -A default="View" -t 2000)
14
-
# if [[ $response == "default" ]]; then
15
-
# xdg-open "$clip"
16
-
# fi