+1
-1
.vscode/launch.json
+1
-1
.vscode/launch.json
-1
.vscode/settings.json
-1
.vscode/settings.json
+47
-46
cmd/cli/main.go
+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
-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
+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
+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
+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
+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
-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
+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