+2
-2
.air.toml
+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
+2
.env.template
+12
-3
Dockerfile
+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
-4
Makefile
+22
-4
README.md
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
-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
+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
+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
+2
oauth/service.go
+1
-1
pages/cache.go
+1
-1
pages/cache.go
+31
-11
pages/pages.go
+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
pages/static/base.css
···
1
+
@import "tailwindcss";
+531
pages/static/main.css
+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
+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 }}
+24
-39
pages/templates/home.gohtml
+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
+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
+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
+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
+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
+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
+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
+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
+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 {