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

use blup.imgs.blue to proxy oauth requests so that we don't need localhost

evan.jarrett.net 6472a316 498128b5

verified
Changed files
+144 -437
.vscode
cmd
cli
server
internal
auth
config
server
+1 -1
.vscode/launch.json
··· 19 19 "mode": "auto", 20 20 "program": "${workspaceFolder}/cmd/cli/main.go", 21 21 "args": [ 22 - "auth" 22 + "auth", "--handle", "evan.jarrett.net" 23 23 ], 24 24 } 25 25 ]
-1
.vscode/settings.json
··· 1 1 { 2 - "go.coverOnSave": true, 3 2 "go.coverageDecorator": { 4 3 "type": "gutter", 5 4 "coveredHighlightColor": "rgba(64,128,128,0.5)",
+47 -46
cmd/cli/main.go
··· 5 5 "fmt" 6 6 "io" 7 7 "net/http" 8 - "net/url" 9 8 "os" 10 9 "path/filepath" 11 10 "strings" ··· 16 15 "github.com/bluesky-social/indigo/xrpc" 17 16 oauth "github.com/haileyok/atproto-oauth-golang" 18 17 "github.com/spf13/cobra" 18 + "golang.design/x/clipboard" 19 19 "tangled.sh/evan.jarrett.net/blup/internal/auth" 20 20 "tangled.sh/evan.jarrett.net/blup/internal/config" 21 21 "tangled.sh/evan.jarrett.net/blup/internal/util" 22 22 ) 23 23 24 + const ( 25 + Name = "blup" 26 + CDN = "https://imgs.blue" 27 + ) 28 + 24 29 var ( 25 - cfg *config.Config 26 - expiresIn time.Duration 27 - contentType string 30 + authHandle string 28 31 ) 29 32 30 33 func main() { 31 34 var rootCmd = &cobra.Command{ 32 - Use: "atimg", 35 + Use: Name, 33 36 Short: "AT Protocol image hosting CLI", 34 - } 35 - 36 - var configureCmd = &cobra.Command{ 37 - Use: "configure", 38 - Short: "Configure OAuth client credentials", 39 - RunE: runConfigure, 40 37 } 41 38 42 39 var authCmd = &cobra.Command{ ··· 44 41 Short: "Authenticate with AT Protocol", 45 42 RunE: runAuth, 46 43 } 44 + authCmd.Flags().StringVar(&authHandle, "handle", "", "Bluesky (ATProto) handle") 47 45 48 46 var statusCmd = &cobra.Command{ 49 47 Use: "status", ··· 64 62 RunE: runUpload, 65 63 } 66 64 67 - uploadCmd.Flags().DurationVarP(&expiresIn, "expires", "e", 24*time.Hour, "Expiration duration") 68 - uploadCmd.Flags().StringVarP(&contentType, "type", "t", "", "Content type (auto-detected if not specified)") 69 - 70 - rootCmd.AddCommand(configureCmd, authCmd, statusCmd, logoutCmd, uploadCmd) 65 + rootCmd.AddCommand(authCmd, statusCmd, logoutCmd, uploadCmd) 71 66 72 67 var debugTokensCmd = &cobra.Command{ 73 68 Use: "debug-tokens", ··· 90 85 91 86 rootCmd.AddCommand(debugTokensCmd) 92 87 93 - // Load config 94 - var err error 95 - cfg, err = config.LoadConfig() 96 - if err != nil { 97 - fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) 98 - os.Exit(1) 99 - } 100 - 101 88 if err := rootCmd.Execute(); err != nil { 102 89 fmt.Fprintf(os.Stderr, "Error: %v\n", err) 103 90 os.Exit(1) 104 91 } 105 92 } 106 93 107 - func runConfigure(cmd *cobra.Command, args []string) error { 108 - fmt.Print("Enter your Bluesky handle: ") 94 + func runAuth(cmd *cobra.Command, args []string) error { 109 95 var handle string 110 - fmt.Scanln(&handle) 111 - if handle != "" { 112 - cfg.Handle = handle 96 + if authHandle == "" { 97 + fmt.Print("Enter your Bluesky handle: ") 98 + fmt.Scanln(&handle) 99 + } else { 100 + fmt.Printf("Using %s as bluesky handle\n", authHandle) 101 + handle = authHandle 113 102 } 114 103 115 - return config.SaveConfig(cfg) 116 - } 117 - 118 - func runAuth(cmd *cobra.Command, args []string) error { 119 - if cfg.ClientID == "" { 120 - return fmt.Errorf("please run 'atimg configure' first") 121 - } 122 - if cfg.ClientID == "http://localhost" { 123 - params := url.Values{} 124 - params.Set("redirect_uri", cfg.RedirectURI) 125 - params.Set("scope", cfg.Scope) 126 - cfg.ClientID = fmt.Sprintf("%s?%s", cfg.ClientID, params.Encode()) 104 + cfg, err := config.SaveConfig(&config.Config{ 105 + AuthserverIss: "https://bsky.social", 106 + Handle: handle, 107 + }) 108 + if err != nil { 109 + return err 127 110 } 128 111 129 112 if _, err := auth.RefreshTokens(cfg); err != nil { ··· 159 142 } 160 143 161 144 func runUpload(cmd *cobra.Command, args []string) error { 145 + // Load config 146 + cfg, err := config.LoadConfig() 147 + if err != nil { 148 + fmt.Fprintf(os.Stderr, "Error loading config: %v\nTry running '%s auth'\n", err, Name) 149 + os.Exit(1) 150 + } 151 + 162 152 ctx := context.Background() 163 153 imagepath := args[0] 164 154 ··· 235 225 } 236 226 237 227 record := map[string]interface{}{ 238 - "$type": "blue.imgs.blup.image", 228 + "$type": fmt.Sprintf("blue.imgs.%s.image", Name), 239 229 "blob": out.Blob, // Use the blob reference from upload 240 230 "createdAt": time.Now().Format(time.RFC3339), 241 231 "expiresAt": time.Now().Add(24 * time.Hour).Format(time.RFC3339), ··· 243 233 "contentType": contentType, 244 234 "size": fileSize, 245 235 "metadata": map[string]interface{}{ 246 - "uploadedFrom": "CLI tool", 236 + "uploadedFrom": Name, 247 237 "version": "1.0", 248 238 }, 249 239 } 250 240 251 241 reqBody := map[string]interface{}{ 252 242 "repo": tokens.Sub, 253 - "collection": "blue.imgs.blup.image", 243 + "collection": record["$type"], 254 244 "record": record, 255 245 } 256 246 ··· 266 256 if err != nil { 267 257 return err 268 258 } 269 - fmt.Printf("https://imgs.blue/%s/%s\n", authArgs.Did, blobCID) 270 - fmt.Printf("https://imgs.blue/%s/%s\n", cfg.Handle, converted) 259 + compressedUrl := fmt.Sprintf("%s/%s/%s\n", CDN, cfg.Handle, converted) 260 + 261 + fmt.Printf("%s/%s/%s\n", CDN, authArgs.Did, blobCID) 262 + fmt.Println(compressedUrl) 263 + 264 + // Initialize clipboard 265 + err = clipboard.Init() 266 + if err != nil { 267 + fmt.Printf("Warning: Could not initialize clipboard: %v\n", err) 268 + } else { 269 + clipboard.Write(clipboard.FmtText, []byte(compressedUrl)) 270 + fmt.Println("URL copied to clipboard") 271 + } 271 272 return nil 272 273 } 273 274 274 275 func userAgent() *string { 275 - s := fmt.Sprintf("blup/1.0") 276 + s := fmt.Sprintf("%s/1.0", Name) 276 277 return &s 277 278 }
-79
cmd/server/main.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "log" 6 - "net/http" 7 - "os" 8 - "os/signal" 9 - "syscall" 10 - "time" 11 - 12 - "tangled.sh/evan.jarrett.net/blup/internal/server" 13 - ) 14 - 15 - type ServerConfig struct { 16 - ListenAddr string 17 - } 18 - 19 - func loadServerConfig() (*ServerConfig, error) { 20 - cfg := &ServerConfig{ 21 - ListenAddr: getEnvOrDefault("LISTEN_ADDR", ":8080"), 22 - } 23 - 24 - return cfg, nil 25 - } 26 - 27 - func getEnvOrDefault(key, defaultValue string) string { 28 - if value := os.Getenv(key); value != "" { 29 - return value 30 - } 31 - return defaultValue 32 - } 33 - 34 - func main() { 35 - // Load configuration 36 - cfg, err := loadServerConfig() 37 - if err != nil { 38 - log.Fatalf("Configuration error: %v", err) 39 - } 40 - 41 - log.Printf("Starting AT Protocol Image Server") 42 - log.Printf("Listen Address: %s", cfg.ListenAddr) 43 - 44 - // Setup server 45 - srv := server.NewServer() 46 - 47 - httpServer := &http.Server{ 48 - Addr: cfg.ListenAddr, 49 - Handler: srv.Router(), 50 - ReadTimeout: 30 * time.Second, 51 - WriteTimeout: 30 * time.Second, 52 - IdleTimeout: 120 * time.Second, 53 - } 54 - 55 - // Start server 56 - go func() { 57 - log.Printf("Server listening on http://localhost%s", cfg.ListenAddr) 58 - if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { 59 - log.Fatalf("Server failed: %v", err) 60 - } 61 - }() 62 - 63 - // Wait for interrupt signal 64 - sigChan := make(chan os.Signal, 1) 65 - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) 66 - <-sigChan 67 - 68 - log.Println("Shutting down server...") 69 - 70 - // Graceful shutdown 71 - shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 72 - defer cancel() 73 - 74 - if err := httpServer.Shutdown(shutdownCtx); err != nil { 75 - log.Printf("Server shutdown error: %v", err) 76 - } 77 - 78 - log.Println("Server stopped") 79 - }
+5 -1
go.mod
··· 4 4 5 5 require ( 6 6 github.com/bluesky-social/indigo v0.0.0-20250621010046-488d1b91889b 7 + github.com/gen2brain/beeep v0.11.1 7 8 github.com/golang-jwt/jwt/v5 v5.2.2 8 - github.com/gorilla/mux v1.8.1 9 9 github.com/haileyok/atproto-oauth-golang v0.0.3-0.20250622200753-e07caa5274a7 10 10 github.com/ipfs/go-cid v0.5.0 11 11 github.com/lestrrat-go/jwx/v2 v2.1.6 12 12 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c 13 13 github.com/spf13/cobra v1.9.1 14 14 github.com/zalando/go-keyring v0.2.6 15 + golang.design/x/clipboard v0.7.1 15 16 ) 16 17 17 18 require ( ··· 70 71 go.uber.org/multierr v1.11.0 // indirect 71 72 go.uber.org/zap v1.27.0 // indirect 72 73 golang.org/x/crypto v0.39.0 // indirect 74 + golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 // indirect 75 + golang.org/x/image v0.28.0 // indirect 76 + golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f // indirect 73 77 golang.org/x/sys v0.33.0 // indirect 74 78 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect 75 79 lukechampine.com/blake3 v1.4.1 // indirect
+8 -2
go.sum
··· 175 175 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 176 176 github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= 177 177 github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= 178 - github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 179 - github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 180 178 github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= 181 179 github.com/haileyok/atproto-oauth-golang v0.0.3-0.20250622200753-e07caa5274a7 h1:P3oEaumqbWobYKGRj1Ku4jdgNxMogNlrLZJ097GkKmc= 182 180 github.com/haileyok/atproto-oauth-golang v0.0.3-0.20250622200753-e07caa5274a7/go.mod h1:vVRo6BPEmWOZnYk9LtXLzBPzfkY63fUaBahA+o4h55Q= ··· 421 419 go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= 422 420 go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 423 421 go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 422 + golang.design/x/clipboard v0.7.1 h1:OEG3CmcYRBNnRwpDp7+uWLiZi3hrMRJpE9JkkkYtz2c= 423 + golang.design/x/clipboard v0.7.1/go.mod h1:i5SiIqj0wLFw9P/1D7vfILFK0KHMk7ydE72HRrUIgkg= 424 424 golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 425 425 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 426 426 golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= ··· 442 442 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= 443 443 golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= 444 444 golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= 445 + golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 h1:Wdx0vgH5Wgsw+lF//LJKmWOJBLWX6nprsMqnf99rYDE= 446 + golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8= 445 447 golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= 446 448 golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 449 + golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE= 450 + golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY= 447 451 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 448 452 golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 449 453 golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= ··· 458 462 golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 459 463 golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= 460 464 golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= 465 + golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f h1:/n+PL2HlfqeSiDCuhdBbRNlGS/g2fM4OHufalHaTVG8= 466 + golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f/go.mod h1:ESkJ836Z6LpG6mTVAhA48LpfW/8fNR0ifStlH2axyfg= 461 467 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 462 468 golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= 463 469 golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+70 -80
internal/auth/oauth.go
··· 1 1 package auth 2 2 3 3 import ( 4 + "bufio" 4 5 "context" 5 6 "encoding/json" 6 7 "fmt" ··· 25 26 cfg *config.Config 26 27 jwk jwk.Key 27 28 server *http.Server 28 - authSuccess chan bool 29 + authSuccess chan *StoredTokens 29 30 authError chan error 30 31 savedState string 31 32 issuer string ··· 49 50 return nil, fmt.Errorf("failed to store JWKS: %w", err) 50 51 } 51 52 } 53 + 54 + clientMetadata := GetClientMetadata() 52 55 53 56 // Create OAuth client 54 57 clientConfig := oauth.ClientArgs{ 55 58 ClientJwk: key, 56 - ClientId: cfg.ClientID, 57 - RedirectUri: cfg.RedirectURI, 59 + ClientId: clientMetadata.ClientID, 60 + RedirectUri: clientMetadata.RedirectURIs[0], 58 61 } 59 62 60 63 client, err := oauth.NewClient(clientConfig) ··· 66 69 client: client, 67 70 cfg: cfg, 68 71 jwk: key, 69 - authSuccess: make(chan bool, 1), 72 + authSuccess: make(chan *StoredTokens, 1), 70 73 authError: make(chan error, 1), 71 74 }, nil 72 75 } 73 76 74 - func (f *OAuthFlow) Authenticate() error { 77 + func (f *OAuthFlow) Authenticate() (*StoredTokens, error) { 75 78 ctx := context.Background() 76 - // Start local server for callback 77 - err := f.startCallbackServer() 78 - if err != nil { 79 - return fmt.Errorf("failed to start callback server: %w", err) 80 - } 81 - defer f.stopCallbackServer() 82 - 83 79 var authServer, did, pdsHost string 80 + var err error 84 81 85 82 // Initialize authorization 86 83 cfg := f.cfg ··· 88 85 if cfg.Handle != "" { 89 86 did, err = ResolveHandle(ctx, cfg.Handle) 90 87 if err != nil { 91 - return err 88 + return nil, err 92 89 } 93 90 pdsHost, err = ResolveService(ctx, did) 94 91 if err != nil { 95 - return err 92 + return nil, err 96 93 } 97 94 authServer, err = f.client.ResolvePdsAuthServer(ctx, pdsHost) 98 95 if err != nil { 99 - return err 96 + return nil, err 100 97 } 101 98 cfg.PDSHost = pdsHost 102 99 cfg.AuthserverIss = authServer ··· 108 105 109 106 meta, err := f.client.FetchAuthServerMetadata(ctx, authServer) 110 107 if err != nil { 111 - return err 108 + return nil, err 112 109 } 113 110 114 - parResp, err := f.client.SendParAuthRequest(ctx, authServer, meta, "", cfg.Scope, f.jwk) 111 + clientMetadata := GetClientMetadata() 112 + 113 + parResp, err := f.client.SendParAuthRequest(ctx, authServer, meta, cfg.Handle, clientMetadata.Scope, f.jwk) 115 114 if err != nil { 116 - return err 115 + return nil, err 117 116 } 118 117 119 118 f.savedState = parResp.State ··· 122 121 f.nonce = parResp.DpopAuthserverNonce 123 122 124 123 authUrl, _ := url.Parse(meta.AuthorizationEndpoint) 125 - authUrl.RawQuery = fmt.Sprintf("client_id=%s&request_uri=%s", url.QueryEscape(cfg.ClientID), parResp.RequestUri) 124 + authUrl.RawQuery = fmt.Sprintf("client_id=%s&request_uri=%s", url.QueryEscape(clientMetadata.ClientID), parResp.RequestUri) 125 + 126 + go f.waitForAuthSSE() 126 127 127 128 // Open browser to authorization URL 128 129 fmt.Printf("Opening browser for authentication...\n") ··· 135 136 fmt.Println("Waiting for authentication...") 136 137 137 138 select { 138 - case <-f.authSuccess: 139 - 139 + case tokens := <-f.authSuccess: 140 140 fmt.Println("Authentication successful!") 141 - return nil 142 - 141 + return tokens, nil 143 142 case err := <-f.authError: 144 - return fmt.Errorf("authentication failed: %w", err) 145 - 143 + return nil, fmt.Errorf("authentication failed: %w", err) 146 144 case <-time.After(5 * time.Minute): 147 - return fmt.Errorf("authentication timeout") 145 + return nil, fmt.Errorf("authentication timeout") 148 146 } 149 147 } 150 148 151 - func (f *OAuthFlow) startCallbackServer() error { 152 - listener, err := net.Listen("tcp", ":8080") 153 - if err != nil { 154 - return err 155 - } 149 + func (f *OAuthFlow) waitForAuthSSE() (string, error) { 150 + clientMetadata := GetClientMetadata() 151 + req, err := http.NewRequest("GET", 152 + fmt.Sprintf("%s/oauth/events?session=%s", clientMetadata.ClientURI, f.savedState), 153 + nil) 154 + if err != nil { 155 + f.authError <- err 156 + } 156 157 157 - mux := http.NewServeMux() 158 - mux.HandleFunc("/callback", f.handleCallback) 158 + req.Header.Set("Accept", "text/event-stream") 159 + req.Header.Set("Cache-Control", "no-cache") 159 160 160 - f.server = &http.Server{ 161 - Handler: mux, 162 - } 161 + client := &http.Client{Timeout: 5 * time.Minute} 162 + resp, err := client.Do(req) 163 + if err != nil { 164 + f.authError <- err 165 + } 166 + defer resp.Body.Close() 163 167 164 - go func() { 165 - if err := f.server.Serve(listener); err != http.ErrServerClosed { 166 - f.authError <- err 167 - } 168 - }() 168 + reader := bufio.NewReader(resp.Body) 169 169 170 - return nil 171 - } 170 + for { 171 + line, err := reader.ReadString('\n') 172 + if err != nil { 173 + f.authError <- err 174 + } 172 175 173 - func (f *OAuthFlow) stopCallbackServer() { 174 - if f.server != nil { 175 - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 176 - defer cancel() 177 - f.server.Shutdown(ctx) 178 - } 176 + if strings.HasPrefix(line, "event: auth-complete") { 177 + // Read data line 178 + dataLine, _ := reader.ReadString('\n') 179 + if strings.HasPrefix(dataLine, "data: ") { 180 + data := strings.TrimPrefix(dataLine, "data: ") 181 + var authData struct { 182 + Code string `json:"code"` 183 + Iss string `json:"iss"` 184 + State string `json:"state"` 185 + } 186 + json.Unmarshal([]byte(data), &authData) 187 + f.handleCallback(req.Context(), authData.Code, authData.Iss, authData.State) 188 + } 189 + } 190 + } 179 191 } 180 192 181 - func (f *OAuthFlow) handleCallback(w http.ResponseWriter, r *http.Request) { 193 + func (f *OAuthFlow) handleCallback(ctx context.Context, code, iss, state string) { 182 194 // Get authorization code from query params 183 - code := r.URL.Query().Get("code") 184 - iss := r.URL.Query().Get("iss") 185 - state := r.URL.Query().Get("state") 186 - 187 195 if state == "" || iss == "" || code == "" { 188 196 f.authError <- fmt.Errorf("request missing needed parameters") 189 197 } ··· 194 202 195 203 if iss != f.issuer { 196 204 f.authError <- fmt.Errorf("incoming iss did not match authserver iss") 197 - 198 205 } 199 206 200 - initialTokenResp, err := f.client.InitialTokenRequest(r.Context(), code, iss, f.verifier, f.nonce, f.jwk) 207 + initialTokenResp, err := f.client.InitialTokenRequest(ctx, code, iss, f.verifier, f.nonce, f.jwk) 201 208 if err != nil { 202 209 f.authError <- err 203 210 } 204 211 205 - if initialTokenResp.Scope != f.cfg.Scope { 212 + if initialTokenResp.Scope != GetClientMetadata().Scope { 206 213 f.authError <- fmt.Errorf("did not receive correct scopes from token request") 207 214 } 208 215 209 216 // Store tokens 210 - if _, err := StoreTokensFromLibrary(initialTokenResp); err != nil { 217 + tokens, err := StoreTokensFromLibrary(initialTokenResp) 218 + if err != nil { 211 219 f.authError <- fmt.Errorf("failed to store tokens: %w", err) 212 220 } 213 221 214 222 // Send code through channel 215 - f.authSuccess <- true 216 - 217 - // Send success response 218 - w.WriteHeader(http.StatusOK) 219 - fmt.Fprintf(w, ` 220 - <!DOCTYPE html> 221 - <html> 222 - <head> 223 - <title>Authentication Successful</title> 224 - <style> 225 - body { font-family: Arial, sans-serif; text-align: center; padding: 50px; } 226 - .success { color: green; font-size: 24px; } 227 - </style> 228 - </head> 229 - <body> 230 - <div class="success">✓ Authentication successful!</div> 231 - <p>You can close this window and return to the application.</p> 232 - </body> 233 - </html>`) 223 + f.authSuccess <- tokens 234 224 } 235 225 236 226 // RefreshTokens refreshes the access token using the stored refresh token ··· 245 235 var newTokens *oauth.TokenResponse 246 236 247 237 if tokens == nil { 248 - return nil, flow.Authenticate() 238 + return flow.Authenticate() 249 239 } 250 240 if tokens.NeedsRefresh() { 251 241 ctx := context.Background() ··· 279 269 } 280 270 281 271 // Convert did:web:... to https://... 282 - if strings.HasPrefix(aud, "did:web:") { 283 - pdsHost := strings.TrimPrefix(aud, "did:web:") 272 + if after, ok0 :=strings.CutPrefix(aud, "did:web:"); ok0 { 273 + pdsHost := after 284 274 return "https://" + pdsHost, nil 285 275 } 286 276
+7 -28
internal/config/config.go
··· 2 2 3 3 import ( 4 4 "encoding/json" 5 - "fmt" 6 - "net/url" 7 5 "os" 8 6 "path/filepath" 9 7 "strings" 10 8 ) 11 9 12 10 type Config struct { 13 - ClientID string `json:"client_id"` 14 - ClientSecret string `json:"client_secret"` 15 - RedirectURI string `json:"redirect_uri"` 16 - Scope string `json:"scope"` 17 11 AuthserverIss string `json:"auth_server"` 18 12 PDSHost string `json:"pds_host"` 19 13 Handle string `json:"handle"` 20 14 } 21 15 22 16 func LoadConfig() (*Config, error) { 23 - configPath := filepath.Join(getConfigDir(), "config.json") 17 + configPath := getConfigFile() 24 18 25 19 // Default configuration 26 20 cfg := &Config{ 27 - RedirectURI: "http://127.0.0.1:8080/callback", 28 - Scope: "atproto transition:generic", 29 21 AuthserverIss: "https://bsky.social", 30 22 } 31 23 32 - params := url.Values{} 33 - params.Set("redirect_uri", cfg.RedirectURI) 34 - params.Set("scope", cfg.Scope) 35 - cfg.ClientID = fmt.Sprintf("http://localhost?%s", params.Encode()) 36 - 37 24 data, err := os.ReadFile(configPath) 38 25 if err != nil { 39 - if os.IsNotExist(err) { 40 - return cfg, nil 41 - } 42 26 return nil, err 43 27 } 44 28 ··· 49 33 return cfg, nil 50 34 } 51 35 52 - func SaveConfig(cfg *Config) error { 53 - configDir := getConfigDir() 54 - if err := os.MkdirAll(configDir, 0700); err != nil { 55 - return err 56 - } 57 - 36 + func SaveConfig(cfg *Config) (*Config, error) { 58 37 if cfg.Handle != "" { 59 38 cfg.Handle = strings.TrimPrefix(cfg.Handle, "@") 60 39 } 61 40 62 - configPath := filepath.Join(configDir, "config.json") 41 + configPath := getConfigFile() 63 42 data, err := json.MarshalIndent(cfg, "", " ") 64 43 if err != nil { 65 - return err 44 + return cfg, err 66 45 } 67 46 68 - return os.WriteFile(configPath, data, 0600) 47 + return cfg, os.WriteFile(configPath, data, 0600) 69 48 } 70 49 71 - func getConfigDir() string { 50 + func getConfigFile() string { 72 51 home, _ := os.UserHomeDir() 73 - return filepath.Join(home, ".atproto-image-host") 52 + return filepath.Join(home, ".blup.conf") 74 53 }
-193
internal/server/server.go
··· 1 - package server 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "encoding/json" 7 - "fmt" 8 - "io" 9 - "log" 10 - 11 - "net/http" 12 - "strings" 13 - "time" 14 - 15 - indigo_util "github.com/bluesky-social/indigo/util" 16 - "github.com/gorilla/mux" 17 - "tangled.sh/evan.jarrett.net/blup/internal/auth" 18 - "tangled.sh/evan.jarrett.net/blup/internal/util" 19 - ) 20 - 21 - type Server struct { 22 - router *mux.Router 23 - } 24 - 25 - func NewServer() *Server { 26 - s := &Server{} 27 - 28 - s.setupRoutes() 29 - return s 30 - } 31 - 32 - func (s *Server) setupRoutes() { 33 - r := mux.NewRouter() 34 - 35 - // Health check 36 - r.HandleFunc("/health", s.handleHealth).Methods("GET") 37 - 38 - // Image serving 39 - r.HandleFunc("/i/{did}/{cid}", s.handleServeImage).Methods("GET") 40 - 41 - // Simple home page 42 - r.HandleFunc("/", s.handleHome).Methods("GET") 43 - 44 - s.router = r 45 - } 46 - 47 - func (s *Server) Router() http.Handler { 48 - return s.router 49 - } 50 - 51 - func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { 52 - w.Header().Set("Content-Type", "application/json") 53 - json.NewEncoder(w).Encode(map[string]string{ 54 - "status": "ok", 55 - "timestamp": time.Now().Format(time.RFC3339), 56 - }) 57 - } 58 - 59 - func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { 60 - html := `<!DOCTYPE html> 61 - <html> 62 - <head> 63 - <title>AT Protocol Image Server</title> 64 - <style> 65 - body { font-family: Arial, sans-serif; margin: 40px; background: #f5f5f5; } 66 - .container { background: white; padding: 30px; border-radius: 8px; max-width: 600px; margin: 0 auto; } 67 - h1 { color: #1a365d; } 68 - .info { background: #e6fffa; padding: 15px; border-radius: 6px; margin: 20px 0; } 69 - code { background: #f7fafc; padding: 2px 6px; border-radius: 3px; } 70 - </style> 71 - </head> 72 - <body> 73 - <div class="container"> 74 - <h1>AT Protocol Image Server</h1> 75 - <div class="info"> 76 - <p>This server renders images uploaded to AT Protocol.</p> 77 - <p><strong>Usage:</strong> <code>/i/{blob-cid}</code></p> 78 - <p>Upload images using: <code>atimg upload image.png</code></p> 79 - </div> 80 - <p><a href="/health">Health Check</a></p> 81 - </div> 82 - </body> 83 - </html>` 84 - w.Header().Set("Content-Type", "text/html") 85 - w.Write([]byte(html)) 86 - } 87 - 88 - func (s *Server) handleServeImage(w http.ResponseWriter, r *http.Request) { 89 - vars := mux.Vars(r) 90 - did := vars["did"] 91 - cid := vars["cid"] 92 - 93 - ctx := r.Context() 94 - 95 - // Handle DID conversion 96 - if !strings.HasPrefix(did, "did:") { 97 - // Convert handle to DID (placeholder function) 98 - convertedDID, err := auth.ResolveHandle(ctx, did) 99 - if err != nil { 100 - log.Printf("error converting handle to DID: %v", err) 101 - http.Error(w, "Invalid handle", http.StatusBadRequest) 102 - return 103 - } 104 - did = convertedDID 105 - } 106 - 107 - // Handle CID conversion 108 - originalCID := cid 109 - if !util.IsBase32(cid) { 110 - // Assume it's base62 and convert to base32 111 - convertedCID, err := util.ConvertCIDBase62ToBase32(cid) 112 - if convertedCID == "" || err != nil { 113 - log.Printf("error converting base62 to base32 for CID: %s", cid) 114 - http.Error(w, "Invalid CID format", http.StatusBadRequest) 115 - return 116 - } 117 - // Add back the bafkrei prefix if it was removed 118 - if !strings.HasPrefix(convertedCID, "bafkrei") { 119 - convertedCID = "bafkrei" + convertedCID 120 - } 121 - cid = convertedCID 122 - } 123 - 124 - // Try unauthenticated access first 125 - blobData, err := s.downloadBlobUnauthenticated(ctx, did, cid) 126 - if err != nil { 127 - log.Printf("error downloading blob (did: %s, original_cid: %s, converted_cid: %s): %v", 128 - did, originalCID, cid, err) 129 - http.Error(w, "Not an image", http.StatusBadRequest) 130 - return 131 - } 132 - 133 - // Detect content type from the blob data 134 - contentType := http.DetectContentType(blobData.Bytes()) 135 - if !strings.HasPrefix(contentType, "image/") { 136 - http.Error(w, "Not an image", http.StatusBadRequest) 137 - return 138 - } 139 - 140 - // Serve the image 141 - w.Header().Set("Content-Type", contentType) 142 - w.Header().Set("Cache-Control", "public, max-age=3600") 143 - w.Write(blobData.Bytes()) 144 - } 145 - 146 - func (s *Server) downloadBlobUnauthenticated(ctx context.Context, did, blobRef string) (*bytes.Buffer, error) { 147 - // Try downloading from common AT Protocol endpoints without auth 148 - pdsHost, err := auth.ResolveService(ctx, did) 149 - if err != nil { 150 - return nil, err 151 - } 152 - 153 - url := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", pdsHost, did, blobRef) 154 - 155 - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 156 - if err != nil { 157 - return nil, err 158 - } 159 - req.Header.Set("Accept", "application/octet-stream") 160 - req.Header.Set("User-Agent", *userAgent()) 161 - 162 - resp, err := indigo_util.RobustHTTPClient().Do(req.WithContext(ctx)) 163 - if err != nil { 164 - return nil, err 165 - } 166 - 167 - defer resp.Body.Close() 168 - 169 - var imageData bytes.Buffer 170 - 171 - if resp.StatusCode == http.StatusOK { 172 - if resp.ContentLength < 0 { 173 - _, err := io.Copy(&imageData, resp.Body) 174 - if err != nil { 175 - return nil, fmt.Errorf("reading response body: %w", err) 176 - } 177 - } else { 178 - n, err := io.CopyN(&imageData, resp.Body, resp.ContentLength) 179 - if err != nil { 180 - return nil, fmt.Errorf("reading length delimited response body (%d < %d): %w", n, resp.ContentLength, err) 181 - } 182 - } 183 - 184 - return &imageData, nil 185 - } 186 - 187 - return nil, fmt.Errorf("blob not accessible via public endpoints") 188 - } 189 - 190 - func userAgent() *string { 191 - s := "blup-server/1.0" 192 - return &s 193 - }
+6 -6
tnyshoot
··· 7 7 img="/tmp/shot.png" 8 8 gnome-screenshot -af $img 9 9 clip=$(uploadImage $img) 10 - echo $clip | xclip -selection c 10 + #echo $clip | xclip -selection c 11 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 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