[WIP] music platform user data scraper
teal-fm atproto

Compare changes

Choose any two refs to compare.

+2
.env.template
··· 16 16 ATPROTO_CLIENT_ID= 17 17 ATPROTO_METADATA_URL= 18 18 ATPROTO_CALLBACK_URL= 19 + ATPROTO_CLIENT_SECRET_KEY={goat key generate -t P-256} 20 + ATPROTO_CLIENT_SECRET_KEY_ID={can be whatever usually a timestamp} 19 21 20 22 # Last.fm 21 23 LASTFM_API_KEY=
+10 -4
Dockerfile
··· 1 - //TODO will need a node buiilder here for tailwindcss 1 + FROM --platform=${BUILDPLATFORM:-linux/amd64} node:24-alpine3.21 as node_builder 2 + WORKDIR /app 3 + RUN npm install tailwindcss @tailwindcss/cli 2 4 5 + COPY ./pages/templates /app/templates 6 + COPY ./pages/static /app/static 7 + 8 + RUN npx @tailwindcss/cli -i /app/static/base.css -o /app/static/main.css -m 3 9 4 10 FROM --platform=${BUILDPLATFORM:-linux/amd64} golang:1.24.3-alpine3.21 as builder 5 11 ··· 20 26 # step 2. build the actual app 21 27 WORKDIR /app 22 28 COPY . . 23 - #generate the jwks 24 - RUN go run github.com/haileyok/atproto-oauth-golang/cmd/helper generate-jwks 29 + #Overwrite the main.css with the one from the builder 30 + COPY --from=node_builder /app/static/main.css /app/pages/static/main.css 31 + #generate the jwks 25 32 RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags='-w -s -extldflags "-static"' -o main ./cmd 26 33 ARG TARGETOS=${TARGETPLATFORM%%/*} 27 34 ARG TARGETARCH=${TARGETPLATFORM##*/} ··· 31 38 WORKDIR /db 32 39 WORKDIR /app 33 40 COPY --from=builder /app/main /app/main 34 - COPY --from=builder /app/jwks.json /app/jwks.json 35 41 ENTRYPOINT ["/app/main"]
-4
Makefile
··· 25 25 --build-file ./lexcfg.json \ 26 26 ../atproto/lexicons \ 27 27 ./lexicons/teal 28 - 29 - .PHONY: jwtgen 30 - jwtgen: 31 - go run github.com/haileyok/atproto-oauth-golang/cmd/helper generate-jwks
+8 -1
README.md
··· 23 23 24 24 This is a break down of what each env variable is and what it may look like 25 25 26 + **_breaking piper/v0.0.2 changes env_** 27 + 28 + You now have to bring your own private key to run piper. Can do this via goat `goat key generate -t P-256`. You want the one that is labeled under "Secret Key (Multibase Syntax): save this securely (eg, add to password manager)" 29 + 30 + - `ATPROTO_CLIENT_SECRET_KEY` - Private key for oauth confidential client. This can be generated via goat `goat key generate -t P-256` 31 + - `ATPROTO_CLIENT_SECRET_KEY_ID` - Key ID for oauth confidential client. This needs to be persistent and unique, can use a timestamp. Here's one for you: `1758199756` 32 + 33 + 26 34 - `SERVER_PORT` - The port piper is hosted on 27 35 - `SERVER_HOST` - The server host. `localhost` is fine here, or `0.0.0.0` for docker 28 36 - `SERVER_ROOT_URL` - This needs to be the pubically accessible url created in [Setup](#setup). Like `https://piper.teal.fm` ··· 53 61 run some make scripts: 54 62 55 63 ``` 56 - make jwtgen 57 64 58 65 make dev-setup 59 66 ```
+44 -4
cmd/handlers.go
··· 9 9 10 10 "github.com/teal-fm/piper/db" 11 11 "github.com/teal-fm/piper/models" 12 + atprotoauth "github.com/teal-fm/piper/oauth/atproto" 12 13 pages "github.com/teal-fm/piper/pages" 14 + atprotoservice "github.com/teal-fm/piper/service/atproto" 13 15 "github.com/teal-fm/piper/service/musicbrainz" 16 + "github.com/teal-fm/piper/service/playingnow" 14 17 "github.com/teal-fm/piper/service/spotify" 15 18 "github.com/teal-fm/piper/session" 16 19 ) ··· 187 190 188 191 func apiMusicBrainzSearch(mbService *musicbrainz.MusicBrainzService) http.HandlerFunc { 189 192 return func(w http.ResponseWriter, r *http.Request) { 193 + if mbService == nil { 194 + jsonResponse(w, http.StatusServiceUnavailable, map[string]string{"error": "MusicBrainz service is not available"}) 195 + return 196 + } 190 197 191 198 params := musicbrainz.SearchParams{ 192 199 Track: r.URL.Query().Get("track"), ··· 318 325 } 319 326 320 327 // apiSubmitListensHandler handles ListenBrainz-compatible submissions 321 - func apiSubmitListensHandler(database *db.DB) http.HandlerFunc { 328 + func apiSubmitListensHandler(database *db.DB, atprotoService *atprotoauth.ATprotoAuthService, playingNowService *playingnow.PlayingNowService, mbService *musicbrainz.MusicBrainzService) http.HandlerFunc { 322 329 return func(w http.ResponseWriter, r *http.Request) { 323 330 userID, authenticated := session.GetUserID(r.Context()) 324 331 if !authenticated { ··· 358 365 return 359 366 } 360 367 368 + // Get user for PDS submission 369 + user, err := database.GetUserByID(userID) 370 + if err != nil { 371 + log.Printf("apiSubmitListensHandler: Error getting user %d: %v", userID, err) 372 + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "Failed to get user"}) 373 + return 374 + } 375 + 361 376 // Process each listen in the payload 362 377 var processedTracks []models.Track 363 378 var errors []string ··· 376 391 // Convert to internal Track format 377 392 track := listen.ConvertToTrack(userID) 378 393 379 - // For 'playing_now' type, we might want to handle differently 380 - // For now, treat all the same but could add temporary storage later 394 + // Attempt to hydrate with MusicBrainz data if service is available and track doesn't have MBIDs 395 + if mbService != nil && track.RecordingMBID == nil { 396 + hydratedTrack, err := musicbrainz.HydrateTrack(mbService, track) 397 + if err != nil { 398 + log.Printf("apiSubmitListensHandler: Could not hydrate track with MusicBrainz for user %d: %v (continuing with original data)", userID, err) 399 + // Continue with non-hydrated track 400 + } else if hydratedTrack != nil { 401 + track = *hydratedTrack 402 + log.Printf("apiSubmitListensHandler: Successfully hydrated track '%s' with MusicBrainz data", track.Name) 403 + } 404 + } 405 + 406 + // For 'playing_now' type, publish to PDS as actor status 381 407 if submission.ListenType == "playing_now" { 382 408 log.Printf("Received playing_now listen for user %d: %s - %s", userID, track.Artist[0].Name, track.Name) 383 - // Could store in a separate playing_now table or just log 409 + 410 + if user.ATProtoDID != nil && playingNowService != nil { 411 + if err := playingNowService.PublishPlayingNow(r.Context(), userID, &track); err != nil { 412 + log.Printf("apiSubmitListensHandler: Error publishing playing_now to PDS for user %d: %v", userID, err) 413 + // Don't fail the request, just log the error 414 + } 415 + } 384 416 continue 385 417 } 386 418 ··· 389 421 log.Printf("apiSubmitListensHandler: Error saving track for user %d: %v", userID, err) 390 422 errors = append(errors, fmt.Sprintf("payload[%d]: failed to save track", i)) 391 423 continue 424 + } 425 + 426 + // Submit to PDS as feed.play record 427 + if user.ATProtoDID != nil && atprotoService != nil { 428 + if err := atprotoservice.SubmitPlayToPDS(r.Context(), *user.ATProtoDID, *user.MostRecentAtProtoSessionID, &track, atprotoService); err != nil { 429 + log.Printf("apiSubmitListensHandler: Error submitting play to PDS for user %d: %v", userID, err) 430 + // Don't fail the request, just log the error 431 + } 392 432 } 393 433 394 434 processedTracks = append(processedTracks, track)
+85 -6
cmd/listenbrainz_test.go
··· 11 11 12 12 "github.com/teal-fm/piper/db" 13 13 "github.com/teal-fm/piper/models" 14 + "github.com/teal-fm/piper/service/musicbrainz" 14 15 "github.com/teal-fm/piper/session" 15 16 ) 16 17 ··· 102 103 rr := httptest.NewRecorder() 103 104 104 105 // Call handler 105 - handler := apiSubmitListensHandler(database) 106 + handler := apiSubmitListensHandler(database, nil, nil, nil) 106 107 handler(rr, req) 107 108 108 109 // Check response ··· 186 187 req = req.WithContext(ctx) 187 188 188 189 rr := httptest.NewRecorder() 189 - handler := apiSubmitListensHandler(database) 190 + handler := apiSubmitListensHandler(database, nil, nil, nil) 190 191 handler(rr, req) 191 192 192 193 if rr.Code != http.StatusOK { ··· 263 264 req = req.WithContext(ctx) 264 265 265 266 rr := httptest.NewRecorder() 266 - handler := apiSubmitListensHandler(database) 267 + handler := apiSubmitListensHandler(database, nil, nil, nil) 267 268 handler(rr, req) 268 269 269 270 if rr.Code != http.StatusOK { ··· 324 325 req = req.WithContext(ctx) 325 326 326 327 rr := httptest.NewRecorder() 327 - handler := apiSubmitListensHandler(database) 328 + handler := apiSubmitListensHandler(database, nil, nil, nil) 328 329 handler(rr, req) 329 330 330 331 if rr.Code != http.StatusOK { ··· 419 420 req = req.WithContext(ctx) 420 421 421 422 rr := httptest.NewRecorder() 422 - handler := apiSubmitListensHandler(database) 423 + handler := apiSubmitListensHandler(database, nil, nil, nil) 423 424 handler(rr, req) 424 425 425 426 if rr.Code != tc.expectedStatus { ··· 462 463 // No Authorization header 463 464 464 465 rr := httptest.NewRecorder() 465 - handler := apiSubmitListensHandler(database) 466 + handler := apiSubmitListensHandler(database, nil, nil, nil) 466 467 handler(rr, req) 467 468 468 469 if rr.Code != http.StatusUnauthorized { ··· 537 538 t.Errorf("Second artist MBID not set correctly") 538 539 } 539 540 } 541 + 542 + func TestListenBrainzSubmission_WithMusicBrainzHydration(t *testing.T) { 543 + database := setupTestDB(t) 544 + defer database.Close() 545 + 546 + userID, apiKey := createTestUser(t, database) 547 + 548 + // Create a MusicBrainz service for hydration 549 + mbService := musicbrainz.NewMusicBrainzService(database) 550 + 551 + // Create minimal submission (artist and track name only) 552 + submission := models.ListenBrainzSubmission{ 553 + ListenType: "single", 554 + Payload: []models.ListenBrainzPayload{ 555 + { 556 + ListenedAt: func() *int64 { i := int64(1704067200); return &i }(), 557 + TrackMetadata: models.ListenBrainzTrackMetadata{ 558 + ArtistName: "Daft Punk", 559 + TrackName: "One More Time", 560 + // No MBIDs provided - should be hydrated 561 + }, 562 + }, 563 + }, 564 + } 565 + 566 + jsonData, err := json.Marshal(submission) 567 + if err != nil { 568 + t.Fatalf("Failed to marshal submission: %v", err) 569 + } 570 + 571 + req := httptest.NewRequest(http.MethodPost, "/1/submit-listens", bytes.NewReader(jsonData)) 572 + req.Header.Set("Content-Type", "application/json") 573 + req.Header.Set("Authorization", "Token "+apiKey) 574 + 575 + ctx := withUserContext(req.Context(), userID) 576 + req = req.WithContext(ctx) 577 + 578 + rr := httptest.NewRecorder() 579 + 580 + // Call handler with MusicBrainz service 581 + handler := apiSubmitListensHandler(database, nil, nil, mbService) 582 + handler(rr, req) 583 + 584 + if rr.Code != http.StatusOK { 585 + t.Errorf("Expected status %d, got %d. Body: %s", http.StatusOK, rr.Code, rr.Body.String()) 586 + } 587 + 588 + // Verify track was saved 589 + tracks, err := database.GetRecentTracks(userID, 10) 590 + if err != nil { 591 + t.Fatalf("Failed to get tracks from database: %v", err) 592 + } 593 + 594 + if len(tracks) != 1 { 595 + t.Fatalf("Expected 1 track in database, got %d", len(tracks)) 596 + } 597 + 598 + track := tracks[0] 599 + 600 + // The track should have been hydrated with MusicBrainz data 601 + // Note: This test requires network access to MusicBrainz API 602 + // In a real test environment, you might want to mock the HTTP client 603 + if track.RecordingMBID != nil { 604 + t.Logf("Track was hydrated with recording MBID: %s", *track.RecordingMBID) 605 + } 606 + 607 + if track.ReleaseMBID != nil { 608 + t.Logf("Track was hydrated with release MBID: %s", *track.ReleaseMBID) 609 + } 610 + 611 + // Even if hydration fails, the track should still be saved with original data 612 + if track.Name != "One More Time" { 613 + t.Errorf("Expected track name 'One More Time', got %s", track.Name) 614 + } 615 + if len(track.Artist) == 0 || track.Artist[0].Name != "Daft Punk" { 616 + t.Errorf("Expected artist 'Daft Punk', got %v", track.Artist) 617 + } 618 + }
+16 -11
cmd/main.go
··· 5 5 "fmt" 6 6 "log" 7 7 "net/http" 8 - "os" 9 8 "time" 10 9 11 10 "github.com/teal-fm/piper/service/lastfm" ··· 57 56 log.Fatalf("Error initializing database: %v", err) 58 57 } 59 58 59 + sessionManager := session.NewSessionManager(database) 60 + 60 61 // --- Service Initializations --- 61 - jwksBytes, err := os.ReadFile("./jwks.json") 62 - if err != nil { 63 - // run `make jwtgen` 64 - log.Fatalf("Error reading JWK file: %v", err) 62 + 63 + var newJwkPrivateKey = viper.GetString("atproto.client_secret_key") 64 + if newJwkPrivateKey == "" { 65 + fmt.Printf("You now have to set the ATPROTO_CLIENT_SECRET_KEY env var to a private key. This can be done via goat key generate -t P-256") 66 + return 65 67 } 66 - jwks, err := atproto.LoadJwks(jwksBytes) 67 - if err != nil { 68 - log.Fatalf("Error loading JWK: %v", err) 68 + var clientSecretKeyId = viper.GetString("atproto.client_secret_key_id") 69 + if clientSecretKeyId == "" { 70 + fmt.Printf("You also now have to set the ATPROTO_CLIENT_SECRET_KEY_ID env var to a key ID. This needs to be persistent and unique. Here's one for you: %d", time.Now().Unix()) 71 + return 69 72 } 73 + 70 74 atprotoService, err := atproto.NewATprotoAuthService( 71 75 database, 72 - jwks, 76 + sessionManager, 77 + newJwkPrivateKey, 73 78 viper.GetString("atproto.client_id"), 74 79 viper.GetString("atproto.callback_url"), 80 + clientSecretKeyId, 75 81 ) 76 82 if err != nil { 77 83 log.Fatalf("Error creating ATproto auth service: %v", err) ··· 82 88 spotifyService := spotify.NewSpotifyService(database, atprotoService, mbService, playingNowService) 83 89 lastfmService := lastfm.NewLastFMService(database, viper.GetString("lastfm.api_key"), mbService, atprotoService, playingNowService) 84 90 85 - sessionManager := session.NewSessionManager(database) 86 - oauthManager := oauth.NewOAuthServiceManager(sessionManager) 91 + oauthManager := oauth.NewOAuthServiceManager() 87 92 88 93 spotifyOAuth := oauth.NewOAuth2Service( 89 94 viper.GetString("spotify.client_id"),
+3 -3
cmd/routes.go
··· 28 28 mux.HandleFunc("/api-keys", session.WithAuth(app.apiKeyService.HandleAPIKeyManagement(app.database, app.pages), app.sessionManager)) 29 29 mux.HandleFunc("/link-lastfm", session.WithAuth(handleLinkLastfmForm(app.database, app.pages), app.sessionManager)) // GET form 30 30 mux.HandleFunc("/link-lastfm/submit", session.WithAuth(handleLinkLastfmSubmit(app.database), app.sessionManager)) // POST submit - Changed route slightly 31 - mux.HandleFunc("/logout", app.sessionManager.HandleLogout) 31 + mux.HandleFunc("/logout", app.oauthManager.HandleLogout("atproto")) 32 32 mux.HandleFunc("/debug/", session.WithAuth(app.sessionManager.HandleDebug, app.sessionManager)) 33 33 34 34 mux.HandleFunc("/api/v1/me", session.WithAPIAuth(apiMeHandler(app.database), app.sessionManager)) ··· 40 40 mux.HandleFunc("/api/v1/musicbrainz/search", apiMusicBrainzSearch(app.mbService)) // MusicBrainz (public?) 41 41 42 42 // ListenBrainz-compatible endpoint 43 - mux.HandleFunc("/1/submit-listens", session.WithAPIAuth(apiSubmitListensHandler(app.database), app.sessionManager)) 43 + mux.HandleFunc("/1/submit-listens", session.WithAPIAuth(apiSubmitListensHandler(app.database, app.atprotoService, app.playingNowService, app.mbService), app.sessionManager)) 44 44 45 45 serverUrlRoot := viper.GetString("server.root_url") 46 46 atpClientId := viper.GetString("atproto.client_id") 47 47 atpCallbackUrl := viper.GetString("atproto.callback_url") 48 - mux.HandleFunc("/.well-known/client-metadata.json", func(w http.ResponseWriter, r *http.Request) { 48 + mux.HandleFunc("/oauth-client-metadata.json", func(w http.ResponseWriter, r *http.Request) { 49 49 app.atprotoService.HandleClientMetadata(w, r, serverUrlRoot, atpClientId, atpCallbackUrl) 50 50 }) 51 51 mux.HandleFunc("/oauth/jwks.json", app.atprotoService.HandleJwks)
+261 -170
db/atproto.go
··· 3 3 import ( 4 4 "context" 5 5 "database/sql" 6 - "encoding/json" 7 6 "fmt" 7 + "strings" 8 8 "time" 9 9 10 - oauth "github.com/haileyok/atproto-oauth-golang" 11 - "github.com/haileyok/atproto-oauth-golang/helpers" 12 - "github.com/lestrrat-go/jwx/v2/jwk" 10 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 13 12 "github.com/teal-fm/piper/models" 14 13 ) 15 14 16 - type ATprotoAuthData struct { 17 - State string `json:"state"` 18 - DID string `json:"did"` 19 - PDSUrl string `json:"pds_url"` 20 - AuthServerIssuer string `json:"authserver_issuer"` 21 - PKCEVerifier string `json:"pkce_verifier"` 22 - DPoPAuthServerNonce string `json:"dpop_authserver_nonce"` 23 - DPoPPrivateJWK jwk.Key `json:"dpop_private_jwk"` 24 - CreatedAt time.Time `json:"created_at"` 25 - } 26 - 27 - func (db *DB) SaveATprotoAuthData(data *models.ATprotoAuthData) error { 28 - dpopPrivateJWKBytes, err := json.Marshal(data.DPoPPrivateJWK) 29 - if err != nil { 30 - return err 31 - } 32 - 33 - _, err = db.Exec(` 34 - INSERT INTO atproto_auth_data (state, did, pds_url, authserver_issuer, pkce_verifier, dpop_authserver_nonce, dpop_private_jwk) 35 - VALUES (?, ?, ?, ?, ?, ?, ?)`, 36 - data.State, data.DID, data.PDSUrl, data.AuthServerIssuer, data.PKCEVerifier, data.DPoPAuthServerNonce, string(dpopPrivateJWKBytes)) 37 - 38 - return err 39 - } 40 - 41 - func (db *DB) GetATprotoAuthData(state string) (*models.ATprotoAuthData, error) { 42 - var data models.ATprotoAuthData 43 - var dpopPrivateJWKString string 44 - 45 - err := db.QueryRow(` 46 - SELECT state, did, pds_url, authserver_issuer, pkce_verifier, dpop_authserver_nonce, dpop_private_jwk 47 - FROM atproto_auth_data 48 - WHERE state = ?`, 49 - state).Scan( 50 - &data.State, 51 - &data.DID, 52 - &data.PDSUrl, 53 - &data.AuthServerIssuer, 54 - &data.PKCEVerifier, 55 - &data.DPoPAuthServerNonce, 56 - &dpopPrivateJWKString, 57 - ) 58 - if err != nil { 59 - if err == sql.ErrNoRows { 60 - return nil, fmt.Errorf("no auth data found for state %s: %w", state, err) 61 - } 62 - return nil, fmt.Errorf("failed to scan auth data for state %s: %w", state, err) 63 - } 64 - 65 - key, err := helpers.ParseJWKFromBytes([]byte(dpopPrivateJWKString)) 66 - if err != nil { 67 - return nil, fmt.Errorf("failed to parse DPoPPrivateJWK for state %s: %w", state, err) 68 - } 69 - data.DPoPPrivateJWK = key 70 - 71 - return &data, nil 72 - } 73 - 74 15 func (db *DB) FindOrCreateUserByDID(did string) (*models.User, error) { 75 16 var user models.User 76 17 err := db.QueryRow(` ··· 108 49 return &user, err 109 50 } 110 51 111 - // create or update the current user's ATproto session data. 112 - func (db *DB) SaveATprotoSession(tokenResp *oauth.TokenResponse, authserverIss string, dpopPrivateJWK jwk.Key, pdsUrl string) error { 113 - db.logger.Printf("Saving session with PDS url %s", pdsUrl) 114 - expiryTime := time.Now().UTC().Add(time.Second * time.Duration(tokenResp.ExpiresIn)) 52 + func (db *DB) SetLatestATProtoSessionId(did string, atProtoSessionID string) error { 53 + db.logger.Printf("Setting latest atproto session id for did %s to %s", did, atProtoSessionID) 115 54 now := time.Now().UTC() 116 55 117 - dpopPrivateJWKBytes, err := json.Marshal(dpopPrivateJWK) 118 - if err != nil { 119 - return err 120 - } 121 - 122 56 result, err := db.Exec(` 123 57 UPDATE users 124 - SET atproto_access_token = ?, 125 - atproto_refresh_token = ?, 126 - atproto_token_expiry = ?, 127 - atproto_scope = ?, 128 - atproto_sub = ?, 129 - atproto_authserver_issuer = ?, 130 - atproto_token_type = ?, 131 - atproto_authserver_nonce = ?, 132 - atproto_dpop_private_jwk = ?, 133 - atproto_pds_url = ?, 134 - atproto_pds_nonce = ?, 58 + SET 59 + most_recent_at_session_id = ?, 135 60 updated_at = ? 136 61 WHERE atproto_did = ?`, 137 - tokenResp.AccessToken, 138 - tokenResp.RefreshToken, 139 - expiryTime, 140 - tokenResp.Scope, 141 - tokenResp.Sub, 142 - authserverIss, 143 - tokenResp.TokenType, 144 - tokenResp.DpopAuthserverNonce, 145 - string(dpopPrivateJWKBytes), 146 - pdsUrl, 147 - // will get set later 148 - "", 62 + atProtoSessionID, 149 63 now, 150 - tokenResp.Sub, 64 + did, 151 65 ) 152 - 153 66 if err != nil { 154 - return fmt.Errorf("failed to update atproto session for did %s: %w", tokenResp.Sub, err) 67 + db.logger.Printf("%v", err) 68 + return fmt.Errorf("failed to update atproto session for did %s: %w", did, atProtoSessionID) 155 69 } 156 70 157 71 rowsAffected, err := result.RowsAffected() 158 72 if err != nil { 159 73 // it's possible the update succeeded here? 160 - return fmt.Errorf("failed to check rows affected after updating atproto session for did %s: %w", tokenResp.Sub, err) 74 + return fmt.Errorf("failed to check rows affected after updating atproto session for did %s: %w", did, atProtoSessionID) 161 75 } 162 76 163 77 if rowsAffected == 0 { 164 - return fmt.Errorf("no user found with did %s to update session, creating new session", tokenResp.Sub) 78 + return fmt.Errorf("no user found with did %s to update session, creating new session", did) 165 79 } 166 80 167 81 return nil 168 82 } 169 83 170 - func (db *DB) GetAtprotoSession(did string, ctx context.Context, oauthClient oauth.Client) (*models.ATprotoAuthSession, error) { 171 - var oauthSession models.ATprotoAuthSession 172 - var authserverIss string 173 - var jwkBytes string 84 + type SqliteATProtoStore struct { 85 + db *sql.DB 86 + } 174 87 175 - err := db.QueryRow( 176 - ` 177 - SELECT id, 178 - atproto_did, 179 - atproto_pds_url, 180 - atproto_authserver_issuer, 181 - atproto_access_token, 182 - atproto_refresh_token, 183 - atproto_pds_nonce, 184 - atproto_authserver_nonce, 185 - atproto_dpop_private_jwk, 186 - atproto_token_expiry 187 - FROM users 188 - WHERE atproto_did = ?`, 189 - did, 190 - ).Scan( 191 - &oauthSession.ID, 192 - &oauthSession.DID, 193 - &oauthSession.PDSUrl, 194 - &authserverIss, 195 - &oauthSession.AccessToken, 196 - &oauthSession.RefreshToken, 197 - &oauthSession.DpopPdsNonce, 198 - &oauthSession.DpopAuthServerNonce, 199 - &jwkBytes, 200 - &oauthSession.TokenExpiry, 88 + var _ oauth.ClientAuthStore = (*SqliteATProtoStore)(nil) 89 + 90 + func NewSqliteATProtoStore(db *sql.DB) *SqliteATProtoStore { 91 + return &SqliteATProtoStore{ 92 + db: db, 93 + } 94 + } 95 + 96 + func sessionKey(did syntax.DID, sessionID string) string { 97 + return fmt.Sprintf("%s/%s", did, sessionID) 98 + } 99 + 100 + func splitScopes(s string) []string { 101 + if s == "" { 102 + return nil 103 + } 104 + return strings.Fields(s) 105 + } 106 + 107 + func joinScopes(scopes []string) string { 108 + if len(scopes) == 0 { 109 + return "" 110 + } 111 + return strings.Join(scopes, " ") 112 + } 113 + 114 + func (s *SqliteATProtoStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 115 + lookUpKey := sessionKey(did, sessionID) 116 + 117 + var ( 118 + accountDIDStr string 119 + lookUpKeyStr string 120 + sessionIDStr string 121 + hostURL string 122 + authServerURL string 123 + authServerTokenEndpoint string 124 + authServerRevocationEndpoint string 125 + scopesStr string 126 + accessToken string 127 + refreshToken string 128 + dpopAuthServerNonce string 129 + dpopHostNonce string 130 + dpopPrivateKeyMultibase string 131 + ) 132 + 133 + err := s.db.QueryRow(` 134 + SELECT account_did, 135 + look_up_key, 136 + session_id, 137 + host_url, 138 + authserver_url, 139 + authserver_token_endpoint, 140 + authserver_revocation_endpoint, 141 + scopes, 142 + access_token, 143 + refresh_token, 144 + dpop_authserver_nonce, 145 + dpop_host_nonce, 146 + dpop_privatekey_multibase 147 + FROM atproto_sessions 148 + WHERE look_up_key = ? 149 + `, lookUpKey).Scan( 150 + &accountDIDStr, 151 + &lookUpKeyStr, 152 + &sessionIDStr, 153 + &hostURL, 154 + &authServerURL, 155 + &authServerTokenEndpoint, 156 + &authServerRevocationEndpoint, 157 + &scopesStr, 158 + &accessToken, 159 + &refreshToken, 160 + &dpopAuthServerNonce, 161 + &dpopHostNonce, 162 + &dpopPrivateKeyMultibase, 201 163 ) 202 164 165 + if err == sql.ErrNoRows { 166 + return nil, fmt.Errorf("session not found: %s", lookUpKey) 167 + } 203 168 if err != nil { 204 - return nil, fmt.Errorf("failed to get atproto session for did %s: %w", did, err) 169 + return nil, err 205 170 } 206 171 207 - privateJwk, err := helpers.ParseJWKFromBytes([]byte(jwkBytes)) 172 + accDID, err := syntax.ParseDID(accountDIDStr) 208 173 if err != nil { 209 - return nil, fmt.Errorf("failed to parse DPoPPrivateJWK: %w", err) 210 - } else { 211 - // add jwk to the struct 212 - oauthSession.DpopPrivateJWK = privateJwk 174 + return nil, fmt.Errorf("invalid account DID in session: %w", err) 213 175 } 214 176 215 - // printout the session details 216 - db.logger.Printf("Getting session details for the did: %+v\n", oauthSession.DID) 177 + sess := oauth.ClientSessionData{ 178 + AccountDID: accDID, 179 + SessionID: sessionIDStr, 180 + HostURL: hostURL, 181 + AuthServerURL: authServerURL, 182 + AuthServerTokenEndpoint: authServerTokenEndpoint, 183 + AuthServerRevocationEndpoint: authServerRevocationEndpoint, 184 + Scopes: splitScopes(scopesStr), 185 + AccessToken: accessToken, 186 + RefreshToken: refreshToken, 187 + DPoPAuthServerNonce: dpopAuthServerNonce, 188 + DPoPHostNonce: dpopHostNonce, 189 + DPoPPrivateKeyMultibase: dpopPrivateKeyMultibase, 190 + } 217 191 218 - // if token is expired, refresh it 219 - if time.Now().UTC().After(oauthSession.TokenExpiry) { 192 + return &sess, nil 193 + } 220 194 221 - resp, err := oauthClient.RefreshTokenRequest(ctx, oauthSession.RefreshToken, authserverIss, oauthSession.DpopAuthServerNonce, privateJwk) 222 - if err != nil { 223 - return nil, err 224 - } 195 + func (s *SqliteATProtoStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { 196 + lookUpKey := sessionKey(sess.AccountDID, sess.SessionID) 197 + // simple upsert: delete then insert 198 + _, _ = s.db.Exec(`DELETE FROM atproto_sessions WHERE look_up_key = ?`, lookUpKey) 199 + _, err := s.db.Exec(` 200 + INSERT INTO atproto_sessions ( 201 + look_up_key, 202 + account_did, 203 + session_id, 204 + host_url, 205 + authserver_url, 206 + authserver_token_endpoint, 207 + authserver_revocation_endpoint, 208 + scopes, 209 + access_token, 210 + refresh_token, 211 + dpop_authserver_nonce, 212 + dpop_host_nonce, 213 + dpop_privatekey_multibase 214 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 215 + `, 216 + lookUpKey, 217 + sess.AccountDID.String(), 218 + sess.SessionID, 219 + sess.HostURL, 220 + sess.AuthServerURL, 221 + sess.AuthServerTokenEndpoint, 222 + sess.AuthServerRevocationEndpoint, 223 + joinScopes(sess.Scopes), 224 + sess.AccessToken, 225 + sess.RefreshToken, 226 + sess.DPoPAuthServerNonce, 227 + sess.DPoPHostNonce, 228 + sess.DPoPPrivateKeyMultibase, 229 + ) 230 + return err 231 + } 225 232 226 - if err := db.SaveATprotoSession(resp, authserverIss, privateJwk, oauthSession.PDSUrl); err != nil { 227 - return nil, fmt.Errorf("failed to save refreshed token: %w", err) 228 - } 233 + func (s *SqliteATProtoStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 234 + lookUpKey := sessionKey(did, sessionID) 235 + _, err := s.db.Exec(`DELETE FROM atproto_sessions WHERE look_up_key = ?`, lookUpKey) 236 + return err 237 + } 229 238 230 - oauthSession = models.ATprotoAuthSession{ 231 - ID: oauthSession.ID, 232 - DID: oauthSession.DID, 233 - PDSUrl: oauthSession.PDSUrl, 234 - AuthServerIssuer: authserverIss, 235 - AccessToken: resp.AccessToken, 236 - RefreshToken: resp.RefreshToken, 237 - DpopPdsNonce: oauthSession.DpopPdsNonce, 238 - DpopAuthServerNonce: resp.DpopAuthserverNonce, 239 - DpopPrivateJWK: privateJwk, 240 - TokenExpiry: time.Now().UTC().Add(time.Duration(resp.ExpiresIn) * time.Second), 239 + func (s *SqliteATProtoStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { 240 + var ( 241 + authServerURL string 242 + accountDIDStr sql.NullString 243 + scopesStr string 244 + requestURI string 245 + authServerTokenEndpoint string 246 + authServerRevocationEndpoint string 247 + pkceVerifier string 248 + dpopAuthServerNonce string 249 + dpopPrivateKeyMultibase string 250 + ) 251 + err := s.db.QueryRow(` 252 + SELECT authserver_url, 253 + account_did, 254 + scopes, 255 + request_uri, 256 + authserver_token_endpoint, 257 + authserver_revocation_endpoint, 258 + pkce_verifier, 259 + dpop_authserver_nonce, 260 + dpop_privatekey_multibase 261 + FROM atproto_state 262 + WHERE state = ? 263 + `, state).Scan( 264 + &authServerURL, 265 + &accountDIDStr, 266 + &scopesStr, 267 + &requestURI, 268 + &authServerTokenEndpoint, 269 + &authServerRevocationEndpoint, 270 + &pkceVerifier, 271 + &dpopAuthServerNonce, 272 + &dpopPrivateKeyMultibase, 273 + ) 274 + if err == sql.ErrNoRows { 275 + return nil, fmt.Errorf("request info not found: %s", state) 276 + } 277 + if err != nil { 278 + return nil, err 279 + } 280 + var accountDIDPtr *syntax.DID 281 + if accountDIDStr.Valid && accountDIDStr.String != "" { 282 + acc, err := syntax.ParseDID(accountDIDStr.String) 283 + if err != nil { 284 + return nil, fmt.Errorf("invalid account DID in auth request: %w", err) 241 285 } 286 + accountDIDPtr = &acc 287 + } 288 + info := oauth.AuthRequestData{ 289 + State: state, 290 + AuthServerURL: authServerURL, 291 + AccountDID: accountDIDPtr, 292 + Scopes: splitScopes(scopesStr), 293 + RequestURI: requestURI, 294 + AuthServerTokenEndpoint: authServerTokenEndpoint, 295 + AuthServerRevocationEndpoint: authServerRevocationEndpoint, 296 + PKCEVerifier: pkceVerifier, 297 + DPoPAuthServerNonce: dpopAuthServerNonce, 298 + DPoPPrivateKeyMultibase: dpopPrivateKeyMultibase, 299 + } 300 + return &info, nil 301 + } 242 302 303 + func (s *SqliteATProtoStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { 304 + // ensure not already exists 305 + var exists int 306 + err := s.db.QueryRow(`SELECT 1 FROM atproto_state WHERE state = ?`, info.State).Scan(&exists) 307 + if err == nil { 308 + return fmt.Errorf("auth request already saved for state %s", info.State) 243 309 } 244 - 245 - return &oauthSession, nil 310 + if err != nil && err != sql.ErrNoRows { 311 + return err 312 + } 313 + var accountDIDStr interface{} 314 + if info.AccountDID != nil { 315 + accountDIDStr = info.AccountDID.String() 316 + } else { 317 + accountDIDStr = nil 318 + } 319 + _, err = s.db.Exec(` 320 + INSERT INTO atproto_state ( 321 + state, 322 + authserver_url, 323 + account_did, 324 + scopes, 325 + request_uri, 326 + authserver_token_endpoint, 327 + authserver_revocation_endpoint, 328 + pkce_verifier, 329 + dpop_authserver_nonce, 330 + dpop_privatekey_multibase 331 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 332 + `, 333 + info.State, 334 + info.AuthServerURL, 335 + accountDIDStr, 336 + joinScopes(info.Scopes), 337 + info.RequestURI, 338 + info.AuthServerTokenEndpoint, 339 + info.AuthServerRevocationEndpoint, 340 + info.PKCEVerifier, 341 + info.DPoPAuthServerNonce, 342 + info.DPoPPrivateKeyMultibase, 343 + ) 344 + return err 246 345 } 247 346 248 - func AtpSessionToAuthArgs(sess *models.ATprotoAuthSession) *oauth.XrpcAuthedRequestArgs { 249 - //Commenting out so jwts and tokens are not in logs 250 - //fmt.Printf("DID: %s\nPDS URL: %s\nISS: %s\nAccess Token: %s\nNonce: %s\nPrivate JWK: %s\n", sess.DID, sess.PDSUrl, sess.AuthServerIssuer, sess.AccessToken, sess.DpopPdsNonce, sess.DpopPrivateJWK) 251 - return &oauth.XrpcAuthedRequestArgs{ 252 - Did: sess.DID, 253 - PdsUrl: sess.PDSUrl, 254 - Issuer: sess.AuthServerIssuer, 255 - AccessToken: sess.AccessToken, 256 - DpopPdsNonce: sess.DpopPdsNonce, 257 - DpopPrivateJwk: sess.DpopPrivateJWK, 258 - } 347 + func (s *SqliteATProtoStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { 348 + _, err := s.db.Exec(`DELETE FROM atproto_state WHERE state = ?`, state) 349 + return err 259 350 }
+57 -24
db/db.go
··· 45 45 username TEXT, -- Made nullable, might not have username initially 46 46 email TEXT UNIQUE, -- Made nullable 47 47 atproto_did TEXT UNIQUE, -- Atproto DID (identifier) 48 - atproto_pds_url TEXT, 49 - atproto_authserver_issuer TEXT, 50 - atproto_access_token TEXT, -- Atproto access token 51 - atproto_refresh_token TEXT, -- Atproto refresh token 52 - atproto_token_expiry TIMESTAMP, -- Atproto token expiry 53 - atproto_sub TEXT, 54 - atproto_scope TEXT, -- Atproto token scope 55 - atproto_token_type TEXT, -- Atproto token type 56 - atproto_authserver_nonce TEXT, 57 - atproto_pds_nonce TEXT, 58 - atproto_dpop_private_jwk TEXT, 48 + most_recent_at_session_id TEXT, -- Most recent oAuth session id 59 49 spotify_id TEXT UNIQUE, -- Spotify specific ID 60 50 access_token TEXT, -- Spotify access token 61 51 refresh_token TEXT, -- Spotify refresh token ··· 91 81 } 92 82 93 83 _, err = db.Exec(` 94 - CREATE TABLE IF NOT EXISTS atproto_auth_data ( 95 - id INTEGER PRIMARY KEY AUTOINCREMENT, 96 - state TEXT NOT NULL, 97 - did TEXT, 98 - pds_url TEXT NOT NULL, 99 - authserver_issuer TEXT NOT NULL, 100 - pkce_verifier TEXT NOT NULL, 101 - dpop_authserver_nonce TEXT NOT NULL, 102 - dpop_private_jwk TEXT NOT NULL, 103 - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 104 - )`) 84 + CREATE TABLE IF NOT EXISTS atproto_state ( 85 + id INTEGER PRIMARY KEY AUTOINCREMENT, 86 + state TEXT NOT NULL, 87 + authserver_url TEXT, 88 + account_did TEXT, 89 + scopes TEXT, 90 + request_uri TEXT, 91 + authserver_token_endpoint TEXT, 92 + authserver_revocation_endpoint TEXT, 93 + pkce_verifier TEXT, 94 + dpop_authserver_nonce TEXT, 95 + dpop_privatekey_multibase TEXT, 96 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 97 + ); 98 + CREATE INDEX IF NOT EXISTS atproto_state_state ON atproto_state(state); 99 + 100 + `) 101 + if err != nil { 102 + return err 103 + } 104 + 105 + _, err = db.Exec(` 106 + CREATE TABLE IF NOT EXISTS atproto_sessions ( 107 + id INTEGER PRIMARY KEY AUTOINCREMENT, 108 + look_up_key TEXT NOT NULL, 109 + account_did TEXT, 110 + session_id TEXT, 111 + host_url TEXT, 112 + authserver_url TEXT, 113 + authserver_token_endpoint TEXT, 114 + authserver_revocation_endpoint TEXT, 115 + scopes TEXT, 116 + access_token TEXT, 117 + refresh_token TEXT, 118 + dpop_authserver_nonce TEXT, 119 + dpop_host_nonce TEXT, 120 + dpop_privatekey_multibase TEXT, 121 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 122 + ); 123 + CREATE INDEX IF NOT EXISTS idx_atproto_sessions_look_up_key ON atproto_sessions(look_up_key); 124 + `) 105 125 if err != nil { 106 126 return err 107 127 } ··· 162 182 user := &models.User{} 163 183 164 184 err := db.QueryRow(` 165 - SELECT id, username, email, atproto_did, spotify_id, access_token, refresh_token, token_expiry, lastfm_username, created_at, updated_at 185 + SELECT id, 186 + username, 187 + email, 188 + atproto_did, 189 + most_recent_at_session_id, 190 + spotify_id, 191 + access_token, 192 + refresh_token, 193 + token_expiry, 194 + lastfm_username, 195 + created_at, 196 + updated_at 166 197 FROM users WHERE id = ?`, ID).Scan( 167 - &user.ID, &user.Username, &user.Email, &user.ATProtoDID, &user.SpotifyID, 198 + &user.ID, &user.Username, &user.Email, &user.ATProtoDID, &user.MostRecentAtProtoSessionID, &user.SpotifyID, 168 199 &user.AccessToken, &user.RefreshToken, &user.TokenExpiry, 169 200 &user.LastFMUsername, 170 201 &user.CreatedAt, &user.UpdatedAt) ··· 472 503 473 504 return &lastTimestamp, nil 474 505 } 506 + 507 + //
+2 -2
db/lfm.go
··· 42 42 43 43 func (db *DB) GetUserByLastFM(lastfmUsername string) (*models.User, error) { 44 44 row := db.QueryRow(` 45 - SELECT id, username, email, atproto_did, created_at, updated_at, lastfm_username 45 + SELECT id, username, email, atproto_did, most_recent_at_session_id, created_at, updated_at, lastfm_username 46 46 FROM users 47 47 WHERE lastfm_username = ?`, lastfmUsername) 48 48 49 49 user := &models.User{} 50 50 err := row.Scan( 51 - &user.ID, &user.Username, &user.Email, &user.ATProtoDID, 51 + &user.ID, &user.Username, &user.Email, &user.ATProtoDID, &user.MostRecentAtProtoSessionID, 52 52 &user.CreatedAt, &user.UpdatedAt, &user.LastFMUsername) 53 53 if err != nil { 54 54 return nil, err
+29 -3
go.mod
··· 3 3 go 1.24.0 4 4 5 5 require ( 6 - github.com/bluesky-social/indigo v0.0.0-20250506174012-7075cf22f63e 6 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e 7 7 github.com/dlclark/regexp2 v1.11.5 8 - github.com/haileyok/atproto-oauth-golang v0.0.2 9 8 github.com/ipfs/go-cid v0.4.1 10 9 github.com/joho/godotenv v1.5.1 11 10 github.com/justinas/alice v1.2.0 ··· 21 20 require ( 22 21 dario.cat/mergo v1.0.1 // indirect 23 22 github.com/air-verse/air v1.61.7 // indirect 23 + github.com/beorn7/perks v1.0.1 // indirect 24 24 github.com/bep/godartsass v1.2.0 // indirect 25 25 github.com/bep/godartsass/v2 v2.1.0 // indirect 26 26 github.com/bep/golibsass v1.2.0 // indirect 27 27 github.com/carlmjohnson/versioninfo v0.22.5 // indirect 28 + github.com/cespare/xxhash/v2 v2.3.0 // indirect 28 29 github.com/cli/safeexec v1.0.1 // indirect 29 30 github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect 30 31 github.com/creack/pty v1.1.23 // indirect ··· 39 40 github.com/goccy/go-json v0.10.2 // indirect 40 41 github.com/gogo/protobuf v1.3.2 // indirect 41 42 github.com/gohugoio/hugo v0.134.3 // indirect 42 - github.com/golang-jwt/jwt/v5 v5.2.1 // indirect 43 + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 44 + github.com/google/go-querystring v1.1.0 // indirect 43 45 github.com/google/uuid v1.6.0 // indirect 46 + github.com/gorilla/context v1.1.2 // indirect 47 + github.com/gorilla/securecookie v1.1.2 // indirect 48 + github.com/gorilla/sessions v1.4.0 // indirect 44 49 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 45 50 github.com/hashicorp/go-retryablehttp v0.7.5 // indirect 46 51 github.com/hashicorp/golang-lru v1.0.2 // indirect 52 + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 47 53 github.com/ipfs/bbloom v0.0.4 // indirect 48 54 github.com/ipfs/go-block-format v0.2.0 // indirect 49 55 github.com/ipfs/go-datastore v0.6.0 // indirect ··· 56 62 github.com/ipfs/go-log/v2 v2.5.1 // indirect 57 63 github.com/ipfs/go-metrics-interface v0.0.1 // indirect 58 64 github.com/jbenet/goprocess v0.1.4 // indirect 65 + github.com/jinzhu/inflection v1.0.0 // indirect 66 + github.com/jinzhu/now v1.1.5 // indirect 59 67 github.com/klauspost/cpuid/v2 v2.2.7 // indirect 68 + github.com/labstack/echo-contrib v0.17.2 // indirect 69 + github.com/labstack/echo/v4 v4.13.3 // indirect 70 + github.com/labstack/gommon v0.4.2 // indirect 60 71 github.com/lestrrat-go/blackmagic v1.0.2 // indirect 61 72 github.com/lestrrat-go/httpcc v1.0.1 // indirect 62 73 github.com/lestrrat-go/httprc v1.0.4 // indirect ··· 64 75 github.com/lestrrat-go/option v1.0.1 // indirect 65 76 github.com/mattn/go-colorable v0.1.13 // indirect 66 77 github.com/mattn/go-isatty v0.0.20 // indirect 78 + github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect 67 79 github.com/minio/sha256-simd v1.0.1 // indirect 68 80 github.com/mr-tron/base58 v1.2.0 // indirect 69 81 github.com/multiformats/go-base32 v0.1.0 // indirect ··· 71 83 github.com/multiformats/go-multibase v0.2.0 // indirect 72 84 github.com/multiformats/go-multihash v0.2.3 // indirect 73 85 github.com/multiformats/go-varint v0.0.7 // indirect 86 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 74 87 github.com/opentracing/opentracing-go v1.2.0 // indirect 75 88 github.com/pelletier/go-toml v1.9.5 // indirect 76 89 github.com/pelletier/go-toml/v2 v2.2.3 // indirect 77 90 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 91 + github.com/prometheus/client_golang v1.20.5 // indirect 92 + github.com/prometheus/client_model v0.6.1 // indirect 93 + github.com/prometheus/common v0.61.0 // indirect 94 + github.com/prometheus/procfs v0.15.1 // indirect 78 95 github.com/russross/blackfriday/v2 v2.1.0 // indirect 79 96 github.com/sagikazarmark/locafero v0.7.0 // indirect 97 + github.com/samber/lo v1.47.0 // indirect 98 + github.com/samber/slog-echo v1.15.1 // indirect 80 99 github.com/segmentio/asm v1.2.0 // indirect 81 100 github.com/sourcegraph/conc v0.3.0 // indirect 82 101 github.com/spaolacci/murmur3 v1.1.0 // indirect ··· 86 105 github.com/subosito/gotenv v1.6.0 // indirect 87 106 github.com/tdewolff/parse/v2 v2.7.15 // indirect 88 107 github.com/urfave/cli/v2 v2.27.5 // indirect 108 + github.com/valyala/bytebufferpool v1.0.0 // indirect 109 + github.com/valyala/fasttemplate v1.2.2 // indirect 89 110 github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 111 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 112 + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 90 113 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect 91 114 go.opentelemetry.io/otel v1.29.0 // indirect 92 115 go.opentelemetry.io/otel/metric v1.29.0 // indirect ··· 96 119 go.uber.org/zap v1.26.0 // indirect 97 120 golang.org/x/crypto v0.32.0 // indirect 98 121 golang.org/x/mod v0.21.0 // indirect 122 + golang.org/x/net v0.33.0 // indirect 99 123 golang.org/x/sys v0.29.0 // indirect 100 124 golang.org/x/text v0.21.0 // indirect 101 125 google.golang.org/protobuf v1.36.1 // indirect 102 126 gopkg.in/yaml.v3 v3.0.1 // indirect 127 + gorm.io/driver/sqlite v1.5.7 // indirect 128 + gorm.io/gorm v1.25.9 // indirect 103 129 lukechampine.com/blake3 v1.2.1 // indirect 104 130 ) 105 131
+61 -4
go.sum
··· 11 11 github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M= 12 12 github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= 13 13 github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 14 + github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 15 + github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 14 16 github.com/bep/clocks v0.5.0 h1:hhvKVGLPQWRVsBP/UB7ErrHYIO42gINVbvqxvYTPVps= 15 17 github.com/bep/clocks v0.5.0/go.mod h1:SUq3q+OOq41y2lRQqH5fsOoxN8GbxSiT6jvoVVLCVhU= 16 18 github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= ··· 37 39 github.com/bep/overlayfs v0.9.2/go.mod h1:aYY9W7aXQsGcA7V9x/pzeR8LjEgIxbtisZm8Q7zPz40= 38 40 github.com/bep/tmc v0.5.1 h1:CsQnSC6MsomH64gw0cT5f+EwQDcvZz4AazKunFwTpuI= 39 41 github.com/bep/tmc v0.5.1/go.mod h1:tGYHN8fS85aJPhDLgXETVKp+PR382OvFi2+q2GkGsq0= 40 - github.com/bluesky-social/indigo v0.0.0-20250506174012-7075cf22f63e h1:yEW1njmALj7i1AjLhq6Lsxts48JUCTT+wpM9m7GNLVY= 41 - github.com/bluesky-social/indigo v0.0.0-20250506174012-7075cf22f63e/go.mod h1:ovyxp8AMO1Hoe838vMJUbqHTZaAR8ABM3g3TXu+A5Ng= 42 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU= 43 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8= 42 44 github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= 43 45 github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= 44 46 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= ··· 121 123 github.com/gohugoio/locales v0.14.0/go.mod h1:ip8cCAv/cnmVLzzXtiTpPwgJ4xhKZranqNqtoIu0b/4= 122 124 github.com/gohugoio/localescompressed v1.0.1 h1:KTYMi8fCWYLswFyJAeOtuk/EkXR/KPTHHNN9OS+RTxo= 123 125 github.com/gohugoio/localescompressed v1.0.1/go.mod h1:jBF6q8D7a0vaEmcWPNcAjUZLJaIVNiwvM3WlmTvooB0= 124 - github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= 125 - github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 126 + github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 127 + github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 126 128 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 127 129 github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 128 130 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= ··· 138 140 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 139 141 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 140 142 github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 143 + github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 141 144 github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= 142 145 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 143 146 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 147 + github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 148 + github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 144 149 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 145 150 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 146 151 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 147 152 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 148 153 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 154 + github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= 155 + github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= 156 + github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 157 + github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 158 + github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= 159 + github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 149 160 github.com/haileyok/atproto-oauth-golang v0.0.2 h1:61KPkLB615LQXR2f5x1v3sf6vPe6dOXqNpTYCgZ0Fz8= 150 161 github.com/haileyok/atproto-oauth-golang v0.0.2/go.mod h1:jcZ4GCjo5I5RuE/RsAXg1/b6udw7R4W+2rb/cGyTDK8= 151 162 github.com/hairyhenderson/go-codeowners v0.5.0 h1:dpQB+hVHiRc2VVvc2BHxkuM+tmu9Qej/as3apqUbsWc= ··· 199 210 github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= 200 211 github.com/jdkato/prose v1.2.1 h1:Fp3UnJmLVISmlc57BgKUzdjr0lOtjqTZicL3PaYy6cU= 201 212 github.com/jdkato/prose v1.2.1/go.mod h1:AiRHgVagnEx2JbQRQowVBKjG0bcs/vtkGCH1dYAL1rA= 213 + github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 214 + github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 215 + github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 216 + github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 202 217 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 203 218 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 204 219 github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= ··· 221 236 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 222 237 github.com/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U= 223 238 github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE= 239 + github.com/labstack/echo-contrib v0.17.2 h1:K1zivqmtcC70X9VdBFdLomjPDEVHlrcAObqmuFj1c6w= 240 + github.com/labstack/echo-contrib v0.17.2/go.mod h1:NeDh3PX7j/u+jR4iuDt1zHmWZSCz9c/p9mxXcDpyS8E= 241 + github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= 242 + github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= 243 + github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= 244 + github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= 224 245 github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= 225 246 github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= 226 247 github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= ··· 251 272 github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 252 273 github.com/mattn/go-sqlite3 v1.14.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU= 253 274 github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 275 + github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= 276 + github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= 254 277 github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 255 278 github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 256 279 github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE= ··· 273 296 github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 274 297 github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= 275 298 github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= 299 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 300 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 276 301 github.com/niklasfasching/go-org v1.7.0 h1:vyMdcMWWTe/XmANk19F4k8XGBYg0GQ/gJGMimOjGMek= 277 302 github.com/niklasfasching/go-org v1.7.0/go.mod h1:WuVm4d45oePiE0eX25GqTDQIt/qPW1T9DGkRscqLW5o= 278 303 github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= ··· 297 322 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 298 323 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= 299 324 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= 325 + github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= 326 + github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= 327 + github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= 328 + github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= 300 329 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 330 + github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= 331 + github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= 332 + github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 333 + github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 334 + github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= 335 + github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= 336 + github.com/prometheus/common v0.61.0 h1:3gv/GThfX0cV2lpO7gkTUwZru38mxevy90Bj8YFSRQQ= 337 + github.com/prometheus/common v0.61.0/go.mod h1:zr29OCN/2BsJRaFwG8QOBr41D6kkchKbpeNH7pAjb/s= 338 + github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= 339 + github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= 340 + github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 341 + github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 301 342 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 302 343 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 303 344 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= ··· 309 350 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 310 351 github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= 311 352 github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= 353 + github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc= 354 + github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU= 355 + github.com/samber/slog-echo v1.15.1 h1:mzeQNPYPxmpehIRtgQJRgJMVvrRbZHp5D2maxSljTBw= 356 + github.com/samber/slog-echo v1.15.1/go.mod h1:K21nbusPmai/MYm8PFactmZoFctkMmkeaTdXXyvhY1c= 312 357 github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 313 358 github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 314 359 github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= ··· 356 401 github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 357 402 github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= 358 403 github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= 404 + github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 405 + github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 406 + github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= 407 + github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 359 408 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= 360 409 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 361 410 github.com/whyrusleeping/cbor v0.0.0-20171005072247-63513f603b11 h1:5HZfQkwe0mIfyDmc1Em5GqlNRzcdtlv4HTNmdpt7XH0= ··· 372 421 github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 373 422 github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4= 374 423 github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= 424 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 425 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 426 + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= 427 + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 375 428 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= 376 429 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= 377 430 go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= ··· 537 590 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 538 591 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 539 592 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 593 + gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I= 594 + gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= 595 + gorm.io/gorm v1.25.9 h1:wct0gxZIELDk8+ZqF/MVnHLkA1rvYlBWUMv2EdsK1g8= 596 + gorm.io/gorm v1.25.9/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= 540 597 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 541 598 honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 542 599 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
+7 -4
models/user.go
··· 18 18 LastFMUsername *string 19 19 20 20 // atp info 21 - ATProtoDID *string 22 - ATProtoAccessToken *string 23 - ATProtoRefreshToken *string 24 - ATProtoTokenExpiry *time.Time 21 + ATProtoDID *string 22 + //This is meant to only be used by the automated music stamping service. If the user ever does an 23 + //atproto action from the web ui use the atproto session id for the logged-in session 24 + MostRecentAtProtoSessionID *string 25 + //ATProtoAccessToken *string 26 + //ATProtoRefreshToken *string 27 + //ATProtoTokenExpiry *time.Time 25 28 26 29 CreatedAt time.Time 27 30 UpdatedAt time.Time
+104 -134
oauth/atproto/atproto.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 + 7 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 8 + _ "github.com/bluesky-social/indigo/atproto/auth/oauth" 9 + "github.com/bluesky-social/indigo/atproto/client" 10 + "github.com/bluesky-social/indigo/atproto/crypto" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + "github.com/teal-fm/piper/db" 13 + 14 + "github.com/teal-fm/piper/session" 15 + 6 16 "log" 7 17 "net/http" 8 18 "net/url" 9 - 10 - oauth "github.com/haileyok/atproto-oauth-golang" 11 - "github.com/haileyok/atproto-oauth-golang/helpers" 12 - "github.com/lestrrat-go/jwx/v2/jwk" 13 - "github.com/teal-fm/piper/db" 14 - "github.com/teal-fm/piper/models" 19 + "os" 20 + "slices" 15 21 ) 16 22 17 23 type ATprotoAuthService struct { 18 - client *oauth.Client 19 - jwks jwk.Key 20 - DB *db.DB 21 - clientId string 22 - callbackUrl string 23 - xrpc *oauth.XrpcClient 24 + clientApp *oauth.ClientApp 25 + DB *db.DB 26 + sessionManager *session.SessionManager 27 + clientId string 28 + callbackUrl string 29 + logger *log.Logger 24 30 } 25 31 26 - func NewATprotoAuthService(db *db.DB, jwks jwk.Key, clientId string, callbackUrl string) (*ATprotoAuthService, error) { 32 + func NewATprotoAuthService(database *db.DB, sessionManager *session.SessionManager, clientSecretKey string, clientId string, callbackUrl string, clientSecretId string) (*ATprotoAuthService, error) { 27 33 fmt.Println(clientId, callbackUrl) 28 - cli, err := oauth.NewClient(oauth.ClientArgs{ 29 - ClientJwk: jwks, 30 - ClientId: clientId, 31 - RedirectUri: callbackUrl, 32 - }) 34 + 35 + scopes := []string{"atproto", "repo:fm.teal.alpha.feed.play", "repo:fm.teal.alpha.actor.status"} 36 + 37 + var config oauth.ClientConfig 38 + config = oauth.NewPublicConfig(clientId, callbackUrl, scopes) 39 + 40 + priv, err := crypto.ParsePrivateMultibase(clientSecretKey) 33 41 if err != nil { 34 - return nil, fmt.Errorf("failed to create atproto oauth client: %w", err) 42 + return nil, err 35 43 } 44 + if err := config.SetClientSecret(priv, clientSecretId); err != nil { 45 + return nil, err 46 + } 47 + 48 + oauthClient := oauth.NewClientApp(&config, db.NewSqliteATProtoStore(database.DB)) 49 + 50 + logger := log.New(os.Stdout, "ATProto oauth: ", log.LstdFlags|log.Lmsgprefix) 51 + 36 52 svc := &ATprotoAuthService{ 37 - client: cli, 38 - jwks: jwks, 39 - callbackUrl: callbackUrl, 40 - DB: db, 41 - clientId: clientId, 53 + clientApp: oauthClient, 54 + callbackUrl: callbackUrl, 55 + DB: database, 56 + sessionManager: sessionManager, 57 + clientId: clientId, 58 + logger: logger, 42 59 } 43 - svc.NewXrpcClient() 44 60 return svc, nil 45 61 } 46 62 47 - func (a *ATprotoAuthService) GetATProtoClient() (*oauth.Client, error) { 48 - if a.client != nil { 49 - return a.client, nil 63 + func (a *ATprotoAuthService) GetATProtoClient(accountDID string, sessionID string, ctx context.Context) (*client.APIClient, error) { 64 + did, err := syntax.ParseDID(accountDID) 65 + if err != nil { 66 + return nil, err 50 67 } 51 68 52 - if a.client == nil { 53 - cli, err := oauth.NewClient(oauth.ClientArgs{ 54 - ClientJwk: a.jwks, 55 - ClientId: a.clientId, 56 - RedirectUri: a.callbackUrl, 57 - }) 58 - if err != nil { 59 - return nil, fmt.Errorf("failed to create atproto oauth client: %w", err) 60 - } 61 - a.client = cli 69 + oauthSess, err := a.clientApp.ResumeSession(ctx, did, sessionID) 70 + if err != nil { 71 + return nil, err 62 72 } 63 73 64 - return a.client, nil 65 - } 74 + return oauthSess.APIClient(), nil 66 75 67 - func LoadJwks(jwksBytes []byte) (jwk.Key, error) { 68 - key, err := helpers.ParseJWKFromBytes(jwksBytes) 69 - if err != nil { 70 - return nil, fmt.Errorf("failed to parse JWK from bytes: %w", err) 71 - } 72 - return key, nil 73 76 } 74 77 75 78 func (a *ATprotoAuthService) HandleLogin(w http.ResponseWriter, r *http.Request) { 76 79 handle := r.URL.Query().Get("handle") 77 80 if handle == "" { 78 - log.Printf("ATProto Login Error: handle is required") 81 + a.logger.Printf("ATProto Login Error: handle is required") 79 82 http.Error(w, "handle query parameter is required", http.StatusBadRequest) 80 83 return 81 84 } 82 - 83 - authUrl, err := a.getLoginUrlAndSaveState(r.Context(), handle) 85 + ctx := r.Context() 86 + redirectURL, err := a.clientApp.StartAuthFlow(ctx, handle) 87 + if err != nil { 88 + http.Error(w, fmt.Sprintf("Error initiating login: %v", err), http.StatusInternalServerError) 89 + } 90 + authUrl, err := url.Parse(redirectURL) 84 91 if err != nil { 85 - log.Printf("ATProto Login Error: Failed to get login URL for handle %s: %v", handle, err) 86 92 http.Error(w, fmt.Sprintf("Error initiating login: %v", err), http.StatusInternalServerError) 87 - return 88 93 } 89 94 90 - log.Printf("ATProto Login: Redirecting user %s to %s", handle, authUrl.String()) 95 + a.logger.Printf("ATProto Login: Redirecting user %s to %s", handle, authUrl.String()) 91 96 http.Redirect(w, r, authUrl.String(), http.StatusFound) 92 97 } 93 98 94 - func (a *ATprotoAuthService) getLoginUrlAndSaveState(ctx context.Context, handle string) (*url.URL, error) { 95 - scope := "atproto transition:generic" 96 - // resolve 97 - ui, err := a.getUserInformation(ctx, handle) 98 - if err != nil { 99 - return nil, fmt.Errorf("failed to get user information for %s: %w", handle, err) 100 - } 101 - 102 - fmt.Println("user info: ", ui.AuthServer, ui.AuthService) 103 - 104 - // create a dpop jwk for this session 105 - k, err := helpers.GenerateKey(nil) // Generate ephemeral DPoP key for this flow 106 - if err != nil { 107 - return nil, fmt.Errorf("failed to generate DPoP key: %w", err) 108 - } 99 + func (a *ATprotoAuthService) HandleLogout(w http.ResponseWriter, r *http.Request) { 100 + cookie, err := r.Cookie("session") 109 101 110 - // Send PAR auth req 111 - parResp, err := a.client.SendParAuthRequest(ctx, ui.AuthServer, ui.AuthMeta, ui.Handle, scope, k) 112 - if err != nil { 113 - return nil, fmt.Errorf("failed PAR request to %s: %w", ui.AuthServer, err) 114 - } 102 + if err == nil { 103 + session, exists := a.sessionManager.GetSession(cookie.Value) 104 + if !exists { 105 + http.Redirect(w, r, "/", http.StatusSeeOther) 106 + return 107 + } 115 108 116 - // Save state 117 - data := &models.ATprotoAuthData{ 118 - State: parResp.State, 119 - DID: ui.DID, 120 - PDSUrl: ui.AuthService, 121 - AuthServerIssuer: ui.AuthMeta.Issuer, 122 - PKCEVerifier: parResp.PkceVerifier, 123 - DPoPAuthServerNonce: parResp.DpopAuthserverNonce, 124 - DPoPPrivateJWK: k, 125 - } 109 + dbUser, err := a.DB.GetUserByID(session.UserID) 110 + if err != nil { 111 + http.Redirect(w, r, "/", http.StatusSeeOther) 112 + return 113 + } 114 + did, err := syntax.ParseDID(*dbUser.ATProtoDID) 126 115 127 - // print data 128 - fmt.Println(data) 116 + if err != nil { 117 + a.logger.Printf("Should not happen: %s", err) 118 + a.sessionManager.ClearSessionCookie(w) 119 + http.Redirect(w, r, "/", http.StatusSeeOther) 120 + } 129 121 130 - err = a.DB.SaveATprotoAuthData(data) 131 - if err != nil { 132 - return nil, fmt.Errorf("failed to save ATProto auth data for state %s: %w", parResp.State, err) 122 + ctx := r.Context() 123 + err = a.clientApp.Logout(ctx, did, session.ATProtoSessionID) 124 + if err != nil { 125 + a.logger.Printf("Error logging the user: %s out: %s", did, err) 126 + } 127 + a.sessionManager.DeleteSession(cookie.Value) 133 128 } 134 129 135 - // Construct authorization URL using the request_uri from PAR response 136 - authEndpointURL, err := url.Parse(ui.AuthMeta.AuthorizationEndpoint) 137 - if err != nil { 138 - return nil, fmt.Errorf("invalid authorization endpoint URL %s: %w", ui.AuthMeta.AuthorizationEndpoint, err) 139 - } 140 - q := authEndpointURL.Query() 141 - q.Set("client_id", a.clientId) 142 - q.Set("request_uri", parResp.RequestUri) 143 - q.Set("state", parResp.State) 144 - authEndpointURL.RawQuery = q.Encode() 130 + a.sessionManager.ClearSessionCookie(w) 145 131 146 - return authEndpointURL, nil 132 + http.Redirect(w, r, "/", http.StatusSeeOther) 147 133 } 148 134 149 135 func (a *ATprotoAuthService) HandleCallback(w http.ResponseWriter, r *http.Request) (int64, error) { 150 - state := r.URL.Query().Get("state") 151 - code := r.URL.Query().Get("code") 152 - issuer := r.URL.Query().Get("iss") // Issuer (auth base URL) is needed for token request 153 - 154 - if state == "" || code == "" || issuer == "" { 155 - errMsg := r.URL.Query().Get("error") 156 - errDesc := r.URL.Query().Get("error_description") 157 - log.Printf("ATProto Callback Error: Missing parameters. State: '%s', Code: '%s', Issuer: '%s'. Error: '%s', Description: '%s'", state, code, issuer, errMsg, errDesc) 158 - http.Error(w, fmt.Sprintf("Authorization callback failed: %s (%s). Missing state, code, or issuer.", errMsg, errDesc), http.StatusBadRequest) 159 - return 0, fmt.Errorf("missing state, code, or issuer") 160 - } 136 + ctx := r.Context() 161 137 162 - // Retrieve saved data using state 163 - data, err := a.DB.GetATprotoAuthData(state) 138 + sessData, err := a.clientApp.ProcessCallback(ctx, r.URL.Query()) 164 139 if err != nil { 165 - log.Printf("ATProto Callback Error: Failed to retrieve auth data for state '%s': %v", state, err) 166 - http.Error(w, "Invalid or expired state.", http.StatusBadRequest) 167 - return 0, fmt.Errorf("invalid or expired state") 140 + errMsg := fmt.Errorf("processing OAuth callback: %w", err) 141 + http.Error(w, errMsg.Error(), http.StatusBadRequest) 142 + return 0, errMsg 168 143 } 169 144 170 - // Clean up the temporary auth data now that we've retrieved it 171 - // defer a.DB.DeleteATprotoAuthData(state) // Consider adding deletion logic 172 - // if issuers don't match, return an error 173 - if data.AuthServerIssuer != issuer { 174 - log.Printf("ATProto Callback Error: Issuer mismatch for state '%s', expected '%s', got '%s'", state, data.AuthServerIssuer, issuer) 175 - http.Error(w, "Invalid or expired state.", http.StatusBadRequest) 176 - return 0, fmt.Errorf("issuer mismatch") 145 + // It's in the example repo and leaving for some debugging cause i've seen different scopes cause issues before 146 + // so may be some nice debugging info to have 147 + if !slices.Equal(sessData.Scopes, a.clientApp.Config.Scopes) { 148 + a.logger.Printf("session auth scopes did not match those requested") 177 149 } 178 150 179 - resp, err := a.client.InitialTokenRequest(r.Context(), code, issuer, data.PKCEVerifier, data.DPoPAuthServerNonce, data.DPoPPrivateJWK) 151 + user, err := a.DB.FindOrCreateUserByDID(sessData.AccountDID.String()) 180 152 if err != nil { 181 - log.Printf("ATProto Callback Error: Failed initial token request for state '%s', issuer '%s': %v", state, issuer, err) 182 - http.Error(w, fmt.Sprintf("Error exchanging code for token: %v", err), http.StatusInternalServerError) 183 - return 0, fmt.Errorf("failed initial token request") 184 - } 185 - 186 - userID, err := a.DB.FindOrCreateUserByDID(data.DID) 187 - if err != nil { 188 - log.Printf("ATProto Callback Error: Failed to find or create user for DID %s: %v", data.DID, err) 153 + a.logger.Printf("ATProto Callback Error: Failed to find or create user for DID %s: %v", sessData.AccountDID.String(), err) 189 154 http.Error(w, "Failed to process user information.", http.StatusInternalServerError) 190 155 return 0, fmt.Errorf("failed to find or create user") 191 156 } 192 157 193 - err = a.DB.SaveATprotoSession(resp, data.AuthServerIssuer, data.DPoPPrivateJWK, data.PDSUrl) 158 + //This is piper's session for manging piper, not atproto sessions 159 + createdSession := a.sessionManager.CreateSession(user.ID, sessData.SessionID) 160 + a.sessionManager.SetSessionCookie(w, createdSession) 161 + a.logger.Printf("Created session for user %d via service atproto", user.ATProtoDID) 162 + 163 + err = a.DB.SetLatestATProtoSessionId(sessData.AccountDID.String(), sessData.SessionID) 194 164 if err != nil { 195 - log.Printf("ATProto Callback Error: Failed to save ATProto tokens for user %d (DID %s): %v", userID.ID, data.DID, err) 165 + a.logger.Printf("Failed to set latest atproto session id for user %d: %v", user.ID, err) 196 166 } 197 167 198 - log.Printf("ATProto Callback Success: User %d (DID: %s) authenticated.", userID.ID, data.DID) 199 - return userID.ID, nil 168 + a.logger.Printf("ATProto Callback Success: User %d (DID: %s) authenticated.", user.ID, user.ATProtoDID) 169 + return user.ID, nil 200 170 }
+26 -30
oauth/atproto/http.go
··· 4 4 import ( 5 5 "encoding/json" 6 6 "fmt" 7 - "log" 8 7 "net/http" 9 - 10 - "github.com/haileyok/atproto-oauth-golang/helpers" 11 8 ) 12 9 13 - func (a *ATprotoAuthService) HandleJwks(w http.ResponseWriter, r *http.Request) { 14 - pubKey, err := a.jwks.PublicKey() 15 - if err != nil { 16 - http.Error(w, fmt.Sprintf("Error getting public key from JWK: %v", err), http.StatusInternalServerError) 17 - log.Printf("Error getting public key from JWK: %v", err) 18 - return 19 - } 10 + func strPtr(raw string) *string { 11 + return &raw 12 + } 20 13 14 + func (a *ATprotoAuthService) HandleJwks(w http.ResponseWriter, r *http.Request) { 21 15 w.Header().Set("Content-Type", "application/json") 22 - if err := json.NewEncoder(w).Encode(helpers.CreateJwksResponseObject(pubKey)); err != nil { 23 - log.Printf("Error encoding JWKS response: %v", err) 16 + body := a.clientApp.Config.PublicJWKS() 17 + if err := json.NewEncoder(w).Encode(body); err != nil { 18 + http.Error(w, err.Error(), http.StatusInternalServerError) 19 + return 24 20 } 25 21 } 26 22 27 23 func (a *ATprotoAuthService) HandleClientMetadata(w http.ResponseWriter, r *http.Request, serverUrlRoot, serverMetadataUrl, serverCallbackUrl string) { 28 - metadata := map[string]any{ 29 - "client_id": serverMetadataUrl, 30 - "client_name": "Piper Telekinesis", 31 - "client_uri": serverUrlRoot, 32 - "logo_uri": fmt.Sprintf("%s/logo.png", serverUrlRoot), 33 - "tos_uri": fmt.Sprintf("%s/tos", serverUrlRoot), 34 - "policy_url": fmt.Sprintf("%s/policy", serverUrlRoot), 35 - "redirect_uris": []string{serverCallbackUrl}, 36 - "grant_types": []string{"authorization_code", "refresh_token"}, 37 - "response_types": []string{"code"}, 38 - "application_type": "web", 39 - "dpop_bound_access_tokens": true, 40 - "jwks_uri": fmt.Sprintf("%s/oauth/jwks.json", serverUrlRoot), 41 - "scope": "atproto transition:generic", 42 - "token_endpoint_auth_method": "private_key_jwt", 43 - "token_endpoint_auth_signing_alg": "ES256", 24 + 25 + meta := a.clientApp.Config.ClientMetadata() 26 + if a.clientApp.Config.IsConfidential() { 27 + meta.JWKSURI = strPtr(fmt.Sprintf("%s/oauth/jwks.json", serverUrlRoot)) 44 28 } 29 + meta.ClientName = strPtr("Piper Telekinesis") 30 + meta.ClientURI = strPtr(serverUrlRoot) 31 + 32 + // internal consistency check 33 + if err := meta.Validate(a.clientApp.Config.ClientID); err != nil { 34 + a.logger.Printf("validating client metadata", "err", err) 35 + http.Error(w, err.Error(), http.StatusInternalServerError) 36 + return 37 + } 38 + 45 39 w.Header().Set("Content-Type", "application/json") 46 - if err := json.NewEncoder(w).Encode(metadata); err != nil { 47 - log.Printf("Error encoding client metadata: %v", err) 40 + if err := json.NewEncoder(w).Encode(meta); err != nil { 41 + http.Error(w, err.Error(), http.StatusInternalServerError) 42 + return 48 43 } 44 + 49 45 }
+2 -55
oauth/atproto/resolve.go
··· 12 12 "strings" 13 13 14 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 - oauth "github.com/haileyok/atproto-oauth-golang" 16 15 ) 17 16 18 17 // user information struct 19 18 type UserInformation struct { 20 - AuthService string `json:"authService"` 21 - AuthServer string `json:"authServer"` 22 - AuthMeta *oauth.OauthAuthorizationMetadata `json:"authMeta"` 19 + AuthService string `json:"authService"` 20 + AuthServer string `json:"authServer"` 23 21 // do NOT save the current handle permanently! 24 22 Handle string `json:"handle"` 25 23 DID string `json:"did"` ··· 32 30 Type string `json:"type"` 33 31 ServiceEndpoint string `json:"serviceEndpoint"` 34 32 } `json:"service"` 35 - } 36 - 37 - func (a *ATprotoAuthService) getUserInformation(ctx context.Context, handleOrDid string) (*UserInformation, error) { 38 - cli := a.client 39 - 40 - // if we have a did skip this 41 - did := handleOrDid 42 - err := error(nil) 43 - // technically checking SHOULD be more rigorous. 44 - if !strings.HasPrefix(handleOrDid, "did:") { 45 - did, err = resolveHandle(ctx, did) 46 - if err != nil { 47 - return nil, err 48 - } 49 - } else { 50 - did = handleOrDid 51 - } 52 - 53 - doc, err := getIdentityDocument(ctx, did) 54 - if err != nil { 55 - return nil, err 56 - } 57 - 58 - service, err := getAtprotoPdsService(doc) 59 - if err != nil { 60 - return nil, err 61 - } 62 - 63 - authserver, err := cli.ResolvePdsAuthServer(ctx, service) 64 - if err != nil { 65 - return nil, err 66 - } 67 - 68 - authmeta, err := cli.FetchAuthServerMetadata(ctx, authserver) 69 - if err != nil { 70 - return nil, err 71 - } 72 - 73 - if len(doc.AlsoKnownAs) == 0 { 74 - return nil, fmt.Errorf("alsoKnownAs is empty, couldn't acquire handle: %w", err) 75 - 76 - } 77 - handle := strings.Replace(doc.AlsoKnownAs[0], "at://", "", 1) 78 - 79 - return &UserInformation{ 80 - AuthService: service, 81 - AuthServer: authserver, 82 - AuthMeta: authmeta, 83 - Handle: handle, 84 - DID: did, 85 - }, nil 86 33 } 87 34 88 35 func resolveHandle(ctx context.Context, handle string) (string, error) {
-25
oauth/atproto/xrpc.go
··· 1 - package atproto 2 - 3 - import ( 4 - "log/slog" 5 - 6 - oauth "github.com/haileyok/atproto-oauth-golang" 7 - ) 8 - 9 - func (atp *ATprotoAuthService) NewXrpcClient() { 10 - atp.xrpc = &oauth.XrpcClient{ 11 - OnDpopPdsNonceChanged: func(did, newNonce string) { 12 - _, err := atp.DB.Exec("UPDATE users SET atproto_pds_nonce = ? WHERE atproto_did = ?", newNonce, did) 13 - if err != nil { 14 - slog.Default().Error("error updating pds nonce", "err", err) 15 - } 16 - }, 17 - } 18 - } 19 - 20 - func (atp *ATprotoAuthService) GetXrpcClient() *oauth.XrpcClient { 21 - if atp.xrpc == nil { 22 - atp.NewXrpcClient() 23 - } 24 - return atp.xrpc 25 - }
+5
oauth/oauth2.go
··· 86 86 http.Redirect(w, r, authURL, http.StatusSeeOther) 87 87 } 88 88 89 + func (o *OAuth2Service) HandleLogout(w http.ResponseWriter, r *http.Request) { 90 + //TODO not implemented yet. not sure what the api call is for this package 91 + http.Redirect(w, r, "/", http.StatusSeeOther) 92 + } 93 + 89 94 func (o *OAuth2Service) HandleCallback(w http.ResponseWriter, r *http.Request) (int64, error) { 90 95 state := r.URL.Query().Get("state") 91 96 if state != o.state {
+22 -15
oauth/oauth_manager.go
··· 6 6 "log" 7 7 "net/http" 8 8 "sync" 9 - 10 - "github.com/teal-fm/piper/session" 11 9 ) 12 10 13 11 // manages multiple oauth client services 14 12 type OAuthServiceManager struct { 15 - services map[string]AuthService 16 - sessionManager *session.SessionManager 17 - mu sync.RWMutex 18 - logger *log.Logger 13 + services map[string]AuthService 14 + mu sync.RWMutex 15 + logger *log.Logger 19 16 } 20 17 21 - func NewOAuthServiceManager(sessionManager *session.SessionManager) *OAuthServiceManager { 18 + func NewOAuthServiceManager() *OAuthServiceManager { 22 19 return &OAuthServiceManager{ 23 - services: make(map[string]AuthService), 24 - sessionManager: sessionManager, 25 - logger: log.New(log.Writer(), "oauth: ", log.LstdFlags|log.Lmsgprefix), 20 + services: make(map[string]AuthService), 21 + logger: log.New(log.Writer(), "oauth: ", log.LstdFlags|log.Lmsgprefix), 26 22 } 27 23 } 28 24 ··· 58 54 } 59 55 } 60 56 57 + func (m *OAuthServiceManager) HandleLogout(serviceName string) http.HandlerFunc { 58 + return func(w http.ResponseWriter, r *http.Request) { 59 + m.mu.RLock() 60 + service, exists := m.services[serviceName] 61 + m.mu.RUnlock() 62 + 63 + if exists { 64 + service.HandleLogout(w, r) 65 + return 66 + } 67 + 68 + m.logger.Printf("Auth service '%s' not found for login request", serviceName) 69 + http.Error(w, fmt.Sprintf("Auth service '%s' not found", serviceName), http.StatusNotFound) 70 + } 71 + } 72 + 61 73 func (m *OAuthServiceManager) HandleCallback(serviceName string) http.HandlerFunc { 62 74 return func(w http.ResponseWriter, r *http.Request) { 63 75 m.mu.RLock() ··· 81 93 } 82 94 83 95 if userID > 0 { 84 - session := m.sessionManager.CreateSession(userID) 85 - 86 - m.sessionManager.SetSessionCookie(w, session) 87 - 88 - m.logger.Printf("Created session for user %d via service %s", userID, serviceName) 89 96 90 97 http.Redirect(w, r, "/", http.StatusSeeOther) 91 98 } else {
+2
oauth/service.go
··· 10 10 // handles the callback for the provider. is responsible for inserting 11 11 // sessions in the db 12 12 HandleCallback(w http.ResponseWriter, r *http.Request) (int64, error) 13 + 14 + HandleLogout(w http.ResponseWriter, r *http.Request) 13 15 } 14 16 15 17 // optional but recommended
+1 -1
pages/cache.go
··· 2 2 3 3 import "sync" 4 4 5 - /// Cache for pages 5 + // Cache for pages 6 6 7 7 type TmplCache[K comparable, V any] struct { 8 8 data map[K]V
+1 -2
pages/pages.go
··· 131 131 func Cache(h http.Handler) http.Handler { 132 132 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 133 133 path := strings.Split(r.URL.Path, "?")[0] 134 - 135 - //We may want to change these, just took what tangled has and allows browser side caching 134 + // We may want to change these, just took what tangled has and allows browser side caching 136 135 if strings.HasSuffix(path, ".css") { 137 136 // on day for css files 138 137 w.Header().Set("Cache-Control", "public, max-age=86400")
-125
pages/static/base.css
··· 1 1 @import "tailwindcss"; 2 - 3 - body { 4 - font-family: Arial, sans-serif; 5 - max-width: 800px; 6 - margin: 0 auto; 7 - padding: 20px; 8 - line-height: 1.6; 9 - } 10 - 11 - 12 - h1 { 13 - color: #1DB954; /* Spotify green */ 14 - } 15 - 16 - .nav { 17 - display: flex; 18 - flex-wrap: wrap; /* Allow wrapping on smaller screens */ 19 - margin-bottom: 20px; 20 - } 21 - 22 - .nav a { 23 - margin-right: 15px; 24 - margin-bottom: 5px; /* Add spacing below links */ 25 - text-decoration: none; 26 - color: #1DB954; 27 - font-weight: bold; 28 - } 29 - 30 - .card { 31 - border: 1px solid #ddd; 32 - border-radius: 8px; 33 - padding: 20px; 34 - margin-bottom: 20px; 35 - } 36 - 37 - .service-status { 38 - font-style: italic; 39 - color: #555; 40 - } 41 - 42 - 43 - label, input { 44 - display: block; 45 - margin-bottom: 10px; 46 - } 47 - 48 - input[type='text'] { 49 - width: 95%; 50 - padding: 8px; 51 - } 52 - 53 - /* Corrected width */ 54 - input[type='submit'] { 55 - padding: 10px 15px; 56 - color: white; 57 - border: none; 58 - border-radius: 4px; 59 - cursor: pointer; 60 - } 61 - 62 - .last-fm-input { 63 - background-color: #d51007; 64 - } 65 - 66 - .teal-input { 67 - background-color: #1DB954; 68 - } 69 - 70 - .error { 71 - color: red; 72 - margin-bottom: 10px; 73 - } 74 - 75 - .lastfm-form { 76 - max-width: 600px; 77 - margin: 20px auto; 78 - padding: 20px; 79 - border: 1px solid #ddd; 80 - border-radius: 8px; 81 - } 82 - 83 - .card { 84 - border: 1px solid #ddd; 85 - border-radius: 8px; 86 - padding: 20px; 87 - margin-bottom: 20px; 88 - } 89 - table { 90 - width: 100%; 91 - border-collapse: collapse; 92 - } 93 - table th, table td { 94 - padding: 8px; 95 - text-align: left; 96 - border-bottom: 1px solid #ddd; 97 - } 98 - .key-value { 99 - font-family: monospace; 100 - padding: 10px; 101 - background-color: #f5f5f5; 102 - border: 1px solid #ddd; 103 - border-radius: 4px; 104 - word-break: break-all; 105 - } 106 - .new-key-alert { 107 - background-color: #f8f9fa; 108 - border-left: 4px solid #1DB954; 109 - padding: 15px; 110 - margin-bottom: 20px; 111 - } 112 - .btn { 113 - padding: 8px 16px; 114 - background-color: #1DB954; 115 - color: white; 116 - border: none; 117 - border-radius: 4px; 118 - cursor: pointer; 119 - } 120 - .btn-danger { 121 - background-color: #dc3545; 122 - } 123 - 124 - .teal-header { 125 - color: #1DB954; 126 - }
+220 -107
pages/static/main.css
··· 7 7 "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 8 8 --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", 9 9 "Courier New", monospace; 10 + --color-gray-100: oklch(96.7% 0.003 264.542); 11 + --color-gray-200: oklch(92.8% 0.006 264.531); 12 + --color-gray-300: oklch(87.2% 0.01 258.338); 13 + --color-gray-600: oklch(44.6% 0.03 256.802); 14 + --color-white: #fff; 15 + --spacing: 0.25rem; 16 + --text-lg: 1.125rem; 17 + --text-lg--line-height: calc(1.75 / 1.125); 18 + --text-xl: 1.25rem; 19 + --text-xl--line-height: calc(1.75 / 1.25); 20 + --font-weight-semibold: 600; 21 + --font-weight-bold: 700; 22 + --leading-relaxed: 1.625; 23 + --radius-lg: 0.5rem; 10 24 --default-font-family: var(--font-sans); 11 25 --default-mono-font-family: var(--font-mono); 12 26 } ··· 190 204 max-width: 96rem; 191 205 } 192 206 } 207 + .mx-auto { 208 + margin-inline: auto; 209 + } 210 + .my-5 { 211 + margin-block: calc(var(--spacing) * 5); 212 + } 213 + .mt-1 { 214 + margin-top: calc(var(--spacing) * 1); 215 + } 216 + .mt-3 { 217 + margin-top: calc(var(--spacing) * 3); 218 + } 219 + .mb-1 { 220 + margin-bottom: calc(var(--spacing) * 1); 221 + } 222 + .mb-2 { 223 + margin-bottom: calc(var(--spacing) * 2); 224 + } 225 + .mb-3 { 226 + margin-bottom: calc(var(--spacing) * 3); 227 + } 228 + .mb-4 { 229 + margin-bottom: calc(var(--spacing) * 4); 230 + } 231 + .mb-5 { 232 + margin-bottom: calc(var(--spacing) * 5); 233 + } 193 234 .block { 194 235 display: block; 195 236 } 196 237 .contents { 197 238 display: contents; 198 239 } 240 + .flex { 241 + display: flex; 242 + } 199 243 .hidden { 200 244 display: none; 201 245 } 202 246 .table { 203 247 display: table; 204 248 } 249 + .w-\[95\%\] { 250 + width: 95%; 251 + } 252 + .w-full { 253 + width: 100%; 254 + } 255 + .max-w-\[600px\] { 256 + max-width: 600px; 257 + } 258 + .max-w-\[800px\] { 259 + max-width: 800px; 260 + } 261 + .border-collapse { 262 + border-collapse: collapse; 263 + } 205 264 .transform { 206 265 transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); 207 266 } 267 + .cursor-pointer { 268 + cursor: pointer; 269 + } 270 + .list-disc { 271 + list-style-type: disc; 272 + } 273 + .flex-wrap { 274 + flex-wrap: wrap; 275 + } 276 + .space-y-2 { 277 + :where(& > :not(:last-child)) { 278 + --tw-space-y-reverse: 0; 279 + margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse)); 280 + margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse))); 281 + } 282 + } 283 + .gap-x-4 { 284 + column-gap: calc(var(--spacing) * 4); 285 + } 286 + .gap-y-1 { 287 + row-gap: calc(var(--spacing) * 1); 288 + } 289 + .rounded { 290 + border-radius: 0.25rem; 291 + } 292 + .rounded-lg { 293 + border-radius: var(--radius-lg); 294 + } 295 + .border { 296 + border-style: var(--tw-border-style); 297 + border-width: 1px; 298 + } 299 + .border-b { 300 + border-bottom-style: var(--tw-border-style); 301 + border-bottom-width: 1px; 302 + } 303 + .border-l-4 { 304 + border-left-style: var(--tw-border-style); 305 + border-left-width: 4px; 306 + } 307 + .border-\[\#1DB954\] { 308 + border-color: #1DB954; 309 + } 310 + .border-gray-200 { 311 + border-color: var(--color-gray-200); 312 + } 313 + .border-gray-300 { 314 + border-color: var(--color-gray-300); 315 + } 316 + .bg-\[\#1DB954\] { 317 + background-color: #1DB954; 318 + } 319 + .bg-\[\#d51007\] { 320 + background-color: #d51007; 321 + } 322 + .bg-\[\#dc3545\] { 323 + background-color: #dc3545; 324 + } 325 + .bg-gray-100 { 326 + background-color: var(--color-gray-100); 327 + } 328 + .p-2 { 329 + padding: calc(var(--spacing) * 2); 330 + } 331 + .p-4 { 332 + padding: calc(var(--spacing) * 4); 333 + } 334 + .p-5 { 335 + padding: calc(var(--spacing) * 5); 336 + } 337 + .px-3 { 338 + padding-inline: calc(var(--spacing) * 3); 339 + } 340 + .px-4 { 341 + padding-inline: calc(var(--spacing) * 4); 342 + } 343 + .py-1\.5 { 344 + padding-block: calc(var(--spacing) * 1.5); 345 + } 346 + .py-2 { 347 + padding-block: calc(var(--spacing) * 2); 348 + } 349 + .py-2\.5 { 350 + padding-block: calc(var(--spacing) * 2.5); 351 + } 352 + .pl-5 { 353 + padding-left: calc(var(--spacing) * 5); 354 + } 355 + .text-left { 356 + text-align: left; 357 + } 358 + .font-mono { 359 + font-family: var(--font-mono); 360 + } 361 + .font-sans { 362 + font-family: var(--font-sans); 363 + } 364 + .text-lg { 365 + font-size: var(--text-lg); 366 + line-height: var(--tw-leading, var(--text-lg--line-height)); 367 + } 368 + .text-xl { 369 + font-size: var(--text-xl); 370 + line-height: var(--tw-leading, var(--text-xl--line-height)); 371 + } 372 + .leading-relaxed { 373 + --tw-leading: var(--leading-relaxed); 374 + line-height: var(--leading-relaxed); 375 + } 376 + .font-bold { 377 + --tw-font-weight: var(--font-weight-bold); 378 + font-weight: var(--font-weight-bold); 379 + } 380 + .font-semibold { 381 + --tw-font-weight: var(--font-weight-semibold); 382 + font-weight: var(--font-weight-semibold); 383 + } 384 + .text-\[\#1DB954\] { 385 + color: #1DB954; 386 + } 387 + .text-gray-600 { 388 + color: var(--color-gray-600); 389 + } 390 + .text-white { 391 + color: var(--color-white); 392 + } 208 393 .lowercase { 209 394 text-transform: lowercase; 210 395 } 396 + .italic { 397 + font-style: italic; 398 + } 399 + .no-underline { 400 + text-decoration-line: none; 401 + } 211 402 .filter { 212 403 filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); 213 404 } 214 - } 215 - body { 216 - font-family: Arial, sans-serif; 217 - max-width: 800px; 218 - margin: 0 auto; 219 - padding: 20px; 220 - line-height: 1.6; 221 - } 222 - h1 { 223 - color: #1DB954; 224 - } 225 - .nav { 226 - display: flex; 227 - flex-wrap: wrap; 228 - margin-bottom: 20px; 229 - } 230 - .nav a { 231 - margin-right: 15px; 232 - margin-bottom: 5px; 233 - text-decoration: none; 234 - color: #1DB954; 235 - font-weight: bold; 236 - } 237 - .card { 238 - border: 1px solid #ddd; 239 - border-radius: 8px; 240 - padding: 20px; 241 - margin-bottom: 20px; 242 - } 243 - .service-status { 244 - font-style: italic; 245 - color: #555; 246 - } 247 - label, input { 248 - display: block; 249 - margin-bottom: 10px; 250 - } 251 - input[type='text'] { 252 - width: 95%; 253 - padding: 8px; 254 - } 255 - input[type='submit'] { 256 - padding: 10px 15px; 257 - color: white; 258 - border: none; 259 - border-radius: 4px; 260 - cursor: pointer; 261 - } 262 - .last-fm-input { 263 - background-color: #d51007; 264 - } 265 - .teal-input { 266 - background-color: #1DB954; 267 - } 268 - .error { 269 - color: red; 270 - margin-bottom: 10px; 271 - } 272 - .lastfm-form { 273 - max-width: 600px; 274 - margin: 20px auto; 275 - padding: 20px; 276 - border: 1px solid #ddd; 277 - border-radius: 8px; 278 - } 279 - .card { 280 - border: 1px solid #ddd; 281 - border-radius: 8px; 282 - padding: 20px; 283 - margin-bottom: 20px; 284 - } 285 - table { 286 - width: 100%; 287 - border-collapse: collapse; 288 - } 289 - table th, table td { 290 - padding: 8px; 291 - text-align: left; 292 - border-bottom: 1px solid #ddd; 293 - } 294 - .key-value { 295 - font-family: monospace; 296 - padding: 10px; 297 - background-color: #f5f5f5; 298 - border: 1px solid #ddd; 299 - border-radius: 4px; 300 - word-break: break-all; 301 - } 302 - .new-key-alert { 303 - background-color: #f8f9fa; 304 - border-left: 4px solid #1DB954; 305 - padding: 15px; 306 - margin-bottom: 20px; 307 - } 308 - .btn { 309 - padding: 8px 16px; 310 - background-color: #1DB954; 311 - color: white; 312 - border: none; 313 - border-radius: 4px; 314 - cursor: pointer; 315 - } 316 - .btn-danger { 317 - background-color: #dc3545; 318 - } 319 - .teal-header { 320 - color: #1DB954; 405 + .hover\:opacity-90 { 406 + &:hover { 407 + @media (hover: hover) { 408 + opacity: 90%; 409 + } 410 + } 411 + } 321 412 } 322 413 @property --tw-rotate-x { 323 414 syntax: "*"; ··· 336 427 inherits: false; 337 428 } 338 429 @property --tw-skew-y { 430 + syntax: "*"; 431 + inherits: false; 432 + } 433 + @property --tw-space-y-reverse { 434 + syntax: "*"; 435 + inherits: false; 436 + initial-value: 0; 437 + } 438 + @property --tw-border-style { 439 + syntax: "*"; 440 + inherits: false; 441 + initial-value: solid; 442 + } 443 + @property --tw-leading { 444 + syntax: "*"; 445 + inherits: false; 446 + } 447 + @property --tw-font-weight { 339 448 syntax: "*"; 340 449 inherits: false; 341 450 } ··· 400 509 --tw-rotate-z: initial; 401 510 --tw-skew-x: initial; 402 511 --tw-skew-y: initial; 512 + --tw-space-y-reverse: 0; 513 + --tw-border-style: solid; 514 + --tw-leading: initial; 515 + --tw-font-weight: initial; 403 516 --tw-blur: initial; 404 517 --tw-brightness: initial; 405 518 --tw-contrast: initial;
+32 -32
pages/templates/apiKeys.gohtml
··· 4 4 {{ template "components/navBar" .NavBar }} 5 5 6 6 7 - <h1>API Key Management</h1> 7 + <h1 class="text-[#1DB954]">API Key Management</h1> 8 8 9 - <div class="card"> 10 - <h2 class="teal-header">Create New API Key</h2> 11 - <p>API keys allow programmatic access to your Piper account data.</p> 9 + <div class="border border-gray-300 rounded-lg p-5 mb-5"> 10 + <h2 class="text-[#1DB954] text-xl font-semibold mb-2">Create New API Key</h2> 11 + <p class="mb-3">API keys allow programmatic access to your Piper account data.</p> 12 12 <form method="POST" action="/api-keys"> 13 - <div style="margin-bottom: 15px;"> 14 - <label for="name">Key Name (for your reference):</label> 15 - <input type="text" id="name" name="name" placeholder="My Application" style="width: 100%; padding: 8px; margin-top: 5px;"> 13 + <div class="mb-4"> 14 + <label class="block" for="name">Key Name (for your reference):</label> 15 + <input class="mt-1 w-full p-2 border border-gray-300 rounded" type="text" id="name" name="name" placeholder="My Application"> 16 16 </div> 17 - <button type="submit" class="btn">Generate New API Key</button> 17 + <button type="submit" class="bg-[#1DB954] text-white px-4 py-2 rounded cursor-pointer hover:opacity-90">Generate New API Key</button> 18 18 </form> 19 19 </div> 20 20 21 21 {{if .NewKeyID}} <!-- Changed from .NewKey to .NewKeyID for clarity --> 22 - <div class="new-key-alert"> 23 - <h3 class="teal-header">Your new API key (ID: {{.NewKeyID}}) has been created</h3> 22 + <div class="bg-gray-100 border-l-4 border-[#1DB954] p-4 mb-5"> 23 + <h3 class="text-[#1DB954] text-lg font-semibold mb-1">Your new API key (ID: {{.NewKeyID}}) has been created</h3> 24 24 <!-- The message below is misleading if only the ID is shown. 25 25 Consider changing this text or modifying the flow to show the actual key once for HTML. --> 26 26 <p><strong>Important:</strong> If this is an ID, ensure you have copied the actual key if it was displayed previously. For keys generated via the API, the key is returned in the API response.</p> 27 27 </div> 28 28 {{end}} 29 29 30 - <div class="card"> 31 - <h2 class="teal-header">Your API Keys</h2> 30 + <div class="border border-gray-300 rounded-lg p-5 mb-5"> 31 + <h2 class="text-[#1DB954] text-xl font-semibold mb-2">Your API Keys</h2> 32 32 {{if .Keys}} 33 - <table> 33 + <table class="w-full border-collapse"> 34 34 <thead> 35 - <tr> 36 - <th>Name</th> 37 - <th>Prefix</th> 38 - <th>Created</th> 39 - <th>Expires</th> 40 - <th>Actions</th> 35 + <tr class="text-left border-b border-gray-300"> 36 + <th class="p-2">Name</th> 37 + <th class="p-2">Prefix</th> 38 + <th class="p-2">Created</th> 39 + <th class="p-2">Expires</th> 40 + <th class="p-2">Actions</th> 41 41 </tr> 42 42 </thead> 43 43 <tbody> 44 44 {{range .Keys}} 45 - <tr> 46 - <td>{{.Name}}</td> 47 - <td>{{.KeyPrefix}}</td> <!-- Added KeyPrefix for better identification --> 48 - <td>{{formatTime .CreatedAt}}</td> 49 - <td>{{formatTime .ExpiresAt}}</td> 50 - <td> 51 - <button class="btn btn-danger" onclick="deleteKey('{{.ID}}')">Delete</button> 45 + <tr class="border-b border-gray-200"> 46 + <td class="p-2">{{.Name}}</td> 47 + <td class="p-2">{{.KeyPrefix}}</td> <!-- Added KeyPrefix for better identification --> 48 + <td class="p-2">{{formatTime .CreatedAt}}</td> 49 + <td class="p-2">{{formatTime .ExpiresAt}}</td> 50 + <td class="p-2"> 51 + <button class="bg-[#dc3545] text-white px-3 py-1.5 rounded cursor-pointer hover:opacity-90" onclick="deleteKey('{{.ID}}')">Delete</button> 52 52 </td> 53 53 </tr> 54 54 {{end}} ··· 59 59 {{end}} 60 60 </div> 61 61 62 - <div class="card"> 63 - <h2 class="teal-header">API Usage</h2> 64 - <p>To use your API key, include it in the Authorization header of your HTTP requests:</p> 65 - <pre>Authorization: Bearer YOUR_API_KEY</pre> 66 - <p>Or include it as a query parameter (less secure for the key itself):</p> 67 - <pre>https://your-piper-instance.com/endpoint?api_key=YOUR_API_KEY</pre> 62 + <div class="border border-gray-300 rounded-lg p-5 mb-5"> 63 + <h2 class="text-[#1DB954] text-xl font-semibold mb-2">API Usage</h2> 64 + <p class="mb-2">To use your API key, include it in the Authorization header of your HTTP requests:</p> 65 + <pre class="font-mono p-2 bg-gray-100 border border-gray-300 rounded">Authorization: Bearer YOUR_API_KEY</pre> 66 + <p class="mt-3 mb-2">Or include it as a query parameter (less secure for the key itself):</p> 67 + <pre class="font-mono p-2 bg-gray-100 border border-gray-300 rounded">https://your-piper-instance.com/endpoint?api_key=YOUR_API_KEY</pre> 68 68 </div> 69 69 70 70 <script>
+11 -11
pages/templates/components/navBar.gohtml
··· 1 1 {{ define "components/navBar" }} 2 2 3 - <div class="nav"> 4 - <a href="/">Home</a> 3 + <nav class="flex flex-wrap mb-5 gap-x-4 gap-y-1"> 4 + <a class="text-[#1DB954] font-bold no-underline" href="/">Home</a> 5 5 6 6 {{if .IsLoggedIn}} 7 - <a href="/current-track">Spotify Current</a> 8 - <a href="/history">Spotify History</a> 9 - <a href="/link-lastfm">Link Last.fm</a> 7 + <a class="text-[#1DB954] font-bold no-underline" href="/current-track">Spotify Current</a> 8 + <a class="text-[#1DB954] font-bold no-underline" href="/history">Spotify History</a> 9 + <a class="text-[#1DB954] font-bold no-underline" href="/link-lastfm">Link Last.fm</a> 10 10 {{ if .LastFMUsername }} 11 - <a href="/lastfm/recent">Last.fm Recent</a> 11 + <a class="text-[#1DB954] font-bold no-underline" href="/lastfm/recent">Last.fm Recent</a> 12 12 {{ end }} 13 - <a href="/api-keys">API Keys</a> 14 - <a href="/login/spotify">Connect Spotify Account</a> 15 - <a href="/logout">Logout</a> 13 + <a class="text-[#1DB954] font-bold no-underline" href="/api-keys">API Keys</a> 14 + <a class="text-[#1DB954] font-bold no-underline" href="/login/spotify">Connect Spotify Account</a> 15 + <a class="text-[#1DB954] font-bold no-underline" href="/logout">Logout</a> 16 16 {{ else }} 17 - <a href="/login/atproto">Login with ATProto</a> 17 + <a class="text-[#1DB954] font-bold no-underline" href="/login/atproto">Login with ATProto</a> 18 18 {{ end }} 19 - </div> 19 + </nav> 20 20 {{ end }}
+20 -20
pages/templates/home.gohtml
··· 1 1 2 2 {{ define "content" }} 3 3 4 - <h1>Piper - Multi-User Spotify & Last.fm Tracker via ATProto</h1> 4 + <h1 class="text-[#1DB954]">Piper - Multi-User Spotify & Last.fm Tracker via ATProto</h1> 5 5 {{ template "components/navBar" .NavBar }} 6 6 7 7 8 - <div class="card"> 9 - <h2 class="">Welcome to Piper</h2> 10 - <p>Piper is a multi-user application that records what you're listening to on Spotify and Last.fm, saving your listening history.</p> 8 + <div class="border border-gray-300 rounded-lg p-5 mb-5"> 9 + <h2 class="text-xl font-semibold mb-2">Welcome to Piper</h2> 10 + <p class="mb-3">Piper is a multi-user application that records what you're listening to on Spotify and Last.fm, saving your listening history.</p> 11 11 12 12 {{if .NavBar.IsLoggedIn}} 13 - <p>You're logged in!</p> 14 - <ul> 15 - <li><a href="/login/spotify">Connect your Spotify account</a> to start tracking.</li> 16 - <li><a href="/link-lastfm">Link your Last.fm account</a> to track scrobbles.</li> 13 + <p class="mb-2">You're logged in!</p> 14 + <ul class="list-disc pl-5 mb-3"> 15 + <li><a class="text-[#1DB954] font-bold" href="/login/spotify">Connect your Spotify account</a> to start tracking.</li> 16 + <li><a class="text-[#1DB954] font-bold" href="/link-lastfm">Link your Last.fm account</a> to track scrobbles.</li> 17 17 </ul> 18 - <p>Once connected, you can check out your:</p> 19 - <ul> 20 - <li><a href="/current-track">Spotify current track</a> or <a href="/history">listening history</a>.</li> 18 + <p class="mb-2">Once connected, you can check out your:</p> 19 + <ul class="list-disc pl-5 mb-3"> 20 + <li><a class="text-[#1DB954] font-bold" href="/current-track">Spotify current track</a> or <a class="text-[#1DB954] font-bold" href="/history">listening history</a>.</li> 21 21 {{ if .NavBar.LastFMUsername }} 22 - <li><a href="/lastfm/recent">Last.fm recent tracks</a>.</li> 22 + <li><a class="text-[#1DB954] font-bold" href="/lastfm/recent">Last.fm recent tracks</a>.</li> 23 23 {{ end }} 24 24 25 25 </ul> 26 - <p>You can also manage your <a href="/api-keys">API keys</a> for programmatic access.</p> 26 + <p class="mb-3">You can also manage your <a class="text-[#1DB954] font-bold" href="/api-keys">API keys</a> for programmatic access.</p> 27 27 28 28 {{ if .NavBar.LastFMUsername }} 29 - <p class='service-status'>Last.fm Username: {{ .NavBar.LastFMUsername }}</p> 29 + <p class='italic text-gray-600'>Last.fm Username: {{ .NavBar.LastFMUsername }}</p> 30 30 {{else }} 31 - <p class='service-status'>Last.fm account not linked.</p> 31 + <p class='italic text-gray-600'>Last.fm account not linked.</p> 32 32 {{end}} 33 33 34 34 35 35 {{ else }} 36 36 37 - <p>Login with ATProto to get started!</p> 38 - <form action="/login/atproto"> 39 - <label for="handle">handle:</label> 40 - <input type="text" id="handle" name="handle" > 41 - <input class="teal-input" type="submit" value="submit"> 37 + <p class="mb-3">Login with ATProto to get started!</p> 38 + <form class="space-y-2" action="/login/atproto"> 39 + <label class="block" for="handle">handle:</label> 40 + <input class="block w-[95%] p-2 border border-gray-300 rounded" type="text" id="handle" name="handle" > 41 + <input class="bg-[#1DB954] text-white px-4 py-2.5 rounded cursor-pointer hover:opacity-90" type="submit" value="submit"> 42 42 </form> 43 43 44 44
+7 -7
pages/templates/lastFMForm.gohtml
··· 1 1 {{ define "content" }} 2 2 {{ template "components/navBar" .NavBar }} 3 3 4 - <div class="lastfm-form"> 5 - <h2>Link Your Last.fm Account</h2> 6 - <p>Enter your Last.fm username to start tracking your scrobbles.</p> 7 - <form method="post" action="/link-lastfm"> 8 - <label for="lastfm_username">Last.fm Username:</label> 9 - <input type="text" id="lastfm_username" name="lastfm_username" value="{{.CurrentUsername}}" required> 10 - <input class="last-fm-input" type="submit" value="Save Username"> 4 + <div class="max-w-[600px] mx-auto my-5 p-5 border border-gray-300 rounded-lg"> 5 + <h2 class="text-xl font-semibold mb-2">Link Your Last.fm Account</h2> 6 + <p class="mb-3">Enter your Last.fm username to start tracking your scrobbles.</p> 7 + <form class="space-y-2" method="post" action="/link-lastfm"> 8 + <label class="block" for="lastfm_username">Last.fm Username:</label> 9 + <input class="block w-[95%] p-2 border border-gray-300 rounded" type="text" id="lastfm_username" name="lastfm_username" value="{{.CurrentUsername}}" required> 10 + <input class="bg-[#d51007] text-white px-4 py-2.5 rounded cursor-pointer hover:opacity-90" type="submit" value="Save Username"> 11 11 </form> 12 12 </div> 13 13
+1 -1
pages/templates/layouts/base.gohtml
··· 5 5 <title>Piper - Spotify & Last.fm Tracker</title> 6 6 <link rel="stylesheet" href="/static/main.css"> 7 7 </head> 8 - <body> 8 + <body class="font-sans max-w-[800px] mx-auto p-5 leading-relaxed"> 9 9 {{ block "content" . }}{{ end }} 10 10 11 11 </body>
-7
service/apikey/apikey.go
··· 203 203 newKeyValueToShow = newlyCreatedKeyID 204 204 } 205 205 206 - //t, err := template.New("apikeys").Funcs(funcMap).Parse(tmpl) 207 - if err != nil { 208 - http.Error(w, fmt.Sprintf("Error parsing template: %v", err), http.StatusInternalServerError) 209 - return 210 - } 211 - 212 206 data := struct { 213 207 Keys []*db_apikey.ApiKey // Assuming GetUserApiKeys returns this type 214 208 NewKeyID string // Changed from NewKey for clarity as it's an ID ··· 228 222 if err != nil { 229 223 log.Printf("Error executing template: %v", err) 230 224 } 231 - //t.Execute(w, data) 232 225 } 233 226 }
+121
service/atproto/submission.go
··· 1 + package atproto 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log" 7 + "time" 8 + 9 + comatproto "github.com/bluesky-social/indigo/api/atproto" 10 + lexutil "github.com/bluesky-social/indigo/lex/util" 11 + "github.com/spf13/viper" 12 + "github.com/teal-fm/piper/api/teal" 13 + "github.com/teal-fm/piper/models" 14 + atprotoauth "github.com/teal-fm/piper/oauth/atproto" 15 + ) 16 + 17 + // SubmitPlayToPDS submits a track play to the ATProto PDS as a feed.play record 18 + func SubmitPlayToPDS(ctx context.Context, did string, mostRecentAtProtoSessionID string, track *models.Track, atprotoService *atprotoauth.ATprotoAuthService) error { 19 + if did == "" { 20 + return fmt.Errorf("DID cannot be empty") 21 + } 22 + 23 + // Get ATProto client 24 + client, err := atprotoService.GetATProtoClient(did, mostRecentAtProtoSessionID, ctx) 25 + if err != nil || client == nil { 26 + return fmt.Errorf("failed to get ATProto client: %w", err) 27 + } 28 + 29 + // Convert track to feed.play record 30 + playRecord, err := TrackToPlayRecord(track) 31 + if err != nil { 32 + return fmt.Errorf("failed to convert track to play record: %w", err) 33 + } 34 + 35 + // Create the record 36 + input := comatproto.RepoCreateRecord_Input{ 37 + Collection: "fm.teal.alpha.feed.play", 38 + Repo: client.AccountDID.String(), 39 + Record: &lexutil.LexiconTypeDecoder{Val: playRecord}, 40 + } 41 + 42 + if _, err := comatproto.RepoCreateRecord(ctx, client, &input); err != nil { 43 + return fmt.Errorf("failed to create play record for DID %s: %w", did, err) 44 + } 45 + 46 + log.Printf("Successfully submitted play to PDS for DID %s: %s - %s", did, track.Artist[0].Name, track.Name) 47 + return nil 48 + } 49 + 50 + // TrackToPlayRecord converts a models.Track to teal.AlphaFeedPlay 51 + func TrackToPlayRecord(track *models.Track) (*teal.AlphaFeedPlay, error) { 52 + if track.Name == "" { 53 + return nil, fmt.Errorf("track name cannot be empty") 54 + } 55 + 56 + // Convert artists 57 + artists := make([]*teal.AlphaFeedDefs_Artist, 0, len(track.Artist)) 58 + for _, a := range track.Artist { 59 + artist := &teal.AlphaFeedDefs_Artist{ 60 + ArtistName: a.Name, 61 + ArtistMbId: a.MBID, 62 + } 63 + artists = append(artists, artist) 64 + } 65 + 66 + // Prepare optional fields 67 + var durationPtr *int64 68 + if track.DurationMs > 0 { 69 + durationSeconds := track.DurationMs / 1000 70 + durationPtr = &durationSeconds 71 + } 72 + 73 + var playedTimeStr *string 74 + if !track.Timestamp.IsZero() { 75 + timeStr := track.Timestamp.Format(time.RFC3339) 76 + playedTimeStr = &timeStr 77 + } 78 + 79 + var isrcPtr *string 80 + if track.ISRC != "" { 81 + isrcPtr = &track.ISRC 82 + } 83 + 84 + var originUrlPtr *string 85 + if track.URL != "" { 86 + originUrlPtr = &track.URL 87 + } 88 + 89 + var servicePtr *string 90 + if track.ServiceBaseUrl != "" { 91 + servicePtr = &track.ServiceBaseUrl 92 + } 93 + 94 + var releaseNamePtr *string 95 + if track.Album != "" { 96 + releaseNamePtr = &track.Album 97 + } 98 + 99 + // Get submission client agent 100 + submissionAgent := viper.GetString("app.submission_agent") 101 + if submissionAgent == "" { 102 + submissionAgent = "piper/v0.0.1" 103 + } 104 + 105 + playRecord := &teal.AlphaFeedPlay{ 106 + LexiconTypeID: "fm.teal.alpha.feed.play", 107 + TrackName: track.Name, 108 + Artists: artists, 109 + Duration: durationPtr, 110 + PlayedTime: playedTimeStr, 111 + RecordingMbId: track.RecordingMBID, 112 + ReleaseMbId: track.ReleaseMBID, 113 + ReleaseName: releaseNamePtr, 114 + Isrc: isrcPtr, 115 + OriginUrl: originUrlPtr, 116 + MusicServiceBaseDomain: servicePtr, 117 + SubmissionClientAgent: &submissionAgent, 118 + } 119 + 120 + return playRecord, nil 121 + }
+5 -79
service/lastfm/lastfm.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 - "errors" 7 6 "fmt" 8 7 "io" 9 8 "log" ··· 14 13 "sync" 15 14 "time" 16 15 17 - "github.com/bluesky-social/indigo/api/atproto" 18 - lexutil "github.com/bluesky-social/indigo/lex/util" 19 - "github.com/bluesky-social/indigo/xrpc" 20 - "github.com/spf13/viper" 21 - "github.com/teal-fm/piper/api/teal" 22 16 "github.com/teal-fm/piper/db" 23 17 "github.com/teal-fm/piper/models" 24 18 atprotoauth "github.com/teal-fm/piper/oauth/atproto" 19 + atprotoservice "github.com/teal-fm/piper/service/atproto" 25 20 "github.com/teal-fm/piper/service/musicbrainz" 26 21 "golang.org/x/time/rate" 27 22 ) ··· 395 390 } 396 391 l.db.SaveTrack(user.ID, hydratedTrack) 397 392 l.logger.Printf("Submitting track") 398 - err = l.SubmitTrackToPDS(*user.ATProtoDID, hydratedTrack, ctx) 393 + err = l.SubmitTrackToPDS(*user.ATProtoDID, *user.MostRecentAtProtoSessionID, hydratedTrack, ctx) 399 394 if err != nil { 400 395 l.logger.Printf("error submitting track for user %s: %s - %s: %v", username, track.Artist.Text, track.Name, err) 401 396 } ··· 418 413 return nil 419 414 } 420 415 421 - func (l *LastFMService) SubmitTrackToPDS(did string, track *models.Track, ctx context.Context) error { 422 - client, err := l.atprotoService.GetATProtoClient() 423 - if err != nil || client == nil { 424 - return err 425 - } 426 - 427 - xrpcClient := l.atprotoService.GetXrpcClient() 428 - if xrpcClient == nil { 429 - return errors.New("xrpc client is kil") 430 - } 431 - 432 - // we check for client above 433 - sess, err := l.db.GetAtprotoSession(did, ctx, *client) 434 - if err != nil { 435 - return fmt.Errorf("Couldn't get Atproto session: %s", err) 436 - } 437 - 438 - // printout the session details 439 - l.logger.Printf("Submitting track for the did: %+v\n", sess.DID) 440 - 441 - artists := make([]*teal.AlphaFeedDefs_Artist, 0, len(track.Artist)) 442 - for _, a := range track.Artist { 443 - artist := &teal.AlphaFeedDefs_Artist{ 444 - ArtistName: a.Name, 445 - ArtistMbId: a.MBID, 446 - } 447 - artists = append(artists, artist) 448 - } 449 - 450 - var durationPtr *int64 451 - if track.DurationMs > 0 { 452 - durationSeconds := track.DurationMs / 1000 453 - durationPtr = &durationSeconds 454 - } 455 - 456 - playedTimeStr := track.Timestamp.Format(time.RFC3339) 457 - submissionAgent := viper.GetString("app.submission_agent") 458 - if submissionAgent == "" { 459 - submissionAgent = "piper/v0.0.1" // Default if not configured 460 - } 461 - 462 - // track -> tealfm track 463 - tfmTrack := teal.AlphaFeedPlay{ 464 - LexiconTypeID: "fm.teal.alpha.feed.play", // Assuming this is the correct Lexicon ID 465 - // tfm specifies duration in seconds 466 - Duration: durationPtr, // Pointer required 467 - TrackName: track.Name, 468 - // should be unix timestamp 469 - PlayedTime: &playedTimeStr, // Pointer required 470 - Artists: artists, 471 - ReleaseMbId: track.ReleaseMBID, // Pointer required 472 - ReleaseName: &track.Album, // Pointer required 473 - RecordingMbId: track.RecordingMBID, // Pointer required 474 - SubmissionClientAgent: &submissionAgent, // Pointer required 475 - } 476 - 477 - input := atproto.RepoCreateRecord_Input{ 478 - Collection: "fm.teal.alpha.feed.play", 479 - Repo: sess.DID, 480 - Record: &lexutil.LexiconTypeDecoder{Val: &tfmTrack}, 481 - } 482 - 483 - authArgs := db.AtpSessionToAuthArgs(sess) 484 - 485 - var out atproto.RepoCreateRecord_Output 486 - if err := xrpcClient.Do(ctx, authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.createRecord", nil, input, &out); err != nil { 487 - return err 488 - } 489 - 490 - // submit track to PDS 491 - 492 - return nil 416 + func (l *LastFMService) SubmitTrackToPDS(did string, mostRecentAtProtoSessionID string, track *models.Track, ctx context.Context) error { 417 + // Use shared atproto service for submission 418 + return atprotoservice.SubmitPlayToPDS(ctx, did, mostRecentAtProtoSessionID, track, l.atprotoService) 493 419 } 494 420 495 421 // convertLastFMTrackToModelsTrack converts a Last.fm Track to models.Track format
+31 -58
service/playingnow/playingnow.go
··· 8 8 "strconv" 9 9 "time" 10 10 11 - "github.com/bluesky-social/indigo/api/atproto" 11 + "github.com/bluesky-social/indigo/atproto/client" 12 12 lexutil "github.com/bluesky-social/indigo/lex/util" 13 - "github.com/bluesky-social/indigo/xrpc" 14 - oauth "github.com/haileyok/atproto-oauth-golang" 15 13 "github.com/spf13/viper" 14 + 15 + comatproto "github.com/bluesky-social/indigo/api/atproto" 16 16 "github.com/teal-fm/piper/api/teal" 17 17 "github.com/teal-fm/piper/db" 18 18 "github.com/teal-fm/piper/models" ··· 52 52 53 53 did := *user.ATProtoDID 54 54 55 - // Get ATProto client 56 - client, err := p.atprotoService.GetATProtoClient() 57 - if err != nil || client == nil { 58 - return fmt.Errorf("failed to get ATProto client: %w", err) 59 - } 60 - 61 - xrpcClient := p.atprotoService.GetXrpcClient() 62 - if xrpcClient == nil { 63 - return fmt.Errorf("xrpc client is not available") 64 - } 65 - 66 - // Get user session 67 - sess, err := p.db.GetAtprotoSession(did, ctx, *client) 68 - if err != nil { 69 - return fmt.Errorf("couldn't get Atproto session for DID %s: %w", did, err) 55 + // Get ATProto atProtoClient 56 + atProtoClient, err := p.atprotoService.GetATProtoClient(did, *user.MostRecentAtProtoSessionID, ctx) 57 + if err != nil || atProtoClient == nil { 58 + return fmt.Errorf("failed to get ATProto atProtoClient: %w", err) 70 59 } 71 60 72 61 // Convert track to PlayView format ··· 86 75 Item: playView, 87 76 } 88 77 89 - authArgs := db.AtpSessionToAuthArgs(sess) 90 78 var swapRecord *string 91 - swapRecord, err = p.getStatusSwapRecord(ctx, xrpcClient, sess, authArgs) 79 + swapRecord, err = p.getStatusSwapRecord(ctx, atProtoClient) 92 80 if err != nil { 93 81 return err 94 82 } 95 83 96 84 // Create the record input 97 - input := atproto.RepoPutRecord_Input{ 85 + input := comatproto.RepoPutRecord_Input{ 98 86 Collection: "fm.teal.alpha.actor.status", 99 - Repo: sess.DID, 87 + Repo: atProtoClient.AccountDID.String(), 100 88 Rkey: "self", // Use "self" as the record key for current status 101 89 Record: &lexutil.LexiconTypeDecoder{Val: status}, 102 90 SwapRecord: swapRecord, 103 91 } 104 92 105 93 // Submit to PDS 106 - var out atproto.RepoPutRecord_Output 107 - if err := xrpcClient.Do(ctx, authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", nil, input, &out); err != nil { 94 + if _, err := comatproto.RepoPutRecord(ctx, atProtoClient, &input); err != nil { 108 95 p.logger.Printf("Error creating playing now status for DID %s: %v", did, err) 109 96 return fmt.Errorf("failed to create playing now status for DID %s: %w", did, err) 110 97 } ··· 131 118 did := *user.ATProtoDID 132 119 133 120 // Get ATProto clients 134 - client, err := p.atprotoService.GetATProtoClient() 135 - if err != nil || client == nil { 136 - return fmt.Errorf("failed to get ATProto client: %w", err) 137 - } 138 - 139 - xrpcClient := p.atprotoService.GetXrpcClient() 140 - if xrpcClient == nil { 141 - return fmt.Errorf("xrpc client is not available") 142 - } 143 - 144 - // Get user session 145 - sess, err := p.db.GetAtprotoSession(did, ctx, *client) 146 - if err != nil { 147 - return fmt.Errorf("couldn't get Atproto session for DID %s: %w", did, err) 121 + atProtoClient, err := p.atprotoService.GetATProtoClient(did, *user.MostRecentAtProtoSessionID, ctx) 122 + if err != nil || atProtoClient == nil { 123 + return fmt.Errorf("failed to get ATProto atProtoClient: %w", err) 148 124 } 149 125 150 126 // Create an expired status (essentially clearing it) ··· 164 140 Item: emptyPlayView, 165 141 } 166 142 167 - authArgs := db.AtpSessionToAuthArgs(sess) 168 143 var swapRecord *string 169 - swapRecord, err = p.getStatusSwapRecord(ctx, xrpcClient, sess, authArgs) 144 + swapRecord, err = p.getStatusSwapRecord(ctx, atProtoClient) 170 145 if err != nil { 171 146 return err 172 147 } 173 148 174 149 // Update the record 175 - input := atproto.RepoPutRecord_Input{ 150 + input := comatproto.RepoPutRecord_Input{ 176 151 Collection: "fm.teal.alpha.actor.status", 177 - Repo: sess.DID, 152 + Repo: atProtoClient.AccountDID.String(), 178 153 Rkey: "self", 179 154 Record: &lexutil.LexiconTypeDecoder{Val: status}, 180 155 SwapRecord: swapRecord, 181 156 } 182 157 183 - var out atproto.RepoPutRecord_Output 184 - if err := xrpcClient.Do(ctx, authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", nil, input, &out); err != nil { 158 + if _, err := comatproto.RepoPutRecord(ctx, atProtoClient, &input); err != nil { 185 159 p.logger.Printf("Error clearing playing now status for DID %s: %v", did, err) 186 160 return fmt.Errorf("failed to clear playing now status for DID %s: %w", did, err) 187 161 } ··· 242 216 // Get submission client agent 243 217 submissionAgent := viper.GetString("app.submission_agent") 244 218 if submissionAgent == "" { 245 - submissionAgent = "piper/v0.0.1" 219 + submissionAgent = "piper/v0.0.2" 246 220 } 247 221 248 222 playView := &teal.AlphaFeedDefs_PlayView{ ··· 264 238 265 239 // getStatusSwapRecord retrieves the current swap record (CID) for the actor status record. 266 240 // Returns (nil, nil) if the record does not exist yet. 267 - func (p *PlayingNowService) getStatusSwapRecord(ctx context.Context, xrpcClient *oauth.XrpcClient, sess *models.ATprotoAuthSession, authArgs *oauth.XrpcAuthedRequestArgs) (*string, error) { 268 - getOutput := atproto.RepoGetRecord_Output{} 269 - if err := xrpcClient.Do(ctx, authArgs, xrpc.Query, "application/json", "com.atproto.repo.getRecord", map[string]any{ 270 - "repo": sess.DID, 271 - "collection": "fm.teal.alpha.actor.status", 272 - "rkey": "self", 273 - }, nil, &getOutput); err != nil { 274 - xErr, ok := err.(*xrpc.Error) 241 + func (p *PlayingNowService) getStatusSwapRecord(ctx context.Context, atApiClient *client.APIClient) (*string, error) { 242 + result, err := comatproto.RepoGetRecord(ctx, atApiClient, "", "fm.teal.alpha.actor.status", atApiClient.AccountDID.String(), "self") 243 + 244 + if err != nil { 245 + xErr, ok := err.(*client.APIError) 275 246 if !ok { 276 - return nil, fmt.Errorf("could not get record: %w", err) 247 + return nil, fmt.Errorf("error getting the record: %w", err) 277 248 } 278 - if xErr.StatusCode != 400 { // 400 means not found in this API 279 - return nil, fmt.Errorf("could not get record: %w", err) 249 + if xErr.StatusCode == 400 { // 400 means not found in this API, which would be the case if the record does not exist yet 250 + return nil, nil 280 251 } 281 - return nil, nil 252 + 253 + return nil, fmt.Errorf("error getting the record: %w", err) 254 + 282 255 } 283 - return getOutput.Cid, nil 256 + return result.Cid, nil 284 257 }
+9 -76
service/spotify/spotify.go
··· 16 16 17 17 "context" // Added for context.Context 18 18 19 - "github.com/bluesky-social/indigo/api/atproto" // Added for atproto.RepoCreateRecord_Input 20 - lexutil "github.com/bluesky-social/indigo/lex/util" // Added for lexutil.LexiconTypeDecoder 21 - "github.com/bluesky-social/indigo/xrpc" // Added for xrpc.Client 22 - "github.com/spf13/viper" 23 - "github.com/teal-fm/piper/api/teal" // Added for teal.AlphaFeedPlay 19 + // Added for atproto.RepoCreateRecord_Input 20 + // Added for lexutil.LexiconTypeDecoder 21 + // Added for xrpc.Client 22 + "github.com/spf13/viper" // Added for teal.AlphaFeedPlay 24 23 "github.com/teal-fm/piper/db" 25 24 "github.com/teal-fm/piper/models" 26 25 atprotoauth "github.com/teal-fm/piper/oauth/atproto" 26 + atprotoservice "github.com/teal-fm/piper/service/atproto" 27 27 "github.com/teal-fm/piper/service/musicbrainz" 28 28 "github.com/teal-fm/piper/session" 29 29 ) ··· 59 59 } 60 60 } 61 61 62 - func (s *SpotifyService) SubmitTrackToPDS(did string, track *models.Track, ctx context.Context) error { 63 - client, err := s.atprotoService.GetATProtoClient() 64 - if err != nil || client == nil { 65 - s.logger.Printf("Error getting ATProto client: %v", err) 66 - return fmt.Errorf("failed to get ATProto client: %w", err) 67 - } 68 - 69 - xrpcClient := s.atprotoService.GetXrpcClient() 70 - if xrpcClient == nil { 71 - return errors.New("xrpc client is not available") 72 - } 73 - 74 - sess, err := s.DB.GetAtprotoSession(did, ctx, *client) 75 - if err != nil { 76 - return fmt.Errorf("couldn't get Atproto session for DID %s: %w", did, err) 77 - } 78 - 79 - artists := make([]*teal.AlphaFeedDefs_Artist, 0, len(track.Artist)) 80 - for _, a := range track.Artist { 81 - artist := &teal.AlphaFeedDefs_Artist{ 82 - ArtistName: a.Name, 83 - ArtistMbId: a.MBID, 84 - } 85 - artists = append(artists, artist) 86 - } 87 - 88 - var durationPtr *int64 89 - if track.DurationMs > 0 { 90 - durationSeconds := track.DurationMs / 1000 91 - durationPtr = &durationSeconds 92 - } 93 - 94 - playedTimeStr := track.Timestamp.Format(time.RFC3339) 95 - submissionAgent := viper.GetString("app.submission_agent") 96 - if submissionAgent == "" { 97 - submissionAgent = "piper/v0.0.1" // Default if not configured 98 - } 99 - 62 + func (s *SpotifyService) SubmitTrackToPDS(did string, mostRecentAtProtoSessionID string, track *models.Track, ctx context.Context) error { 100 63 //Had a empty feed.play get submitted not sure why. Tracking here 101 64 if track.Name == "" { 102 65 s.logger.Println("Track name is empty. Skipping submission. Please record the logs before and send to the teal.fm Discord") 103 66 return nil 104 67 } 105 68 106 - tfmTrack := teal.AlphaFeedPlay{ 107 - LexiconTypeID: "fm.teal.alpha.feed.play", 108 - Duration: durationPtr, 109 - TrackName: track.Name, 110 - PlayedTime: &playedTimeStr, 111 - Artists: artists, 112 - ReleaseMbId: track.ReleaseMBID, 113 - ReleaseName: &track.Album, 114 - RecordingMbId: track.RecordingMBID, 115 - // Optional: Spotify specific data if your lexicon supports it 116 - // SpotifyTrackID: &track.ServiceID, 117 - // SpotifyAlbumID: &track.ServiceAlbumID, 118 - // SpotifyArtistIDs: track.ServiceArtistIDs, // Assuming this is a []string 119 - SubmissionClientAgent: &submissionAgent, 120 - } 121 - 122 - input := atproto.RepoCreateRecord_Input{ 123 - Collection: "fm.teal.alpha.feed.play", // Ensure this collection is correct 124 - Repo: sess.DID, 125 - Record: &lexutil.LexiconTypeDecoder{Val: &tfmTrack}, 126 - } 127 - 128 - authArgs := db.AtpSessionToAuthArgs(sess) 129 - 130 - var out atproto.RepoCreateRecord_Output 131 - if err := xrpcClient.Do(ctx, authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.createRecord", nil, input, &out); err != nil { 132 - s.logger.Printf("Error creating record for DID %s: %v. Input: %+v", did, err, input) 133 - return fmt.Errorf("failed to create record on PDS for DID %s: %w", did, err) 134 - } 135 - 136 - s.logger.Printf("Successfully submitted track '%s' to PDS for DID %s. Record URI: %s", track.Name, did, out.Uri) 137 - return nil 69 + // Use shared atproto service for submission 70 + return atprotoservice.SubmitPlayToPDS(ctx, did, mostRecentAtProtoSessionID, track, s.atprotoService) 138 71 } 139 72 140 73 func (s *SpotifyService) SetAccessToken(token string, refreshToken string, userId int64, hasSession bool) (int64, error) { ··· 724 657 725 658 s.logger.Printf("User %d (%d): Attempting to submit track '%s' by %s to PDS (DID: %s)", userID, dbUser.ATProtoDID, trackToSubmitToPDS.Name, artistName, *dbUser.ATProtoDID) 726 659 // Use context.Background() for now, or pass down a context if available 727 - if errPDS := s.SubmitTrackToPDS(*dbUser.ATProtoDID, trackToSubmitToPDS, context.Background()); errPDS != nil { 660 + if errPDS := s.SubmitTrackToPDS(*dbUser.ATProtoDID, *dbUser.MostRecentAtProtoSessionID, trackToSubmitToPDS, context.Background()); errPDS != nil { 728 661 s.logger.Printf("User %d (%d): Error submitting track '%s' to PDS: %v", userID, dbUser.ATProtoDID, trackToSubmitToPDS.Name, errPDS) 729 662 } else { 730 663 s.logger.Printf("User %d (%d): Successfully submitted track '%s' to PDS.", userID, dbUser.ATProtoDID, trackToSubmitToPDS.Name)
+22 -29
session/session.go
··· 17 17 18 18 // session/session.go 19 19 type Session struct { 20 - ID string 21 - UserID int64 22 - ATprotoDID string 23 - ATprotoAccessToken string 24 - ATprotoRefreshToken string 25 - CreatedAt time.Time 26 - ExpiresAt time.Time 20 + 21 + //need to re work this. May add onto it for atproto oauth. But need to be careful about that expiresd 22 + //Maybe a speerate oauth session store table and it has a created date? yeah do that then can look it up by session id from this table for user actions 23 + 24 + ID string 25 + UserID int64 26 + ATProtoSessionID string 27 + CreatedAt time.Time 28 + ExpiresAt time.Time 27 29 } 28 30 29 31 type SessionManager struct { ··· 38 40 _, err := database.Exec(` 39 41 CREATE TABLE IF NOT EXISTS sessions ( 40 42 id TEXT PRIMARY KEY, 41 - user_id INTEGER NOT NULL, 43 + user_id INTEGER NOT NULL, 44 + at_proto_session_id TEXT NOT NULL, 42 45 created_at TIMESTAMP, 43 46 expires_at TIMESTAMP, 44 47 FOREIGN KEY (user_id) REFERENCES users(id) ··· 58 61 } 59 62 60 63 // create a new session for a user 61 - func (sm *SessionManager) CreateSession(userID int64) *Session { 64 + func (sm *SessionManager) CreateSession(userID int64, atProtoSessionId string) *Session { 62 65 sm.mu.Lock() 63 66 defer sm.mu.Unlock() 64 67 ··· 71 74 expiresAt := now.Add(24 * time.Hour) // 24-hour session 72 75 73 76 session := &Session{ 74 - ID: sessionID, 75 - UserID: userID, 76 - CreatedAt: now, 77 - ExpiresAt: expiresAt, 77 + ID: sessionID, 78 + UserID: userID, 79 + ATProtoSessionID: atProtoSessionId, 80 + CreatedAt: now, 81 + ExpiresAt: expiresAt, 78 82 } 79 83 80 84 // store session in memory ··· 83 87 // store session in database if available 84 88 if sm.db != nil { 85 89 _, err := sm.db.Exec(` 86 - INSERT INTO sessions (id, user_id, created_at, expires_at) 87 - VALUES (?, ?, ?, ?)`, 88 - sessionID, userID, now, expiresAt) 90 + INSERT INTO sessions (id, user_id, at_proto_session_id, created_at, expires_at) 91 + VALUES (?, ?, ?, ?, ?)`, 92 + sessionID, userID, atProtoSessionId, now, expiresAt) 89 93 90 94 if err != nil { 91 95 log.Printf("Error storing session in database: %v", err) ··· 116 120 session = &Session{ID: sessionID} 117 121 118 122 err := sm.db.QueryRow(` 119 - SELECT user_id, created_at, expires_at 123 + SELECT user_id, at_proto_session_id, created_at, expires_at 120 124 FROM sessions WHERE id = ?`, sessionID).Scan( 121 - &session.UserID, &session.CreatedAt, &session.ExpiresAt) 125 + &session.UserID, &session.ATProtoSessionID, &session.CreatedAt, &session.ExpiresAt) 122 126 123 127 if err != nil { 124 128 return nil, false ··· 178 182 MaxAge: -1, 179 183 } 180 184 http.SetCookie(w, cookie) 181 - } 182 - 183 - func (sm *SessionManager) HandleLogout(w http.ResponseWriter, r *http.Request) { 184 - cookie, err := r.Cookie("session") 185 - if err == nil { 186 - sm.DeleteSession(cookie.Value) 187 - } 188 - 189 - sm.ClearSessionCookie(w) 190 - 191 - http.Redirect(w, r, "/", http.StatusSeeOther) 192 185 } 193 186 194 187 func (sm *SessionManager) GetAPIKeyManager() *apikey.ApiKeyManager {