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 + .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
··· 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
··· 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
··· 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
··· 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 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
··· 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