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

Compare changes

Choose any two refs to compare.

+2 -2
.air.toml
··· 14 14 follow_symlink = false 15 15 full_bin = "" 16 16 include_dir = [] 17 - include_ext = ["go", "tpl", "tmpl", "html", "gohtml"] 17 + include_ext = ["go", "tpl", "tmpl", "html", "gohtml", "css", "js"] 18 18 include_file = [] 19 19 kill_delay = "0s" 20 20 log = "build-errors.log" ··· 48 48 proxy_port = 0 49 49 50 50 [screen] 51 - clear_on_rebuild = false 51 + clear_on_rebuild = true 52 52 keep_scroll = true
+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=
+12 -3
Dockerfile
··· 1 + FROM --platform=${BUILDPLATFORM:-linux/amd64} node:24-alpine3.21 as node_builder 2 + WORKDIR /app 3 + RUN npm install tailwindcss @tailwindcss/cli 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 9 + 1 10 FROM --platform=${BUILDPLATFORM:-linux/amd64} golang:1.24.3-alpine3.21 as builder 2 11 3 12 ARG TARGETPLATFORM ··· 17 26 # step 2. build the actual app 18 27 WORKDIR /app 19 28 COPY . . 20 - #generate the jwks 21 - 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 22 32 RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags='-w -s -extldflags "-static"' -o main ./cmd 23 33 ARG TARGETOS=${TARGETPLATFORM%%/*} 24 34 ARG TARGETARCH=${TARGETPLATFORM##*/} ··· 28 38 WORKDIR /db 29 39 WORKDIR /app 30 40 COPY --from=builder /app/main /app/main 31 - COPY --from=builder /app/jwks.json /app/jwks.json 32 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
+22 -4
README.md
··· 9 9 10 10 well its just a work in progress... we build in the open! 11 11 12 - ## Setup 12 + ## setup 13 13 It is recommend to have port forward url while working with piper. Development or running from docker because of external callbacks. 14 14 15 15 You have a couple of options ··· 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` ··· 44 52 45 53 46 54 47 - #### development 55 + ## development 48 56 49 57 make sure you have your env setup following [the env var setup](#env-variables) 50 58 ··· 53 61 run some make scripts: 54 62 55 63 ``` 56 - make jwtgen 57 64 58 65 make dev-setup 59 66 ``` ··· 69 76 ``` 70 77 air 71 78 ``` 79 + air should automatically build and run piper, and watch for changes on relevant files. 80 + 81 + 82 + ## tailwindcss 83 + 84 + To use tailwindcss you will have to install the tailwindcss cli. This will take the [./pages/static/base.css](./pages/static/base.css) and transform it into a [./pages/static/main.css](./pages/static/main.css) 85 + which is imported on the [./pages/templates/layouts/base.gohtml](./pages/templates/layouts/base.gohtml). When running the dev server tailwindcss will watch for changes and recompile the main.css file. 72 86 73 - air should automatically build and run piper, and watch for changes on relevant files. 87 + 1. Install tailwindcss cli `npm install tailwindcss @tailwindcss/cli` 88 + 2. run `npx @tailwindcss/cli -i ./pages/static/base.css -o ./pages/static/main.css --watch` 89 + 90 + 91 + 74 92 75 93 #### Lexicon changes 76 94 1. Copy the new or changed json schema files to the [lexicon folders](./lexicons)
+68 -40
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 ) 17 20 18 21 type HomeParams struct { 19 - IsLoggedIn bool 20 - LastFMUsername *string 22 + NavBar pages.NavBar 21 23 } 22 24 23 - func home(database *db.DB, pages *pages.Pages) http.HandlerFunc { 25 + func home(database *db.DB, pg *pages.Pages) http.HandlerFunc { 24 26 return func(w http.ResponseWriter, r *http.Request) { 25 27 26 28 w.Header().Set("Content-Type", "text/html") ··· 39 41 } 40 42 } 41 43 params := HomeParams{ 42 - IsLoggedIn: isLoggedIn, 43 - LastFMUsername: &lastfmUsername, 44 + NavBar: pages.NavBar{ 45 + IsLoggedIn: isLoggedIn, 46 + LastFMUsername: lastfmUsername, 47 + }, 44 48 } 45 - err := pages.Execute("home", w, params) 49 + err := pg.Execute("home", w, params) 46 50 if err != nil { 47 51 log.Printf("Error executing template: %v", err) 48 52 } 49 53 } 50 54 } 51 55 52 - func handleLinkLastfmForm(database *db.DB) http.HandlerFunc { 56 + func handleLinkLastfmForm(database *db.DB, pg *pages.Pages) http.HandlerFunc { 53 57 return func(w http.ResponseWriter, r *http.Request) { 54 - userID, _ := session.GetUserID(r.Context()) 58 + userID, authenticated := session.GetUserID(r.Context()) 55 59 if r.Method == http.MethodPost { 56 60 if err := r.ParseForm(); err != nil { 57 61 http.Error(w, "Failed to parse form", http.StatusBadRequest) ··· 86 90 } 87 91 88 92 w.Header().Set("Content-Type", "text/html") 89 - fmt.Fprintf(w, ` 90 - <html> 91 - <head><title>Link Last.fm Account</title> 92 - <style> 93 - body { font-family: Arial, sans-serif; max-width: 600px; margin: 20px auto; padding: 20px; border: 1px solid #ddd; border-radius: 8px; } 94 - label, input { display: block; margin-bottom: 10px; } 95 - input[type='text'] { width: 95%%; padding: 8px; } /* Corrected width */ 96 - input[type='submit'] { padding: 10px 15px; background-color: #d51007; color: white; border: none; border-radius: 4px; cursor: pointer; } 97 - .nav { margin-bottom: 20px; } 98 - .nav a { margin-right: 10px; text-decoration: none; color: #1DB954; font-weight: bold; } 99 - .error { color: red; margin-bottom: 10px; } 100 - </style> 101 - </head> 102 - <body> 103 - <div class="nav"> 104 - <a href="/">Home</a> 105 - <a href="/link-lastfm">Link Last.fm</a> 106 - <a href="/logout">Logout</a> 107 - </div> 108 - <h2>Link Your Last.fm Account</h2> 109 - <p>Enter your Last.fm username to start tracking your scrobbles.</p> 110 - <form method="post" action="/link-lastfm"> 111 - <label for="lastfm_username">Last.fm Username:</label> 112 - <input type="text" id="lastfm_username" name="lastfm_username" value="%s" required> 113 - <input type="submit" value="Save Username"> 114 - </form> 115 - </body> 116 - </html>`, currentUsername) 93 + 94 + pageParams := struct { 95 + NavBar pages.NavBar 96 + CurrentUsername string 97 + }{ 98 + NavBar: pages.NavBar{ 99 + IsLoggedIn: authenticated, 100 + LastFMUsername: currentUsername, 101 + }, 102 + CurrentUsername: currentUsername, 103 + } 104 + err = pg.Execute("lastFMForm", w, pageParams) 105 + if err != nil { 106 + log.Printf("Error executing template: %v", err) 107 + } 117 108 } 118 109 } 119 110 ··· 199 190 200 191 func apiMusicBrainzSearch(mbService *musicbrainz.MusicBrainzService) http.HandlerFunc { 201 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 + } 202 197 203 198 params := musicbrainz.SearchParams{ 204 199 Track: r.URL.Query().Get("track"), ··· 330 325 } 331 326 332 327 // apiSubmitListensHandler handles ListenBrainz-compatible submissions 333 - func apiSubmitListensHandler(database *db.DB) http.HandlerFunc { 328 + func apiSubmitListensHandler(database *db.DB, atprotoService *atprotoauth.ATprotoAuthService, playingNowService *playingnow.PlayingNowService, mbService *musicbrainz.MusicBrainzService) http.HandlerFunc { 334 329 return func(w http.ResponseWriter, r *http.Request) { 335 330 userID, authenticated := session.GetUserID(r.Context()) 336 331 if !authenticated { ··· 370 365 return 371 366 } 372 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 + 373 376 // Process each listen in the payload 374 377 var processedTracks []models.Track 375 378 var errors []string ··· 388 391 // Convert to internal Track format 389 392 track := listen.ConvertToTrack(userID) 390 393 391 - // For 'playing_now' type, we might want to handle differently 392 - // 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 393 407 if submission.ListenType == "playing_now" { 394 408 log.Printf("Received playing_now listen for user %d: %s - %s", userID, track.Artist[0].Name, track.Name) 395 - // 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 + } 396 416 continue 397 417 } 398 418 ··· 401 421 log.Printf("apiSubmitListensHandler: Error saving track for user %d: %v", userID, err) 402 422 errors = append(errors, fmt.Sprintf("payload[%d]: failed to save track", i)) 403 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 + } 404 432 } 405 433 406 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 -14
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"), ··· 116 121 lastfmInterval = 30 * time.Second 117 122 } 118 123 119 - //if err := spotifyService.LoadAllUsers(); err != nil { 120 - // log.Printf("Warning: Failed to preload Spotify users: %v", err) 121 - //} 122 124 go spotifyService.StartListeningTracker(trackerInterval) 123 125 124 126 go lastfmService.StartListeningTracker(lastfmInterval)
+9 -6
cmd/routes.go
··· 11 11 func (app *application) routes() http.Handler { 12 12 mux := http.NewServeMux() 13 13 14 + //Handles static file routes 15 + mux.Handle("/static/{file_name}", app.pages.Static()) 16 + 14 17 mux.HandleFunc("/", session.WithPossibleAuth(home(app.database, app.pages), app.sessionManager)) 15 18 16 19 // OAuth Routes ··· 22 25 // Authenticated Web Routes 23 26 mux.HandleFunc("/current-track", session.WithAuth(app.spotifyService.HandleCurrentTrack, app.sessionManager)) 24 27 mux.HandleFunc("/history", session.WithAuth(app.spotifyService.HandleTrackHistory, app.sessionManager)) 25 - mux.HandleFunc("/api-keys", session.WithAuth(app.apiKeyService.HandleAPIKeyManagement, app.sessionManager)) 26 - mux.HandleFunc("/link-lastfm", session.WithAuth(handleLinkLastfmForm(app.database), app.sessionManager)) // GET form 27 - mux.HandleFunc("/link-lastfm/submit", session.WithAuth(handleLinkLastfmSubmit(app.database), app.sessionManager)) // POST submit - Changed route slightly 28 - mux.HandleFunc("/logout", app.sessionManager.HandleLogout) 28 + mux.HandleFunc("/api-keys", session.WithAuth(app.apiKeyService.HandleAPIKeyManagement(app.database, app.pages), app.sessionManager)) 29 + mux.HandleFunc("/link-lastfm", session.WithAuth(handleLinkLastfmForm(app.database, app.pages), app.sessionManager)) // GET form 30 + mux.HandleFunc("/link-lastfm/submit", session.WithAuth(handleLinkLastfmSubmit(app.database), app.sessionManager)) // POST submit - Changed route slightly 31 + mux.HandleFunc("/logout", app.oauthManager.HandleLogout("atproto")) 29 32 mux.HandleFunc("/debug/", session.WithAuth(app.sessionManager.HandleDebug, app.sessionManager)) 30 33 31 34 mux.HandleFunc("/api/v1/me", session.WithAPIAuth(apiMeHandler(app.database), app.sessionManager)) ··· 37 40 mux.HandleFunc("/api/v1/musicbrainz/search", apiMusicBrainzSearch(app.mbService)) // MusicBrainz (public?) 38 41 39 42 // ListenBrainz-compatible endpoint 40 - 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)) 41 44 42 45 serverUrlRoot := viper.GetString("server.root_url") 43 46 atpClientId := viper.GetString("atproto.client_id") 44 47 atpCallbackUrl := viper.GetString("atproto.callback_url") 45 - 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) { 46 49 app.atprotoService.HandleClientMetadata(w, r, serverUrlRoot, atpClientId, atpCallbackUrl) 47 50 }) 48 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
+31 -11
pages/pages.go
··· 1 1 package pages 2 2 3 - // inspired from tangled's implementation 3 + // Helpers to load gohtml templates and render them 4 + // forked and inspired from tangled's implementation 4 5 //https://tangled.org/@tangled.org/core/blob/master/appview/pages/pages.go 5 6 6 7 import ( ··· 8 9 "html/template" 9 10 "io" 10 11 "io/fs" 12 + "net/http" 11 13 "strings" 12 14 "time" 13 15 ) 14 16 15 - //go:embed templates/* 17 + //go:embed templates/* static/* 16 18 var Files embed.FS 17 19 18 20 type Pages struct { ··· 30 32 31 33 func (p *Pages) fragmentPaths() ([]string, error) { 32 34 var fragmentPaths []string 33 - // When using os.DirFS("templates"), the FS root is already the templates directory. 34 - // Walk from "." and use relative paths (no "templates/" prefix). 35 35 err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 36 36 if err != nil { 37 37 return err ··· 42 42 if !strings.HasSuffix(path, ".gohtml") { 43 43 return nil 44 44 } 45 - //if !strings.Contains(path, "fragments/") { 46 - // return nil 47 - //} 48 45 fragmentPaths = append(fragmentPaths, path) 49 46 return nil 50 47 }) ··· 121 118 return p.parse(stack...) 122 119 } 123 120 124 - func (p *Pages) executePlain(name string, w io.Writer, params any) error { 125 - tpl, err := p.parse(name) 121 + func (p *Pages) Static() http.Handler { 122 + 123 + sub, err := fs.Sub(Files, "static") 126 124 if err != nil { 127 - return err 125 + panic(err) 128 126 } 129 127 130 - return tpl.Execute(w, params) 128 + return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) 131 129 } 132 130 131 + func Cache(h http.Handler) http.Handler { 132 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 133 + path := strings.Split(r.URL.Path, "?")[0] 134 + // We may want to change these, just took what tangled has and allows browser side caching 135 + if strings.HasSuffix(path, ".css") { 136 + // on day for css files 137 + w.Header().Set("Cache-Control", "public, max-age=86400") 138 + } else { 139 + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") 140 + } 141 + h.ServeHTTP(w, r) 142 + }) 143 + } 144 + 145 + // Execute What loads and renders the HTML page/ 133 146 func (p *Pages) Execute(name string, w io.Writer, params any) error { 134 147 tpl, err := p.parseBase(name) 135 148 if err != nil { ··· 138 151 139 152 return tpl.ExecuteTemplate(w, "layouts/base", params) 140 153 } 154 + 155 + // Shared view/template params 156 + 157 + type NavBar struct { 158 + IsLoggedIn bool 159 + LastFMUsername string 160 + }
+1
pages/static/base.css
··· 1 + @import "tailwindcss";
+531
pages/static/main.css
··· 1 + /*! tailwindcss v4.1.13 | MIT License | https://tailwindcss.com */ 2 + @layer properties; 3 + @layer theme, base, components, utilities; 4 + @layer theme { 5 + :root, :host { 6 + --font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", 7 + "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 8 + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", 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; 24 + --default-font-family: var(--font-sans); 25 + --default-mono-font-family: var(--font-mono); 26 + } 27 + } 28 + @layer base { 29 + *, ::after, ::before, ::backdrop, ::file-selector-button { 30 + box-sizing: border-box; 31 + margin: 0; 32 + padding: 0; 33 + border: 0 solid; 34 + } 35 + html, :host { 36 + line-height: 1.5; 37 + -webkit-text-size-adjust: 100%; 38 + tab-size: 4; 39 + font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); 40 + font-feature-settings: var(--default-font-feature-settings, normal); 41 + font-variation-settings: var(--default-font-variation-settings, normal); 42 + -webkit-tap-highlight-color: transparent; 43 + } 44 + hr { 45 + height: 0; 46 + color: inherit; 47 + border-top-width: 1px; 48 + } 49 + abbr:where([title]) { 50 + -webkit-text-decoration: underline dotted; 51 + text-decoration: underline dotted; 52 + } 53 + h1, h2, h3, h4, h5, h6 { 54 + font-size: inherit; 55 + font-weight: inherit; 56 + } 57 + a { 58 + color: inherit; 59 + -webkit-text-decoration: inherit; 60 + text-decoration: inherit; 61 + } 62 + b, strong { 63 + font-weight: bolder; 64 + } 65 + code, kbd, samp, pre { 66 + font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); 67 + font-feature-settings: var(--default-mono-font-feature-settings, normal); 68 + font-variation-settings: var(--default-mono-font-variation-settings, normal); 69 + font-size: 1em; 70 + } 71 + small { 72 + font-size: 80%; 73 + } 74 + sub, sup { 75 + font-size: 75%; 76 + line-height: 0; 77 + position: relative; 78 + vertical-align: baseline; 79 + } 80 + sub { 81 + bottom: -0.25em; 82 + } 83 + sup { 84 + top: -0.5em; 85 + } 86 + table { 87 + text-indent: 0; 88 + border-color: inherit; 89 + border-collapse: collapse; 90 + } 91 + :-moz-focusring { 92 + outline: auto; 93 + } 94 + progress { 95 + vertical-align: baseline; 96 + } 97 + summary { 98 + display: list-item; 99 + } 100 + ol, ul, menu { 101 + list-style: none; 102 + } 103 + img, svg, video, canvas, audio, iframe, embed, object { 104 + display: block; 105 + vertical-align: middle; 106 + } 107 + img, video { 108 + max-width: 100%; 109 + height: auto; 110 + } 111 + button, input, select, optgroup, textarea, ::file-selector-button { 112 + font: inherit; 113 + font-feature-settings: inherit; 114 + font-variation-settings: inherit; 115 + letter-spacing: inherit; 116 + color: inherit; 117 + border-radius: 0; 118 + background-color: transparent; 119 + opacity: 1; 120 + } 121 + :where(select:is([multiple], [size])) optgroup { 122 + font-weight: bolder; 123 + } 124 + :where(select:is([multiple], [size])) optgroup option { 125 + padding-inline-start: 20px; 126 + } 127 + ::file-selector-button { 128 + margin-inline-end: 4px; 129 + } 130 + ::placeholder { 131 + opacity: 1; 132 + } 133 + @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { 134 + ::placeholder { 135 + color: currentcolor; 136 + @supports (color: color-mix(in lab, red, red)) { 137 + color: color-mix(in oklab, currentcolor 50%, transparent); 138 + } 139 + } 140 + } 141 + textarea { 142 + resize: vertical; 143 + } 144 + ::-webkit-search-decoration { 145 + -webkit-appearance: none; 146 + } 147 + ::-webkit-date-and-time-value { 148 + min-height: 1lh; 149 + text-align: inherit; 150 + } 151 + ::-webkit-datetime-edit { 152 + display: inline-flex; 153 + } 154 + ::-webkit-datetime-edit-fields-wrapper { 155 + padding: 0; 156 + } 157 + ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { 158 + padding-block: 0; 159 + } 160 + ::-webkit-calendar-picker-indicator { 161 + line-height: 1; 162 + } 163 + :-moz-ui-invalid { 164 + box-shadow: none; 165 + } 166 + button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button { 167 + appearance: button; 168 + } 169 + ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { 170 + height: auto; 171 + } 172 + [hidden]:where(:not([hidden="until-found"])) { 173 + display: none !important; 174 + } 175 + } 176 + @layer utilities { 177 + .absolute { 178 + position: absolute; 179 + } 180 + .relative { 181 + position: relative; 182 + } 183 + .static { 184 + position: static; 185 + } 186 + .sticky { 187 + position: sticky; 188 + } 189 + .container { 190 + width: 100%; 191 + @media (width >= 40rem) { 192 + max-width: 40rem; 193 + } 194 + @media (width >= 48rem) { 195 + max-width: 48rem; 196 + } 197 + @media (width >= 64rem) { 198 + max-width: 64rem; 199 + } 200 + @media (width >= 80rem) { 201 + max-width: 80rem; 202 + } 203 + @media (width >= 96rem) { 204 + max-width: 96rem; 205 + } 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 + } 234 + .block { 235 + display: block; 236 + } 237 + .contents { 238 + display: contents; 239 + } 240 + .flex { 241 + display: flex; 242 + } 243 + .hidden { 244 + display: none; 245 + } 246 + .table { 247 + display: table; 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 + } 264 + .transform { 265 + transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); 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 + } 393 + .lowercase { 394 + text-transform: lowercase; 395 + } 396 + .italic { 397 + font-style: italic; 398 + } 399 + .no-underline { 400 + text-decoration-line: none; 401 + } 402 + .filter { 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,); 404 + } 405 + .hover\:opacity-90 { 406 + &:hover { 407 + @media (hover: hover) { 408 + opacity: 90%; 409 + } 410 + } 411 + } 412 + } 413 + @property --tw-rotate-x { 414 + syntax: "*"; 415 + inherits: false; 416 + } 417 + @property --tw-rotate-y { 418 + syntax: "*"; 419 + inherits: false; 420 + } 421 + @property --tw-rotate-z { 422 + syntax: "*"; 423 + inherits: false; 424 + } 425 + @property --tw-skew-x { 426 + syntax: "*"; 427 + inherits: false; 428 + } 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 { 448 + syntax: "*"; 449 + inherits: false; 450 + } 451 + @property --tw-blur { 452 + syntax: "*"; 453 + inherits: false; 454 + } 455 + @property --tw-brightness { 456 + syntax: "*"; 457 + inherits: false; 458 + } 459 + @property --tw-contrast { 460 + syntax: "*"; 461 + inherits: false; 462 + } 463 + @property --tw-grayscale { 464 + syntax: "*"; 465 + inherits: false; 466 + } 467 + @property --tw-hue-rotate { 468 + syntax: "*"; 469 + inherits: false; 470 + } 471 + @property --tw-invert { 472 + syntax: "*"; 473 + inherits: false; 474 + } 475 + @property --tw-opacity { 476 + syntax: "*"; 477 + inherits: false; 478 + } 479 + @property --tw-saturate { 480 + syntax: "*"; 481 + inherits: false; 482 + } 483 + @property --tw-sepia { 484 + syntax: "*"; 485 + inherits: false; 486 + } 487 + @property --tw-drop-shadow { 488 + syntax: "*"; 489 + inherits: false; 490 + } 491 + @property --tw-drop-shadow-color { 492 + syntax: "*"; 493 + inherits: false; 494 + } 495 + @property --tw-drop-shadow-alpha { 496 + syntax: "<percentage>"; 497 + inherits: false; 498 + initial-value: 100%; 499 + } 500 + @property --tw-drop-shadow-size { 501 + syntax: "*"; 502 + inherits: false; 503 + } 504 + @layer properties { 505 + @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { 506 + *, ::before, ::after, ::backdrop { 507 + --tw-rotate-x: initial; 508 + --tw-rotate-y: initial; 509 + --tw-rotate-z: initial; 510 + --tw-skew-x: initial; 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; 516 + --tw-blur: initial; 517 + --tw-brightness: initial; 518 + --tw-contrast: initial; 519 + --tw-grayscale: initial; 520 + --tw-hue-rotate: initial; 521 + --tw-invert: initial; 522 + --tw-opacity: initial; 523 + --tw-saturate: initial; 524 + --tw-sepia: initial; 525 + --tw-drop-shadow: initial; 526 + --tw-drop-shadow-color: initial; 527 + --tw-drop-shadow-alpha: 100%; 528 + --tw-drop-shadow-size: initial; 529 + } 530 + } 531 + }
+92
pages/templates/apiKeys.gohtml
··· 1 + 2 + {{ define "content" }} 3 + 4 + {{ template "components/navBar" .NavBar }} 5 + 6 + 7 + <h1 class="text-[#1DB954]">API Key Management</h1> 8 + 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 + <form method="POST" action="/api-keys"> 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 + </div> 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 + </form> 19 + </div> 20 + 21 + {{if .NewKeyID}} <!-- Changed from .NewKey to .NewKeyID for clarity --> 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 + <!-- The message below is misleading if only the ID is shown. 25 + Consider changing this text or modifying the flow to show the actual key once for HTML. --> 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 + </div> 28 + {{end}} 29 + 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 + {{if .Keys}} 33 + <table class="w-full border-collapse"> 34 + <thead> 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 + </tr> 42 + </thead> 43 + <tbody> 44 + {{range .Keys}} 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 + </td> 53 + </tr> 54 + {{end}} 55 + </tbody> 56 + </table> 57 + {{else}} 58 + <p>You don't have any API keys yet.</p> 59 + {{end}} 60 + </div> 61 + 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 + </div> 69 + 70 + <script> 71 + function deleteKey(keyId) { 72 + if (confirm('Are you sure you want to delete this API key? This action cannot be undone.')) { 73 + fetch('/api-keys?key_id=' + keyId, { // This endpoint is handled by HandleAPIKeyManagement 74 + method: 'DELETE', 75 + }) 76 + .then(response => response.json()) 77 + .then(data => { 78 + if (data.success) { 79 + window.location.reload(); 80 + } else { 81 + alert('Failed to delete API key: ' + (data.error || 'Unknown error')); 82 + } 83 + }) 84 + .catch(error => { 85 + console.error('Error:', error); 86 + alert('Failed to delete API key due to a network or processing error.'); 87 + }); 88 + } 89 + } 90 + </script> 91 + 92 + {{ end }}
+20
pages/templates/components/navBar.gohtml
··· 1 + {{ define "components/navBar" }} 2 + 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 + 6 + {{if .IsLoggedIn}} 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 + {{ if .LastFMUsername }} 11 + <a class="text-[#1DB954] font-bold no-underline" href="/lastfm/recent">Last.fm Recent</a> 12 + {{ end }} 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 + {{ else }} 17 + <a class="text-[#1DB954] font-bold no-underline" href="/login/atproto">Login with ATProto</a> 18 + {{ end }} 19 + </nav> 20 + {{ end }}
+24 -39
pages/templates/home.gohtml
··· 1 1 2 2 {{ define "content" }} 3 3 4 - <h1>Piper - Multi-User Spotify & Last.fm Tracker via ATProto</h1> 5 - <div class="nav"> 6 - <a href="/">Home</a> 4 + <h1 class="text-[#1DB954]">Piper - Multi-User Spotify & Last.fm Tracker via ATProto</h1> 5 + {{ template "components/navBar" .NavBar }} 7 6 8 - {{if .IsLoggedIn}} 9 - <a href="/current-track">Spotify Current</a> 10 - <a href="/history">Spotify History</a> 11 - <a href="/link-lastfm">Link Last.fm</a> 12 - {{ if .LastFMUsername }} 13 - <a href="/lastfm/recent">Last.fm Recent</a> 14 - {{ end }} 15 - <a href="/api-keys">API Keys</a> 16 - <a href="/login/spotify">Connect Spotify Account</a> 17 - <a href="/logout">Logout</a> 18 - {{ else }} 19 - <a href="/login/atproto">Login with ATProto</a> 20 - {{ end }} 21 - </div> 22 7 23 - <div class="card"> 24 - <h2>Welcome to Piper</h2> 25 - <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> 26 11 27 - {{if .IsLoggedIn}} 28 - <p>You're logged in!</p> 29 - <ul> 30 - <li><a href="/login/spotify">Connect your Spotify account</a> to start tracking.</li> 31 - <li><a href="/link-lastfm">Link your Last.fm account</a> to track scrobbles.</li> 12 + {{if .NavBar.IsLoggedIn}} 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> 32 17 </ul> 33 - <p>Once connected, you can check out your:</p> 34 - <ul> 35 - <li><a href="/current-track">Spotify current track</a> or <a href="/history">listening history</a>.</li> 36 - {{ if .LastFMUsername }} 37 - <li><a href="/lastfm/recent">Last.fm recent tracks</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 + {{ if .NavBar.LastFMUsername }} 22 + <li><a class="text-[#1DB954] font-bold" href="/lastfm/recent">Last.fm recent tracks</a>.</li> 38 23 {{ end }} 39 24 40 25 </ul> 41 - <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> 42 27 43 - {{ if .LastFMUsername }} 44 - <p class='service-status'>Last.fm Username: {{ .LastFMUsername }}</p> 28 + {{ if .NavBar.LastFMUsername }} 29 + <p class='italic text-gray-600'>Last.fm Username: {{ .NavBar.LastFMUsername }}</p> 45 30 {{else }} 46 - <p class='service-status'>Last.fm account not linked.</p> 31 + <p class='italic text-gray-600'>Last.fm account not linked.</p> 47 32 {{end}} 48 33 49 34 50 35 {{ else }} 51 36 52 - <p>Login with ATProto to get started!</p> 53 - <form action="/login/atproto"> 54 - <label for="handle">handle:</label> 55 - <input type="text" id="handle" name="handle" > 56 - <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"> 57 42 </form> 58 43 59 44
+14
pages/templates/lastFMForm.gohtml
··· 1 + {{ define "content" }} 2 + {{ template "components/navBar" .NavBar }} 3 + 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 + </form> 12 + </div> 13 + 14 + {{ end }}
+2 -35
pages/templates/layouts/base.gohtml
··· 3 3 <html lang="en"> 4 4 <head> 5 5 <title>Piper - Spotify & Last.fm Tracker</title> 6 - <style> 7 - body { 8 - font-family: Arial, sans-serif; 9 - max-width: 800px; 10 - margin: 0 auto; 11 - padding: 20px; 12 - line-height: 1.6; 13 - } 14 - h1 { 15 - color: #1DB954; /* Spotify green */ 16 - } 17 - .nav { 18 - display: flex; 19 - flex-wrap: wrap; /* Allow wrapping on smaller screens */ 20 - margin-bottom: 20px; 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 - .card { 30 - border: 1px solid #ddd; 31 - border-radius: 8px; 32 - padding: 20px; 33 - margin-bottom: 20px; 34 - } 35 - .service-status { 36 - font-style: italic; 37 - color: #555; 38 - } 39 - </style> 6 + <link rel="stylesheet" href="/static/main.css"> 40 7 </head> 41 - <body> 8 + <body class="font-sans max-w-[800px] mx-auto p-5 leading-relaxed"> 42 9 {{ block "content" . }}{{ end }} 43 10 44 11 </body>
+142 -302
service/apikey/apikey.go
··· 3 3 import ( 4 4 "encoding/json" 5 5 "fmt" 6 - "html/template" 7 6 "log" 8 7 "net/http" 9 8 "time" 10 9 11 10 "github.com/teal-fm/piper/db" 12 11 db_apikey "github.com/teal-fm/piper/db/apikey" // Assuming this is the package for ApiKey struct 12 + "github.com/teal-fm/piper/pages" 13 13 "github.com/teal-fm/piper/session" 14 14 ) 15 15 ··· 41 41 jsonResponse(w, statusCode, map[string]string{"error": message}) 42 42 } 43 43 44 - func (s *Service) HandleAPIKeyManagement(w http.ResponseWriter, r *http.Request) { 45 - userID, ok := session.GetUserID(r.Context()) 46 - if !ok { 47 - // If this is an API request context, it might have already been handled by WithAPIAuth, 48 - // but an extra check or appropriate error for the context is good. 49 - if session.IsAPIRequest(r.Context()) { 50 - jsonError(w, "Unauthorized", http.StatusUnauthorized) 51 - } else { 52 - http.Error(w, "Unauthorized", http.StatusUnauthorized) 44 + func (s *Service) HandleAPIKeyManagement(database *db.DB, pg *pages.Pages) http.HandlerFunc { 45 + return func(w http.ResponseWriter, r *http.Request) { 46 + 47 + userID, ok := session.GetUserID(r.Context()) 48 + if !ok { 49 + // If this is an API request context, it might have already been handled by WithAPIAuth, 50 + // but an extra check or appropriate error for the context is good. 51 + if session.IsAPIRequest(r.Context()) { 52 + jsonError(w, "Unauthorized", http.StatusUnauthorized) 53 + } else { 54 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 55 + } 56 + return 53 57 } 54 - return 55 - } 58 + 59 + lastfmUsername := "" 60 + user, err := database.GetUserByID(userID) 61 + if err == nil && user != nil && user.LastFMUsername != nil { 62 + lastfmUsername = *user.LastFMUsername 63 + } else if err != nil { 64 + log.Printf("Error fetching user %d details for home page: %v", userID, err) 65 + } 66 + isAPI := session.IsAPIRequest(r.Context()) 67 + 68 + if isAPI { // JSON API Handling 69 + switch r.Method { 70 + case http.MethodGet: 71 + keys, err := s.sessions.GetAPIKeyManager().GetUserApiKeys(userID) 72 + if err != nil { 73 + jsonError(w, fmt.Sprintf("Error fetching API keys: %v", err), http.StatusInternalServerError) 74 + return 75 + } 76 + // Ensure keys are safe for listing (e.g., no raw key string) 77 + // GetUserApiKeys should return a slice of db_apikey.ApiKey or similar struct 78 + // that includes ID, Name, KeyPrefix, CreatedAt, ExpiresAt. 79 + jsonResponse(w, http.StatusOK, map[string]any{"api_keys": keys}) 80 + 81 + case http.MethodPost: 82 + var reqBody struct { 83 + Name string `json:"name"` 84 + } 85 + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { 86 + jsonError(w, "Invalid request body: "+err.Error(), http.StatusBadRequest) 87 + return 88 + } 89 + keyName := reqBody.Name 90 + if keyName == "" { 91 + keyName = fmt.Sprintf("API Key (via API) - %s", time.Now().UTC().Format(time.RFC3339)) 92 + } 93 + validityDays := 30 // Default, could be made configurable via request body 94 + 95 + // IMPORTANT: Assumes CreateAPIKeyAndReturnRawKey method exists on SessionManager 96 + // and returns the database object and the raw key string. 97 + // Signature: (apiKey *db_apikey.ApiKey, rawKeyString string, err error) 98 + apiKeyObj, err := s.sessions.CreateAPIKey(userID, keyName, validityDays) 99 + if err != nil { 100 + jsonError(w, fmt.Sprintf("Error creating API key: %v", err), http.StatusInternalServerError) 101 + return 102 + } 103 + 104 + jsonResponse(w, http.StatusCreated, map[string]any{ 105 + "id": apiKeyObj.ID, 106 + "name": apiKeyObj.Name, 107 + "created_at": apiKeyObj.CreatedAt, 108 + "expires_at": apiKeyObj.ExpiresAt, 109 + }) 110 + 111 + case http.MethodDelete: 112 + keyID := r.URL.Query().Get("key_id") 113 + if keyID == "" { 114 + jsonError(w, "Query parameter 'key_id' is required", http.StatusBadRequest) 115 + return 116 + } 56 117 57 - isAPI := session.IsAPIRequest(r.Context()) 118 + key, exists := s.sessions.GetAPIKeyManager().GetApiKey(keyID) 119 + if !exists || key.UserID != userID { 120 + jsonError(w, "API key not found or not owned by user", http.StatusNotFound) 121 + return 122 + } 58 123 59 - if isAPI { // JSON API Handling 60 - switch r.Method { 61 - case http.MethodGet: 62 - keys, err := s.sessions.GetAPIKeyManager().GetUserApiKeys(userID) 63 - if err != nil { 64 - jsonError(w, fmt.Sprintf("Error fetching API keys: %v", err), http.StatusInternalServerError) 65 - return 66 - } 67 - // Ensure keys are safe for listing (e.g., no raw key string) 68 - // GetUserApiKeys should return a slice of db_apikey.ApiKey or similar struct 69 - // that includes ID, Name, KeyPrefix, CreatedAt, ExpiresAt. 70 - jsonResponse(w, http.StatusOK, map[string]any{"api_keys": keys}) 124 + if err := s.sessions.GetAPIKeyManager().DeleteApiKey(keyID); err != nil { 125 + jsonError(w, fmt.Sprintf("Error deleting API key: %v", err), http.StatusInternalServerError) 126 + return 127 + } 128 + jsonResponse(w, http.StatusOK, map[string]string{"message": "API key deleted successfully"}) 71 129 72 - case http.MethodPost: 73 - var reqBody struct { 74 - Name string `json:"name"` 130 + default: 131 + jsonError(w, "Method not allowed", http.StatusMethodNotAllowed) 75 132 } 76 - if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { 77 - jsonError(w, "Invalid request body: "+err.Error(), http.StatusBadRequest) 133 + return // End of JSON API handling 134 + } 135 + 136 + // HTML UI Handling (largely existing logic) 137 + if r.Method == http.MethodPost { // Create key from HTML form 138 + if err := r.ParseForm(); err != nil { 139 + http.Error(w, "Invalid form data", http.StatusBadRequest) 78 140 return 79 141 } 80 - keyName := reqBody.Name 142 + 143 + keyName := r.FormValue("name") 81 144 if keyName == "" { 82 - keyName = fmt.Sprintf("API Key (via API) - %s", time.Now().UTC().Format(time.RFC3339)) 145 + keyName = fmt.Sprintf("API Key - %s", time.Now().UTC().Format(time.RFC3339)) 83 146 } 84 - validityDays := 30 // Default, could be made configurable via request body 147 + validityDays := 1024 85 148 86 - // IMPORTANT: Assumes CreateAPIKeyAndReturnRawKey method exists on SessionManager 87 - // and returns the database object and the raw key string. 88 - // Signature: (apiKey *db_apikey.ApiKey, rawKeyString string, err error) 89 - apiKeyObj, err := s.sessions.CreateAPIKey(userID, keyName, validityDays) 149 + // Uses the existing CreateAPIKey, which likely doesn't return the raw key. 150 + // The HTML flow currently redirects and shows the key ID. 151 + // The template message about "only time you'll see this key" is misleading if it shows ID. 152 + // This might require a separate enhancement if the HTML view should show the raw key. 153 + apiKey, err := s.sessions.CreateAPIKey(userID, keyName, validityDays) 90 154 if err != nil { 91 - jsonError(w, fmt.Sprintf("Error creating API key: %v", err), http.StatusInternalServerError) 155 + http.Error(w, fmt.Sprintf("Error creating API key: %v", err), http.StatusInternalServerError) 92 156 return 93 157 } 94 - 95 - jsonResponse(w, http.StatusCreated, map[string]any{ 96 - "id": apiKeyObj.ID, 97 - "name": apiKeyObj.Name, 98 - "created_at": apiKeyObj.CreatedAt, 99 - "expires_at": apiKeyObj.ExpiresAt, 100 - }) 158 + // Redirects, passing the ID of the created key. 159 + // The template shows this ID in the ".NewKey" section. 160 + http.Redirect(w, r, "/api-keys?created="+apiKey.ID, http.StatusSeeOther) 161 + return 162 + } 101 163 102 - case http.MethodDelete: 164 + if r.Method == http.MethodDelete { // Delete key via AJAX from HTML page 103 165 keyID := r.URL.Query().Get("key_id") 104 166 if keyID == "" { 105 - jsonError(w, "Query parameter 'key_id' is required", http.StatusBadRequest) 167 + // For AJAX, a JSON error response is more appropriate than http.Error 168 + jsonError(w, "Key ID is required", http.StatusBadRequest) 106 169 return 107 170 } 108 171 109 172 key, exists := s.sessions.GetAPIKeyManager().GetApiKey(keyID) 110 173 if !exists || key.UserID != userID { 111 - jsonError(w, "API key not found or not owned by user", http.StatusNotFound) 174 + jsonError(w, "Invalid API key or not owned by user", http.StatusBadRequest) // StatusNotFound or StatusForbidden 112 175 return 113 176 } 114 177 ··· 116 179 jsonError(w, fmt.Sprintf("Error deleting API key: %v", err), http.StatusInternalServerError) 117 180 return 118 181 } 119 - jsonResponse(w, http.StatusOK, map[string]string{"message": "API key deleted successfully"}) 120 - 121 - default: 122 - jsonError(w, "Method not allowed", http.StatusMethodNotAllowed) 123 - } 124 - return // End of JSON API handling 125 - } 126 - 127 - // HTML UI Handling (largely existing logic) 128 - if r.Method == http.MethodPost { // Create key from HTML form 129 - if err := r.ParseForm(); err != nil { 130 - http.Error(w, "Invalid form data", http.StatusBadRequest) 182 + // AJAX client expects JSON 183 + jsonResponse(w, http.StatusOK, map[string]any{"success": true}) 131 184 return 132 185 } 133 186 134 - keyName := r.FormValue("name") 135 - if keyName == "" { 136 - keyName = fmt.Sprintf("API Key - %s", time.Now().UTC().Format(time.RFC3339)) 137 - } 138 - validityDays := 1024 139 - 140 - // Uses the existing CreateAPIKey, which likely doesn't return the raw key. 141 - // The HTML flow currently redirects and shows the key ID. 142 - // The template message about "only time you'll see this key" is misleading if it shows ID. 143 - // This might require a separate enhancement if the HTML view should show the raw key. 144 - apiKey, err := s.sessions.CreateAPIKey(userID, keyName, validityDays) 187 + // GET request: Display HTML page for API Key Management 188 + keys, err := s.sessions.GetAPIKeyManager().GetUserApiKeys(userID) 145 189 if err != nil { 146 - http.Error(w, fmt.Sprintf("Error creating API key: %v", err), http.StatusInternalServerError) 190 + http.Error(w, fmt.Sprintf("Error fetching API keys: %v", err), http.StatusInternalServerError) 147 191 return 148 192 } 149 - // Redirects, passing the ID of the created key. 150 - // The template shows this ID in the ".NewKey" section. 151 - http.Redirect(w, r, "/api-keys?created="+apiKey.ID, http.StatusSeeOther) 152 - return 153 - } 154 193 155 - if r.Method == http.MethodDelete { // Delete key via AJAX from HTML page 156 - keyID := r.URL.Query().Get("key_id") 157 - if keyID == "" { 158 - // For AJAX, a JSON error response is more appropriate than http.Error 159 - jsonError(w, "Key ID is required", http.StatusBadRequest) 160 - return 161 - } 194 + // newlyCreatedKey will be the ID from the redirect after form POST 195 + newlyCreatedKeyID := r.URL.Query().Get("created") 196 + var newKeyValueToShow string 162 197 163 - key, exists := s.sessions.GetAPIKeyManager().GetApiKey(keyID) 164 - if !exists || key.UserID != userID { 165 - jsonError(w, "Invalid API key or not owned by user", http.StatusBadRequest) // StatusNotFound or StatusForbidden 166 - return 198 + if newlyCreatedKeyID != "" { 199 + // For HTML, we only have the ID. The template message should be adjusted 200 + // if it implies the raw key is shown. 201 + // If you enhance CreateAPIKey for HTML to also pass the raw key (e.g. via flash message), 202 + // this logic would change. For now, it's the ID. 203 + newKeyValueToShow = newlyCreatedKeyID 167 204 } 168 205 169 - if err := s.sessions.GetAPIKeyManager().DeleteApiKey(keyID); err != nil { 170 - jsonError(w, fmt.Sprintf("Error deleting API key: %v", err), http.StatusInternalServerError) 171 - return 206 + data := struct { 207 + Keys []*db_apikey.ApiKey // Assuming GetUserApiKeys returns this type 208 + NewKeyID string // Changed from NewKey for clarity as it's an ID 209 + NavBar pages.NavBar 210 + }{ 211 + Keys: keys, 212 + NewKeyID: newKeyValueToShow, 213 + NavBar: pages.NavBar{ 214 + IsLoggedIn: ok, 215 + //Just leaving empty so we don't have to pull in the db here, may change 216 + LastFMUsername: lastfmUsername, 217 + }, 172 218 } 173 - // AJAX client expects JSON 174 - jsonResponse(w, http.StatusOK, map[string]any{"success": true}) 175 - return 176 - } 177 219 178 - // GET request: Display HTML page for API Key Management 179 - keys, err := s.sessions.GetAPIKeyManager().GetUserApiKeys(userID) 180 - if err != nil { 181 - http.Error(w, fmt.Sprintf("Error fetching API keys: %v", err), http.StatusInternalServerError) 182 - return 183 - } 184 - 185 - // newlyCreatedKey will be the ID from the redirect after form POST 186 - newlyCreatedKeyID := r.URL.Query().Get("created") 187 - var newKeyValueToShow string 188 - 189 - if newlyCreatedKeyID != "" { 190 - // For HTML, we only have the ID. The template message should be adjusted 191 - // if it implies the raw key is shown. 192 - // If you enhance CreateAPIKey for HTML to also pass the raw key (e.g. via flash message), 193 - // this logic would change. For now, it's the ID. 194 - newKeyValueToShow = newlyCreatedKeyID 195 - } 196 - 197 - tmpl := ` 198 - <!DOCTYPE html> 199 - <html> 200 - <head> 201 - <title>API Key Management - Piper</title> 202 - <style> 203 - body { 204 - font-family: Arial, sans-serif; 205 - max-width: 800px; 206 - margin: 0 auto; 207 - padding: 20px; 208 - line-height: 1.6; 209 - } 210 - h1, h2 { 211 - color: #1DB954; /* Spotify green */ 212 - } 213 - .nav { 214 - display: flex; 215 - margin-bottom: 20px; 216 - } 217 - .nav a { 218 - margin-right: 15px; 219 - text-decoration: none; 220 - color: #1DB954; 221 - font-weight: bold; 222 - } 223 - .card { 224 - border: 1px solid #ddd; 225 - border-radius: 8px; 226 - padding: 20px; 227 - margin-bottom: 20px; 228 - } 229 - table { 230 - width: 100%; 231 - border-collapse: collapse; 232 - } 233 - table th, table td { 234 - padding: 8px; 235 - text-align: left; 236 - border-bottom: 1px solid #ddd; 237 - } 238 - .key-value { 239 - font-family: monospace; 240 - padding: 10px; 241 - background-color: #f5f5f5; 242 - border: 1px solid #ddd; 243 - border-radius: 4px; 244 - word-break: break-all; 245 - } 246 - .new-key-alert { 247 - background-color: #f8f9fa; 248 - border-left: 4px solid #1DB954; 249 - padding: 15px; 250 - margin-bottom: 20px; 251 - } 252 - .btn { 253 - padding: 8px 16px; 254 - background-color: #1DB954; 255 - color: white; 256 - border: none; 257 - border-radius: 4px; 258 - cursor: pointer; 259 - } 260 - .btn-danger { 261 - background-color: #dc3545; 262 - } 263 - </style> 264 - </head> 265 - <body> 266 - <div class="nav"> 267 - <a href="/">Home</a> 268 - <a href="/current-track">Current Track</a> 269 - <a href="/history">Track History</a> 270 - <a href="/api-keys" class="active">API Keys</a> 271 - <a href="/logout">Logout</a> 272 - </div> 273 - 274 - <h1>API Key Management</h1> 275 - 276 - <div class="card"> 277 - <h2>Create New API Key</h2> 278 - <p>API keys allow programmatic access to your Piper account data.</p> 279 - <form method="POST" action="/api-keys"> 280 - <div style="margin-bottom: 15px;"> 281 - <label for="name">Key Name (for your reference):</label> 282 - <input type="text" id="name" name="name" placeholder="My Application" style="width: 100%; padding: 8px; margin-top: 5px;"> 283 - </div> 284 - <button type="submit" class="btn">Generate New API Key</button> 285 - </form> 286 - </div> 287 - 288 - {{if .NewKeyID}} <!-- Changed from .NewKey to .NewKeyID for clarity --> 289 - <div class="new-key-alert"> 290 - <h3>Your new API key (ID: {{.NewKeyID}}) has been created</h3> 291 - <!-- The message below is misleading if only the ID is shown. 292 - Consider changing this text or modifying the flow to show the actual key once for HTML. --> 293 - <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> 294 - </div> 295 - {{end}} 296 - 297 - <div class="card"> 298 - <h2>Your API Keys</h2> 299 - {{if .Keys}} 300 - <table> 301 - <thead> 302 - <tr> 303 - <th>Name</th> 304 - <th>Prefix</th> 305 - <th>Created</th> 306 - <th>Expires</th> 307 - <th>Actions</th> 308 - </tr> 309 - </thead> 310 - <tbody> 311 - {{range .Keys}} 312 - <tr> 313 - <td>{{.Name}}</td> 314 - <td>{{.KeyPrefix}}</td> <!-- Added KeyPrefix for better identification --> 315 - <td>{{formatTime .CreatedAt}}</td> 316 - <td>{{formatTime .ExpiresAt}}</td> 317 - <td> 318 - <button class="btn btn-danger" onclick="deleteKey('{{.ID}}')">Delete</button> 319 - </td> 320 - </tr> 321 - {{end}} 322 - </tbody> 323 - </table> 324 - {{else}} 325 - <p>You don't have any API keys yet.</p> 326 - {{end}} 327 - </div> 328 - 329 - <div class="card"> 330 - <h2>API Usage</h2> 331 - <p>To use your API key, include it in the Authorization header of your HTTP requests:</p> 332 - <pre>Authorization: Bearer YOUR_API_KEY</pre> 333 - <p>Or include it as a query parameter (less secure for the key itself):</p> 334 - <pre>https://your-piper-instance.com/endpoint?api_key=YOUR_API_KEY</pre> 335 - </div> 336 - 337 - <script> 338 - function deleteKey(keyId) { 339 - if (confirm('Are you sure you want to delete this API key? This action cannot be undone.')) { 340 - fetch('/api-keys?key_id=' + keyId, { // This endpoint is handled by HandleAPIKeyManagement 341 - method: 'DELETE', 342 - }) 343 - .then(response => response.json()) 344 - .then(data => { 345 - if (data.success) { 346 - window.location.reload(); 347 - } else { 348 - alert('Failed to delete API key: ' + (data.error || 'Unknown error')); 349 - } 350 - }) 351 - .catch(error => { 352 - console.error('Error:', error); 353 - alert('Failed to delete API key due to a network or processing error.'); 354 - }); 355 - } 356 - } 357 - </script> 358 - </body> 359 - </html> 360 - ` 361 - funcMap := template.FuncMap{ 362 - "formatTime": func(t time.Time) string { 363 - if t.IsZero() { 364 - return "N/A" 365 - } 366 - return t.Format("Jan 02, 2006 15:04") 367 - }, 220 + w.Header().Set("Content-Type", "text/html") 221 + err = pg.Execute("apiKeys", w, data) 222 + if err != nil { 223 + log.Printf("Error executing template: %v", err) 224 + } 368 225 } 369 - 370 - t, err := template.New("apikeys").Funcs(funcMap).Parse(tmpl) 371 - if err != nil { 372 - http.Error(w, fmt.Sprintf("Error parsing template: %v", err), http.StatusInternalServerError) 373 - return 374 - } 375 - 376 - data := struct { 377 - Keys []*db_apikey.ApiKey // Assuming GetUserApiKeys returns this type 378 - NewKeyID string // Changed from NewKey for clarity as it's an ID 379 - }{ 380 - Keys: keys, 381 - NewKeyID: newKeyValueToShow, 382 - } 383 - 384 - w.Header().Set("Content-Type", "text/html") 385 - t.Execute(w, data) 386 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 -60
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 ··· 87 76 } 88 77 89 78 var swapRecord *string 90 - authArgs := db.AtpSessionToAuthArgs(sess) 91 - 92 - swapRecord, err = p.getStatusSwapRecord(ctx, xrpcClient, sess, authArgs) 79 + swapRecord, err = p.getStatusSwapRecord(ctx, atProtoClient) 93 80 if err != nil { 94 81 return err 95 82 } 96 83 97 84 // Create the record input 98 - input := atproto.RepoPutRecord_Input{ 85 + input := comatproto.RepoPutRecord_Input{ 99 86 Collection: "fm.teal.alpha.actor.status", 100 - Repo: sess.DID, 87 + Repo: atProtoClient.AccountDID.String(), 101 88 Rkey: "self", // Use "self" as the record key for current status 102 89 Record: &lexutil.LexiconTypeDecoder{Val: status}, 103 90 SwapRecord: swapRecord, 104 91 } 105 92 106 93 // Submit to PDS 107 - var out atproto.RepoPutRecord_Output 108 - 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 { 109 95 p.logger.Printf("Error creating playing now status for DID %s: %v", did, err) 110 96 return fmt.Errorf("failed to create playing now status for DID %s: %w", did, err) 111 97 } ··· 132 118 did := *user.ATProtoDID 133 119 134 120 // Get ATProto clients 135 - client, err := p.atprotoService.GetATProtoClient() 136 - if err != nil || client == nil { 137 - return fmt.Errorf("failed to get ATProto client: %w", err) 138 - } 139 - 140 - xrpcClient := p.atprotoService.GetXrpcClient() 141 - if xrpcClient == nil { 142 - return fmt.Errorf("xrpc client is not available") 143 - } 144 - 145 - // Get user session 146 - sess, err := p.db.GetAtprotoSession(did, ctx, *client) 147 - if err != nil { 148 - 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) 149 124 } 150 125 151 126 // Create an expired status (essentially clearing it) ··· 165 140 Item: emptyPlayView, 166 141 } 167 142 168 - authArgs := db.AtpSessionToAuthArgs(sess) 169 - 170 143 var swapRecord *string 171 - swapRecord, err = p.getStatusSwapRecord(ctx, xrpcClient, sess, authArgs) 144 + swapRecord, err = p.getStatusSwapRecord(ctx, atProtoClient) 172 145 if err != nil { 173 146 return err 174 147 } 175 148 176 149 // Update the record 177 - input := atproto.RepoPutRecord_Input{ 150 + input := comatproto.RepoPutRecord_Input{ 178 151 Collection: "fm.teal.alpha.actor.status", 179 - Repo: sess.DID, 152 + Repo: atProtoClient.AccountDID.String(), 180 153 Rkey: "self", 181 154 Record: &lexutil.LexiconTypeDecoder{Val: status}, 182 155 SwapRecord: swapRecord, 183 156 } 184 157 185 - var out atproto.RepoPutRecord_Output 186 - 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 { 187 159 p.logger.Printf("Error clearing playing now status for DID %s: %v", did, err) 188 160 return fmt.Errorf("failed to clear playing now status for DID %s: %w", did, err) 189 161 } ··· 244 216 // Get submission client agent 245 217 submissionAgent := viper.GetString("app.submission_agent") 246 218 if submissionAgent == "" { 247 - submissionAgent = "piper/v0.0.1" 219 + submissionAgent = "piper/v0.0.2" 248 220 } 249 221 250 222 playView := &teal.AlphaFeedDefs_PlayView{ ··· 266 238 267 239 // getStatusSwapRecord retrieves the current swap record (CID) for the actor status record. 268 240 // Returns (nil, nil) if the record does not exist yet. 269 - func (p *PlayingNowService) getStatusSwapRecord(ctx context.Context, xrpcClient *oauth.XrpcClient, sess *models.ATprotoAuthSession, authArgs *oauth.XrpcAuthedRequestArgs) (*string, error) { 270 - getOutput := atproto.RepoGetRecord_Output{} 271 - if err := xrpcClient.Do(ctx, authArgs, xrpc.Query, "application/json", "com.atproto.repo.getRecord", map[string]any{ 272 - "repo": sess.DID, 273 - "collection": "fm.teal.alpha.actor.status", 274 - "rkey": "self", 275 - }, nil, &getOutput); err != nil { 276 - 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) 277 246 if !ok { 278 - return nil, fmt.Errorf("could not get record: %w", err) 247 + return nil, fmt.Errorf("error getting the record: %w", err) 279 248 } 280 - if xErr.StatusCode != 400 { // 400 means not found in this API 281 - 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 282 251 } 283 - return nil, nil 252 + 253 + return nil, fmt.Errorf("error getting the record: %w", err) 254 + 284 255 } 285 - return getOutput.Cid, nil 256 + return result.Cid, nil 286 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 {