+9
-3
Makefile
+9
-3
Makefile
···
1
+
.PHONY: build install build-windows
2
3
+
build:
4
+
go build -o ./blup ./cmd/blup
5
+
6
+
install:
7
+
go install ./cmd/blup
8
+
9
build-windows:
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
"github.com/spf13/cobra"
19
"tangled.sh/evan.jarrett.net/blup/internal/auth"
20
"tangled.sh/evan.jarrett.net/blup/internal/clipboard"
21
-
"tangled.sh/evan.jarrett.net/blup/internal/config"
22
"tangled.sh/evan.jarrett.net/blup/internal/screenshot"
23
"tangled.sh/evan.jarrett.net/blup/internal/util"
24
)
···
48
Short: "Log in with AT Protocol",
49
RunE: runAuth,
50
}
51
-
loginCmd.Flags().StringVar(&authHandle, "handle", "", "Bluesky (ATProto) handle")
52
53
var statusCmd = &cobra.Command{
54
Use: "status",
···
111
func runAuth(cmd *cobra.Command, args []string) error {
112
var handle string
113
if authHandle == "" {
114
-
fmt.Print("Enter your Bluesky handle: ")
115
fmt.Scanln(&handle)
116
} else {
117
-
fmt.Printf("Using %s as bluesky handle\n", authHandle)
118
handle = authHandle
119
}
120
121
-
cfg, err := config.SaveConfig(&config.Config{
122
-
AuthserverIss: "https://bsky.social",
123
-
Handle: handle,
124
-
})
125
-
if err != nil {
126
-
return err
127
-
}
128
129
-
if _, err := auth.RefreshTokens(cfg); err != nil {
130
return err
131
}
132
return nil
···
149
}
150
151
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 {
160
return err
161
}
162
fmt.Println("Logged out successfully")
···
203
204
// uploadImage handles the core upload logic and returns the CDN URL
205
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
ctx := context.Background()
213
214
// Open file
···
246
return "", fmt.Errorf("error seeking file: %w", err)
247
}
248
249
-
// Get authenticated session
250
-
sess, err := auth.RefreshTokens(cfg)
251
if err != nil {
252
-
return "", err
253
}
254
255
// Get API client from session
···
283
return "", fmt.Errorf("failed to decode upload response: %w", err)
284
}
285
286
// Create record
287
record := map[string]interface{}{
288
"$type": fmt.Sprintf("blue.imgs.%s.image", Name),
···
317
return "", err
318
}
319
320
-
return fmt.Sprintf("%s/%s/%s", CDN, cfg.Handle, converted), nil
321
}
322
323
func setupLogging() {
···
18
"github.com/spf13/cobra"
19
"tangled.sh/evan.jarrett.net/blup/internal/auth"
20
"tangled.sh/evan.jarrett.net/blup/internal/clipboard"
21
"tangled.sh/evan.jarrett.net/blup/internal/screenshot"
22
"tangled.sh/evan.jarrett.net/blup/internal/util"
23
)
···
47
Short: "Log in with AT Protocol",
48
RunE: runAuth,
49
}
50
+
loginCmd.Flags().StringVar(&authHandle, "handle", "", "ATProto handle")
51
52
var statusCmd = &cobra.Command{
53
Use: "status",
···
110
func runAuth(cmd *cobra.Command, args []string) error {
111
var handle string
112
if authHandle == "" {
113
+
fmt.Print("Enter your ATProto handle: ")
114
fmt.Scanln(&handle)
115
} else {
116
+
fmt.Printf("Using %s as ATProto handle\n", authHandle)
117
handle = authHandle
118
}
119
120
+
handle = strings.TrimPrefix(handle, "@")
121
122
+
if _, err := auth.RefreshTokens(handle); err != nil {
123
return err
124
}
125
return nil
···
142
}
143
144
func runLogout(cmd *cobra.Command, args []string) error {
145
+
if err := auth.Logout(); err != nil {
146
return err
147
}
148
fmt.Println("Logged out successfully")
···
189
190
// uploadImage handles the core upload logic and returns the CDN URL
191
func uploadImage(imagePath string) (string, error) {
192
ctx := context.Background()
193
194
// Open file
···
226
return "", fmt.Errorf("error seeking file: %w", err)
227
}
228
229
+
// Get authenticated session (will re-auth if needed using saved login identifier)
230
+
sess, err := auth.RefreshTokens("")
231
if err != nil {
232
+
return "", fmt.Errorf("not authenticated, run '%s login' first: %w", Name, err)
233
}
234
235
// Get API client from session
···
263
return "", fmt.Errorf("failed to decode upload response: %w", err)
264
}
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
+
272
// Create record
273
record := map[string]interface{}{
274
"$type": fmt.Sprintf("blue.imgs.%s.image", Name),
···
303
return "", err
304
}
305
306
+
return fmt.Sprintf("%s/%s/%s", CDN, handle, converted), nil
307
}
308
309
func setupLogging() {
+50
-47
internal/auth/oauth.go
+50
-47
internal/auth/oauth.go
···
12
"time"
13
14
"github.com/bluesky-social/indigo/atproto/auth/oauth"
15
"github.com/pkg/browser"
16
-
"tangled.sh/evan.jarrett.net/blup/internal/config"
17
)
18
19
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
26
}
27
28
-
func NewOAuthFlow(cfg *config.Config) (*OAuthFlow, error) {
29
store := NewKeyringAuthStore()
30
clientConfig := GetClientConfig()
31
···
41
app := oauth.NewClientApp(&clientConfig, store)
42
43
return &OAuthFlow{
44
-
app: app,
45
-
cfg: cfg,
46
-
store: store,
47
-
authSuccess: make(chan *oauth.ClientSessionData, 1),
48
-
authError: make(chan error, 1),
49
}, nil
50
}
51
52
func (f *OAuthFlow) Authenticate() (*oauth.ClientSessionData, error) {
53
ctx := context.Background()
54
-
cfg := f.cfg
55
56
-
if cfg.Handle == "" {
57
-
return nil, fmt.Errorf("handle is required")
58
}
59
60
// Start the OAuth flow - this handles handle resolution, PAR, etc.
61
// Note: StartAuthFlow internally calls SaveAuthRequestInfo which stores the state
62
-
redirectURL, err := f.app.StartAuthFlow(ctx, cfg.Handle)
63
if err != nil {
64
return nil, fmt.Errorf("failed to start auth flow: %w", err)
65
}
···
92
return nil, fmt.Errorf("failed to save current session: %w", err)
93
}
94
95
-
// Update config with PDS host
96
-
cfg.PDSHost = sess.HostURL
97
-
cfg.AuthserverIss = sess.AuthServerURL
98
-
config.SaveConfig(cfg)
99
100
return sess, nil
101
case err := <-f.authError:
···
189
f.authSuccess <- sess
190
}
191
192
// GetSession retrieves the current session, refreshing tokens if needed
193
-
func GetSession(cfg *config.Config) (*oauth.ClientSession, error) {
194
ctx := context.Background()
195
store := NewKeyringAuthStore()
196
···
213
}
214
215
// RefreshTokens refreshes the access token if needed and returns the session
216
-
func RefreshTokens(cfg *config.Config) (*oauth.ClientSession, error) {
217
ctx := context.Background()
218
store := NewKeyringAuthStore()
219
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
// Check for current session
241
sessData, err := store.GetCurrentSession(ctx)
242
if err != nil {
243
// No session, need to authenticate
244
-
flow, err := NewOAuthFlow(cfg)
245
if err != nil {
246
return nil, err
247
}
···
262
sess, err := app.ResumeSession(ctx, sessData.AccountDID, sessData.SessionID)
263
if err != nil {
264
// Session invalid, need to re-authenticate
265
-
flow, err := NewOAuthFlow(cfg)
266
if err != nil {
267
return nil, err
268
}
···
277
}
278
279
// Logout revokes tokens and clears the session
280
-
func Logout(cfg *config.Config) error {
281
ctx := context.Background()
282
store := NewKeyringAuthStore()
283
···
296
fmt.Printf("Warning: failed to revoke tokens: %v\n", err)
297
}
298
299
-
// Clear current session reference
300
return store.ClearCurrentSession()
301
}
···
12
"time"
13
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"
17
"github.com/pkg/browser"
18
)
19
20
type OAuthFlow struct {
21
+
app *oauth.ClientApp
22
+
loginIdentifier string
23
+
store *KeyringAuthStore
24
+
authSuccess chan *oauth.ClientSessionData
25
+
authError chan error
26
+
savedState string
27
}
28
29
+
func NewOAuthFlow(loginIdentifier string) (*OAuthFlow, error) {
30
store := NewKeyringAuthStore()
31
clientConfig := GetClientConfig()
32
···
42
app := oauth.NewClientApp(&clientConfig, store)
43
44
return &OAuthFlow{
45
+
app: app,
46
+
loginIdentifier: loginIdentifier,
47
+
store: store,
48
+
authSuccess: make(chan *oauth.ClientSessionData, 1),
49
+
authError: make(chan error, 1),
50
}, nil
51
}
52
53
func (f *OAuthFlow) Authenticate() (*oauth.ClientSessionData, error) {
54
ctx := context.Background()
55
56
+
if f.loginIdentifier == "" {
57
+
return nil, fmt.Errorf("login identifier is required")
58
}
59
60
// Start the OAuth flow - this handles handle resolution, PAR, etc.
61
// Note: StartAuthFlow internally calls SaveAuthRequestInfo which stores the state
62
+
redirectURL, err := f.app.StartAuthFlow(ctx, f.loginIdentifier)
63
if err != nil {
64
return nil, fmt.Errorf("failed to start auth flow: %w", err)
65
}
···
92
return nil, fmt.Errorf("failed to save current session: %w", err)
93
}
94
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
100
return sess, nil
101
case err := <-f.authError:
···
189
f.authSuccess <- sess
190
}
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
+
202
// GetSession retrieves the current session, refreshing tokens if needed
203
+
func GetSession() (*oauth.ClientSession, error) {
204
ctx := context.Background()
205
store := NewKeyringAuthStore()
206
···
223
}
224
225
// RefreshTokens refreshes the access token if needed and returns the session
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) {
229
ctx := context.Background()
230
store := NewKeyringAuthStore()
231
232
// Check for current session
233
sessData, err := store.GetCurrentSession(ctx)
234
if err != nil {
235
// No session, need to authenticate
236
+
if loginIdentifier == "" {
237
+
return nil, fmt.Errorf("no active session and no login identifier provided")
238
+
}
239
+
flow, err := NewOAuthFlow(loginIdentifier)
240
if err != nil {
241
return nil, err
242
}
···
257
sess, err := app.ResumeSession(ctx, sessData.AccountDID, sessData.SessionID)
258
if err != nil {
259
// Session invalid, need to re-authenticate
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)
268
if err != nil {
269
return nil, err
270
}
···
279
}
280
281
// Logout revokes tokens and clears the session
282
+
func Logout() error {
283
ctx := context.Background()
284
store := NewKeyringAuthStore()
285
···
298
fmt.Printf("Warning: failed to revoke tokens: %v\n", err)
299
}
300
301
+
// Clear current session reference and login identifier
302
+
store.ClearLoginIdentifier()
303
return store.ClearCurrentSession()
304
}
+17
-17
internal/auth/storage.go
+17
-17
internal/auth/storage.go
···
11
)
12
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"
21
)
22
23
// KeyringAuthStore implements oauth.ClientAuthStore using the system keyring
···
159
return keyring.Delete(keyringService, currentSessionKey)
160
}
161
162
-
// HasLegacyTokens checks if old-format tokens exist (for migration)
163
-
func HasLegacyTokens() bool {
164
-
_, err := keyring.Get(keyringService, legacyTokensKey)
165
-
return err == nil
166
}
167
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
174
}
···
11
)
12
13
const (
14
+
keyringService = "blup"
15
+
currentSessionKey = "current-session"
16
+
sessionKeyPrefix = "session:"
17
+
authRequestPrefix = "auth-request:"
18
+
pendingAuthStateKey = "pending-auth-state"
19
+
loginIdentifierKey = "login-identifier"
20
)
21
22
// KeyringAuthStore implements oauth.ClientAuthStore using the system keyring
···
158
return keyring.Delete(keyringService, currentSessionKey)
159
}
160
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)
169
}
170
171
+
// ClearLoginIdentifier removes the stored login identifier
172
+
func (s *KeyringAuthStore) ClearLoginIdentifier() error {
173
+
return keyring.Delete(keyringService, loginIdentifierKey)
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
},
34
"contentType": {
35
"type": "string",
36
-
"description": "MIME type of the image"
37
},
38
"size": {
39
"type": "integer",
40
"description": "Size of the image in bytes"
41
},
42
"metadata": {
43
-
"type": "object",
44
-
"description": "Additional metadata",
45
-
"properties": {
46
-
"type": "string",
47
-
"maxLength": 1000
48
-
}
49
}
50
}
51
}
···
33
},
34
"contentType": {
35
"type": "string",
36
+
"description": "MIME type of the image",
37
+
"maxLength": 128
38
},
39
"size": {
40
"type": "integer",
41
"description": "Size of the image in bytes"
42
},
43
"metadata": {
44
+
"type": "unknown",
45
+
"description": "Additional metadata"
46
}
47
}
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
···