Upload images to your PDS and get instant CDN URLs via images.blue

large cleanup of unused code

evan.jarrett.net 03a9b20b 7c5bbc48

verified
Changed files
+96 -173
cmd
blup
internal
lexicons
+9 -3
Makefile
··· 1 2 - PHONY: build-windows 3 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
···