diff --git a/.impeccable.md b/.impeccable.md
new file mode 100644
index 0000000..c1e1c63
--- /dev/null
+++ b/.impeccable.md
@@ -0,0 +1,18 @@
+## Design Context
+
+### Users
+Piper is for people who publish their listening activity through an Atmosphere account. They use this interface to connect music services, confirm that tracking is working, see the latest listen from each source, and manage access to their Piper data.
+
+### Brand Personality
+Quiet, legible, and connected. The interface should feel dependable and personal, with the calm directness of Aktivi and none of the density of a conventional admin dashboard.
+
+### Aesthetic Direction
+A light, refined utility interface based on the Lexidraw concept: three clearly separated music sources flow into one Atmosphere identity. Keep Piper's cream-and-green palette. Aktivi is the strongest structural reference, especially its oversized lowercase type, restrained navigation, generous spacing, and use of a real product object as the composition. Offprint informs typographic confidence and compact controls. Atmosphere Money informs the precise treatment of identity and protocol details. Soft status-tinted panels, generous whitespace, and plain language make connection state understandable at a glance. Avoid dashboard metrics, decorative gradients, heavy shadows, and excessive navigation.
+
+### Design Principles
+- Put service connection state before secondary detail.
+- Make the relationship between music sources and the Atmosphere account immediately visible.
+- Use color as a redundant status cue, never as the only explanation.
+- Reveal the latest useful record without turning the page into a feed.
+- Keep setup actions close to the service they affect.
+- Preserve full functionality on narrow screens with a simple vertical flow.
diff --git a/cmd/handlers.go b/cmd/handlers.go
index 439bca0..36f5553 100644
--- a/cmd/handlers.go
+++ b/cmd/handlers.go
@@ -3,10 +3,13 @@ package main
import (
"context"
"encoding/json"
+ "errors"
"fmt"
"log"
"net/http"
+ "net/url"
"strconv"
+ "strings"
"time"
"github.com/spf13/viper"
@@ -19,15 +22,22 @@ import (
atprotoservice "github.com/teal-fm/piper/service/atproto"
"github.com/teal-fm/piper/service/musicbrainz"
"github.com/teal-fm/piper/service/playingnow"
+ profileservice "github.com/teal-fm/piper/service/profile"
"github.com/teal-fm/piper/service/spotify"
"github.com/teal-fm/piper/session"
)
type HomeParams struct {
- NavBar pages.NavBar
+ NavBar pages.NavBar
+ User *models.User
+ SpotifyTrack *models.Track
+ AppleMusicTrack *models.Track
+ LastFMTrack *models.Track
+ Atmosphere profileservice.Account
+ AppleMusicDevToken string
}
-func home(database *db.DB, pg *pages.Pages) http.HandlerFunc {
+func home(database *db.DB, pg *pages.Pages, profileResolver *profileservice.Resolver, spotifyService *spotify.Service, appleMusicService *applemusic.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
@@ -36,22 +46,58 @@ func home(database *db.DB, pg *pages.Pages) http.HandlerFunc {
isLoggedIn := authenticated
lastfmUsername := ""
+ var user *models.User
+ var spotifyTrack, appleMusicTrack, lastFMTrack *models.Track
+ var atmosphere profileservice.Account
+ appleMusicDevToken := ""
+ appleMusicEnabled := viper.GetBool("enable_applemusic") && appleMusicService != nil
+
if isLoggedIn {
- user, err := database.GetUserByID(userID)
- fmt.Printf("User: %+v\n", user)
+ var err error
+ user, err = database.GetUserByID(userID)
if err == nil && user != nil && user.LastFMUsername != nil {
- lastfmUsername = *user.LastFMUsername
+ lastfmUsername = strings.TrimSpace(*user.LastFMUsername)
} else if err != nil {
log.Printf("Error fetching user %d details for home page: %v", userID, err)
}
+ if user != nil && user.ATProtoDID != nil {
+ atmosphere, _ = profileResolver.Cached(*user.ATProtoDID)
+ }
+ if appleMusicEnabled {
+ appleMusicDevToken, _, err = appleMusicService.GenerateDeveloperToken()
+ if err != nil {
+ log.Printf("Error preparing Apple Music on home page: %v", err)
+ appleMusicEnabled = false
+ }
+ }
+
+ spotifyTrack, err = database.GetLatestTrackForService(userID, db.SourceSpotify)
+ if err != nil {
+ log.Printf("Error fetching latest Spotify track for user %d: %v", userID, err)
+ }
+ appleMusicTrack, err = database.GetLatestTrackForService(userID, db.SourceAppleMusic)
+ if err != nil {
+ log.Printf("Error fetching latest Apple Music track for user %d: %v", userID, err)
+ }
+ lastFMTrack, err = database.GetLatestTrackForService(userID, db.SourceLastfm)
+ if err != nil {
+ log.Printf("Error fetching latest Last.fm track for user %d: %v", userID, err)
+ }
}
params := HomeParams{
+ User: user,
+ SpotifyTrack: spotifyTrack,
+ AppleMusicTrack: appleMusicTrack,
+ LastFMTrack: lastFMTrack,
+ Atmosphere: atmosphere,
+ AppleMusicDevToken: appleMusicDevToken,
NavBar: pages.NavBar{
IsLoggedIn: isLoggedIn,
+ CurrentPage: pages.NavConnections,
LastFMUsername: lastfmUsername,
- SpotifyEnabled: viper.GetBool("enable_spotify"),
+ SpotifyEnabled: spotifyService != nil,
LastFMEnabled: viper.GetBool("enable_lastfm"),
- AppleMusicEnabled: viper.GetBool("enable_applemusic"),
+ AppleMusicEnabled: appleMusicEnabled,
},
}
err := pg.Execute("home", w, params)
@@ -70,7 +116,7 @@ func handleLinkLastfmForm(database *db.DB, pg *pages.Pages) http.HandlerFunc {
return
}
- lastfmUsername := r.FormValue("lastfm_username")
+ lastfmUsername := strings.TrimSpace(r.FormValue("lastfm_username"))
if lastfmUsername == "" {
http.Error(w, "Last.fm username cannot be empty", http.StatusBadRequest)
return
@@ -91,7 +137,7 @@ func handleLinkLastfmForm(database *db.DB, pg *pages.Pages) http.HandlerFunc {
currentUser, err := database.GetUserByID(userID)
currentUsername := ""
if err == nil && currentUser != nil && currentUser.LastFMUsername != nil {
- currentUsername = *currentUser.LastFMUsername
+ currentUsername = strings.TrimSpace(*currentUser.LastFMUsername)
} else if err != nil {
log.Printf("Error fetching user %d for Last.fm form: %v", userID, err)
// Don't fail, just show an empty form
@@ -105,6 +151,7 @@ func handleLinkLastfmForm(database *db.DB, pg *pages.Pages) http.HandlerFunc {
}{
NavBar: pages.NavBar{
IsLoggedIn: authenticated,
+ CurrentPage: pages.NavConnections,
LastFMUsername: currentUsername,
SpotifyEnabled: viper.GetBool("enable_spotify"),
LastFMEnabled: viper.GetBool("enable_lastfm"),
@@ -128,7 +175,7 @@ func handleLinkLastfmSubmit(database *db.DB) http.HandlerFunc {
return
}
- lastfmUsername := r.FormValue("lastfm_username")
+ lastfmUsername := strings.TrimSpace(r.FormValue("lastfm_username"))
if lastfmUsername == "" {
http.Error(w, "Last.fm username cannot be empty", http.StatusBadRequest)
return
@@ -147,31 +194,50 @@ func handleLinkLastfmSubmit(database *db.DB) http.HandlerFunc {
}
}
-func handleAppleMusicLink(pg *pages.Pages, am *applemusic.Service) http.HandlerFunc {
+func handleUnlinkLastfm(database *db.DB, allowedOrigin string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "text/html")
- devToken, _, errTok := am.GenerateDeveloperToken()
- if errTok != nil {
- log.Printf("Error generating Apple Music developer token: %v", errTok)
- http.Error(w, "Failed to prepare Apple Music", http.StatusInternalServerError)
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
- data := struct {
- NavBar pages.NavBar
- DevToken string
- }{
- DevToken: devToken,
- NavBar: pages.NavBar{
- SpotifyEnabled: viper.GetBool("enable_spotify"),
- LastFMEnabled: viper.GetBool("enable_lastfm"),
- AppleMusicEnabled: viper.GetBool("enable_applemusic"),
- },
+ if !requestHasAllowedOrigin(r, allowedOrigin) {
+ http.Error(w, "Invalid request origin", http.StatusForbidden)
+ return
}
- err := pg.Execute("applemusic_link", w, data)
- if err != nil {
- log.Printf("Error executing template: %v", err)
+ userID, _ := session.GetUserID(r.Context())
+ if err := database.ClearLastFMUsername(userID); err != nil {
+ log.Printf("Error removing Last.fm account for user %d: %v", userID, err)
+ http.Error(w, "Failed to remove Last.fm account", http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+ }
+}
+
+func requestHasAllowedOrigin(r *http.Request, allowedOrigin string) bool {
+ origin := r.Header.Get("Origin")
+ if origin == "" || origin == "null" {
+ return false
+ }
+ actual, err := url.Parse(origin)
+ if err != nil || actual.Scheme == "" || actual.Host == "" || actual.User != nil {
+ return false
+ }
+
+ expected := &url.URL{}
+ if allowedOrigin != "" {
+ expected, err = url.Parse(allowedOrigin)
+ if err != nil || expected.Scheme == "" || expected.Host == "" {
+ return false
+ }
+ } else {
+ expected.Scheme = "http"
+ if r.TLS != nil {
+ expected.Scheme = "https"
}
+ expected.Host = r.Host
}
+ return strings.EqualFold(actual.Scheme, expected.Scheme) && strings.EqualFold(actual.Host, expected.Host)
}
func apiCurrentTrack(spotifyService *spotify.Service) http.HandlerFunc {
@@ -332,6 +398,7 @@ func apiLinkLastfmHandler(database *db.DB) http.HandlerFunc {
return
}
+ reqBody.LastFMUsername = strings.TrimSpace(reqBody.LastFMUsername)
if reqBody.LastFMUsername == "" {
jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "Last.fm username cannot be empty"})
return
@@ -352,8 +419,7 @@ func apiUnlinkLastfmHandler(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, _ := session.GetUserID(r.Context())
- // TODO: add a clear username for user id fn
- err := database.AddLastFMUsername(userID, "")
+ err := database.ClearLastFMUsername(userID)
if err != nil {
log.Printf("apiUnlinkLastfmHandler: Error unlinking Last.fm username for user %d: %v", userID, err)
jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "Failed to unlink Last.fm username"})
@@ -364,6 +430,81 @@ func apiUnlinkLastfmHandler(database *db.DB) http.HandlerFunc {
}
}
+func apiAtmosphereProfile(database *db.DB, resolver *profileservice.Resolver) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "Method not allowed"})
+ return
+ }
+ userID, authenticated := session.GetUserID(r.Context())
+ if !authenticated {
+ jsonResponse(w, http.StatusUnauthorized, map[string]string{"error": "Unauthorized"})
+ return
+ }
+ user, err := database.GetUserByID(userID)
+ if err != nil || user == nil || user.ATProtoDID == nil {
+ jsonResponse(w, http.StatusNotFound, map[string]string{"error": "Atmosphere account not found"})
+ return
+ }
+ account, ready := resolver.Cached(*user.ATProtoDID)
+ if !ready {
+ jsonResponse(w, http.StatusAccepted, map[string]string{"status": "pending"})
+ return
+ }
+ jsonResponse(w, http.StatusOK, account)
+ }
+}
+
+func apiLatestRecords(database *db.DB, resolver *profileservice.Resolver) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "Method not allowed"})
+ return
+ }
+ userID, authenticated := session.GetUserID(r.Context())
+ if !authenticated {
+ jsonResponse(w, http.StatusUnauthorized, map[string]string{"error": "Unauthorized"})
+ return
+ }
+ user, err := database.GetUserByID(userID)
+ if err != nil || user == nil || user.ATProtoDID == nil {
+ jsonResponse(w, http.StatusNotFound, map[string]string{"error": "Atmosphere account not found"})
+ return
+ }
+ targets := make(map[string]profileservice.RecordTarget, 3)
+ if user.SpotifyID != nil && *user.SpotifyID != "" {
+ if track, trackErr := database.GetLatestTrackForService(userID, db.SourceSpotify); trackErr == nil && track != nil {
+ targets["spotify"] = profileservice.RecordTarget{TrackName: track.Name, PlayedAt: track.Timestamp}
+ }
+ }
+ if user.AppleMusicUserToken != nil && *user.AppleMusicUserToken != "" {
+ if track, trackErr := database.GetLatestTrackForService(userID, db.SourceAppleMusic); trackErr == nil && track != nil {
+ targets["applemusic"] = profileservice.RecordTarget{TrackName: track.Name, PlayedAt: track.Timestamp}
+ }
+ }
+ if user.LastFMUsername != nil && strings.TrimSpace(*user.LastFMUsername) != "" {
+ if track, trackErr := database.GetLatestTrackForService(userID, db.SourceLastfm); trackErr == nil && track != nil {
+ targets["lastfm"] = profileservice.RecordTarget{TrackName: track.Name, PlayedAt: track.Timestamp}
+ }
+ }
+ records, err := resolver.LatestRecords(r.Context(), *user.ATProtoDID, targets)
+ if errors.Is(err, profileservice.ErrProfilePending) {
+ jsonResponse(w, http.StatusAccepted, map[string]string{"status": "pending"})
+ return
+ }
+ if err != nil {
+ log.Printf("Error loading latest record for user %d: %v", userID, err)
+ jsonResponse(w, http.StatusBadGateway, map[string]string{"error": "Latest record unavailable"})
+ return
+ }
+ links := make(map[string]string, len(records))
+ for service, atURI := range records {
+ links[service] = "https://pds.ls/at://" + strings.TrimPrefix(atURI, "at://")
+ }
+ jsonResponse(w, http.StatusOK, links)
+ }
+}
+
// apiAppleMusicAuthorize stores a MusicKit user token for the current user
func apiAppleMusicAuthorize(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
diff --git a/cmd/listenbrainz_test.go b/cmd/listenbrainz_test.go
index 3b17bb5..3c21811 100644
--- a/cmd/listenbrainz_test.go
+++ b/cmd/listenbrainz_test.go
@@ -43,12 +43,12 @@ func createTestUser(t *testing.T, database *db.DB) (int64, string) {
// Create API key for the user
sessionManager := session.NewSessionManager(database)
- apiKeyObj, err := sessionManager.CreateAPIKey(userID, "test-key", 30) // 30 days validity
+ _, rawKey, err := sessionManager.CreateAPIKey(userID, "test-key", 30) // 30 days validity
if err != nil {
t.Fatalf("Failed to create API key: %v", err)
}
- return userID, apiKeyObj.ID
+ return userID, rawKey
}
// Helper to create context with user ID (simulating auth middleware)
diff --git a/cmd/main.go b/cmd/main.go
index 118306f..aac7398 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -23,6 +23,7 @@ import (
"github.com/teal-fm/piper/service/lastfm"
"github.com/teal-fm/piper/service/musicbrainz"
"github.com/teal-fm/piper/service/playingnow"
+ profileservice "github.com/teal-fm/piper/service/profile"
"github.com/teal-fm/piper/service/spotify"
"github.com/teal-fm/piper/session"
)
@@ -38,6 +39,7 @@ type application struct {
playingNowService *playingnow.Service
appleMusicService *applemusic.Service
pages *pages.Pages
+ profileResolver *profileservice.Resolver
}
// JSON API handlers
@@ -67,6 +69,7 @@ func main() {
}
sessionManager := session.NewSessionManager(database)
+ sessionManager.SetSecureCookies(strings.HasPrefix(viper.GetString("server.root_url"), "https://"))
// --- Service Initializations ---
@@ -216,6 +219,7 @@ func main() {
playingNowService: playingNowService,
appleMusicService: appleMusicService,
pages: pages.NewPages(),
+ profileResolver: profileservice.NewResolver(),
}
trackerInterval := time.Duration(viper.GetInt("tracker.interval")) * time.Second
diff --git a/cmd/origin_test.go b/cmd/origin_test.go
new file mode 100644
index 0000000..a1e79d8
--- /dev/null
+++ b/cmd/origin_test.go
@@ -0,0 +1,30 @@
+package main
+
+import (
+ "net/http/httptest"
+ "testing"
+)
+
+func TestRequestHasAllowedOrigin(t *testing.T) {
+ tests := []struct {
+ name string
+ origin string
+ want bool
+ }{
+ {name: "same origin", origin: "https://piper.example", want: true},
+ {name: "cross origin", origin: "https://attacker.example", want: false},
+ {name: "missing origin", want: false},
+ {name: "null origin", origin: "null", want: false},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ request := httptest.NewRequest("POST", "https://piper.example/unlink-lastfm", nil)
+ if test.origin != "" {
+ request.Header.Set("Origin", test.origin)
+ }
+ if got := requestHasAllowedOrigin(request, "https://piper.example"); got != test.want {
+ t.Fatalf("got %v, want %v", got, test.want)
+ }
+ })
+ }
+}
diff --git a/cmd/routes.go b/cmd/routes.go
index 2a58bad..c73e2ee 100644
--- a/cmd/routes.go
+++ b/cmd/routes.go
@@ -14,7 +14,7 @@ func (app *application) routes() http.Handler {
//Handles static file routes
mux.Handle("/static/{file_name}", app.pages.Static())
- mux.HandleFunc("/", session.WithPossibleAuth(home(app.database, app.pages), app.sessionManager))
+ mux.HandleFunc("/", session.WithPossibleAuth(home(app.database, app.pages, app.profileResolver, app.spotifyService, app.appleMusicService), app.sessionManager))
// OAuth Routes
mux.HandleFunc("/login/atproto", app.oauthManager.HandleLogin("atproto"))
@@ -30,7 +30,10 @@ func (app *application) routes() http.Handler {
mux.HandleFunc("/link-lastfm", session.WithAuth(handleLinkLastfmForm(app.database, app.pages), app.sessionManager)) // GET form
mux.HandleFunc("/link-lastfm/submit", session.WithAuth(handleLinkLastfmSubmit(app.database), app.sessionManager)) // POST submit - Changed route slightly
- mux.HandleFunc("/link-applemusic", session.WithAuth(handleAppleMusicLink(app.pages, app.appleMusicService), app.sessionManager))
+ mux.HandleFunc("/unlink-lastfm", session.WithAuth(handleUnlinkLastfm(app.database, viper.GetString("server.root_url")), app.sessionManager))
+ mux.HandleFunc("/link-applemusic", session.WithAuth(func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+ }, app.sessionManager))
mux.HandleFunc("/logout", app.oauthManager.HandleLogout("atproto"))
mux.HandleFunc("/debug/", session.WithAuth(app.sessionManager.HandleDebug, app.sessionManager))
@@ -45,6 +48,8 @@ func (app *application) routes() http.Handler {
// Apple Music user authorization (protected with session auth)
mux.HandleFunc("/api/v1/applemusic/authorize", session.WithAuth(apiAppleMusicAuthorize(app.database), app.sessionManager))
mux.HandleFunc("/api/v1/applemusic/unlink", session.WithAuth(apiAppleMusicUnlink(app.database), app.sessionManager))
+ mux.HandleFunc("/api/v1/atmosphere-profile", session.WithAuth(apiAtmosphereProfile(app.database, app.profileResolver), app.sessionManager))
+ mux.HandleFunc("/api/v1/latest-records", session.WithAuth(apiLatestRecords(app.database, app.profileResolver), app.sessionManager))
// ListenBrainz-compatible endpoint
mux.HandleFunc("/1/submit-listens", session.WithAPIAuth(apiSubmitListensHandler(app.database, app.atprotoService, app.playingNowService, app.mbService), app.sessionManager))
diff --git a/db/apikey/apikey.go b/db/apikey/apikey.go
index 7d0952e..f53be22 100644
--- a/db/apikey/apikey.go
+++ b/db/apikey/apikey.go
@@ -2,8 +2,10 @@ package apikey
import (
"crypto/rand"
+ "crypto/sha256"
"database/sql"
"encoding/base64"
+ "encoding/hex"
"errors"
"fmt"
"log"
@@ -18,6 +20,7 @@ import (
// ApiKey represents an API key for authenticating requests
type ApiKey struct {
ID string
+ KeyPrefix string
UserID int64
Name string
CreatedAt time.Time
@@ -37,6 +40,8 @@ func NewApiKeyManager(database *db.DB) *Manager {
_, err := database.Exec(`
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
+ key_hash TEXT UNIQUE,
+ key_prefix TEXT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMP,
@@ -47,6 +52,20 @@ func NewApiKeyManager(database *db.DB) *Manager {
if err != nil {
log.Printf("Error creating api_keys table: %v", err)
}
+ for _, statement := range []string{
+ `ALTER TABLE api_keys ADD COLUMN key_hash TEXT`,
+ `ALTER TABLE api_keys ADD COLUMN key_prefix TEXT`,
+ } {
+ if _, alterErr := database.Exec(statement); alterErr != nil && !strings.Contains(alterErr.Error(), "duplicate column name") {
+ log.Printf("Error updating api_keys table: %v", alterErr)
+ }
+ }
+ if err := migrateLegacyAPIKeys(database); err != nil {
+ log.Printf("Error migrating legacy API keys: %v", err)
+ }
+ if _, err := database.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_api_keys_key_hash ON api_keys(key_hash)`); err != nil {
+ log.Printf("Error indexing API key hashes: %v", err)
+ }
am := &Manager{
db: database,
@@ -83,22 +102,30 @@ func (am *Manager) cleanupExpiredApiKeys() {
}
// CreateApiKey creates a new API key for a user
-func (am *Manager) CreateApiKey(userID int64, name string, validityDays int) (*ApiKey, error) {
+func (am *Manager) CreateApiKey(userID int64, name string, validityDays int) (*ApiKey, string, error) {
am.mu.Lock()
defer am.mu.Unlock()
- // Generate random API key
- b := make([]byte, 32)
- if _, err := rand.Read(b); err != nil {
- return nil, err
+ rawKey, err := randomToken(32)
+ if err != nil {
+ return nil, "", err
+ }
+ apiKeyID, err := randomToken(16)
+ if err != nil {
+ return nil, "", err
+ }
+ keyHash := hashAPIKey(rawKey)
+ keyPrefix := rawKey
+ if len(keyPrefix) > 8 {
+ keyPrefix = keyPrefix[:8]
}
- apiKeyID := base64.URLEncoding.EncodeToString(b)
now := time.Now().UTC()
expiresAt := now.AddDate(0, 0, validityDays) // Default to validityDays days validity
apiKey := &ApiKey{
ID: apiKeyID,
+ KeyPrefix: keyPrefix,
UserID: userID,
Name: name,
CreatedAt: now,
@@ -106,26 +133,28 @@ func (am *Manager) CreateApiKey(userID int64, name string, validityDays int) (*A
}
// Store API key in memory
- am.apiKeys[apiKeyID] = apiKey
+ am.apiKeys[keyHash] = apiKey
// Store API key in database
- _, err := am.db.Exec(`
- INSERT INTO api_keys (id, user_id, name, created_at, expires_at)
- VALUES (?, ?, ?, ?, ?)`,
- apiKeyID, userID, name, now, expiresAt)
+ _, err = am.db.Exec(`
+ INSERT INTO api_keys (id, key_hash, key_prefix, user_id, name, created_at, expires_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ apiKeyID, keyHash, keyPrefix, userID, name, now, expiresAt)
if err != nil {
- return nil, err
+ delete(am.apiKeys, keyHash)
+ return nil, "", err
}
- return apiKey, nil
+ return apiKey, rawKey, nil
}
// GetApiKey retrieves an API key by ID
func (am *Manager) GetApiKey(apiKeyID string) (*ApiKey, bool) {
+ keyHash := hashAPIKey(apiKeyID)
// First check in-memory cache
am.mu.RLock()
- apiKey, exists := am.apiKeys[apiKeyID]
+ apiKey, exists := am.apiKeys[keyHash]
am.mu.RUnlock()
if exists {
@@ -140,11 +169,11 @@ func (am *Manager) GetApiKey(apiKeyID string) (*ApiKey, bool) {
}
// If not in memory, check database
- apiKey = &ApiKey{ID: apiKeyID}
+ apiKey = &ApiKey{}
err := am.db.QueryRow(`
- SELECT user_id, name, created_at, expires_at
- FROM api_keys WHERE id = ?`, apiKeyID).Scan(
- &apiKey.UserID, &apiKey.Name, &apiKey.CreatedAt, &apiKey.ExpiresAt)
+ SELECT id, COALESCE(key_prefix, ''), user_id, name, created_at, expires_at
+ FROM api_keys WHERE key_hash = ?`, keyHash).Scan(
+ &apiKey.ID, &apiKey.KeyPrefix, &apiKey.UserID, &apiKey.Name, &apiKey.CreatedAt, &apiKey.ExpiresAt)
if err != nil {
return nil, false
@@ -159,7 +188,7 @@ func (am *Manager) GetApiKey(apiKeyID string) (*ApiKey, bool) {
// Add to in-memory cache
am.mu.Lock()
- am.apiKeys[apiKeyID] = apiKey
+ am.apiKeys[keyHash] = apiKey
am.mu.Unlock()
return apiKey, true
@@ -168,7 +197,11 @@ func (am *Manager) GetApiKey(apiKeyID string) (*ApiKey, bool) {
// DeleteApiKey removes an API key
func (am *Manager) DeleteApiKey(apiKeyID string) error {
am.mu.Lock()
- delete(am.apiKeys, apiKeyID)
+ for keyHash, apiKey := range am.apiKeys {
+ if apiKey.ID == apiKeyID {
+ delete(am.apiKeys, keyHash)
+ }
+ }
am.mu.Unlock()
_, err := am.db.Exec("DELETE FROM api_keys WHERE id = ?", apiKeyID)
@@ -178,7 +211,7 @@ func (am *Manager) DeleteApiKey(apiKeyID string) error {
// GetUserApiKeys retrieves all API keys for a user
func (am *Manager) GetUserApiKeys(userID int64) ([]*ApiKey, error) {
rows, err := am.db.Query(`
- SELECT id, user_id, name, created_at, expires_at
+ SELECT id, COALESCE(key_prefix, ''), user_id, name, created_at, expires_at
FROM api_keys
WHERE user_id = ?
ORDER BY created_at DESC`, userID)
@@ -198,6 +231,7 @@ func (am *Manager) GetUserApiKeys(userID int64) ([]*ApiKey, error) {
apiKey := &ApiKey{}
err := rows.Scan(
&apiKey.ID,
+ &apiKey.KeyPrefix,
&apiKey.UserID,
&apiKey.Name,
&apiKey.CreatedAt,
@@ -212,6 +246,56 @@ func (am *Manager) GetUserApiKeys(userID int64) ([]*ApiKey, error) {
return apiKeys, nil
}
+func randomToken(byteLength int) (string, error) {
+ bytes := make([]byte, byteLength)
+ if _, err := rand.Read(bytes); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(bytes), nil
+}
+
+func hashAPIKey(rawKey string) string {
+ hash := sha256.Sum256([]byte(rawKey))
+ return hex.EncodeToString(hash[:])
+}
+
+func migrateLegacyAPIKeys(database *db.DB) error {
+ rows, err := database.Query(`SELECT id FROM api_keys WHERE key_hash IS NULL OR key_hash = ''`)
+ if err != nil {
+ return err
+ }
+ var legacyKeys []string
+ for rows.Next() {
+ var key string
+ if err := rows.Scan(&key); err != nil {
+ _ = rows.Close()
+ return err
+ }
+ legacyKeys = append(legacyKeys, key)
+ }
+ if err := rows.Err(); err != nil {
+ _ = rows.Close()
+ return err
+ }
+ if err := rows.Close(); err != nil {
+ return err
+ }
+ for _, rawKey := range legacyKeys {
+ newID, err := randomToken(16)
+ if err != nil {
+ return err
+ }
+ prefix := rawKey
+ if len(prefix) > 8 {
+ prefix = prefix[:8]
+ }
+ if _, err := database.Exec(`UPDATE api_keys SET id = ?, key_hash = ?, key_prefix = ? WHERE id = ?`, newID, hashAPIKey(rawKey), prefix, rawKey); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
// ExtractApiKey extracts the API key from the request
func ExtractApiKey(r *http.Request) (string, error) {
// Try to get from Authorization header first
diff --git a/db/apikey/apikey_test.go b/db/apikey/apikey_test.go
new file mode 100644
index 0000000..edf1f9d
--- /dev/null
+++ b/db/apikey/apikey_test.go
@@ -0,0 +1,71 @@
+package apikey
+
+import (
+ "testing"
+
+ "github.com/teal-fm/piper/db"
+)
+
+func testDatabase(t *testing.T) *db.DB {
+ t.Helper()
+ database, err := db.New(":memory:")
+ if err != nil {
+ t.Fatalf("new database: %v", err)
+ }
+ if err := database.Initialize(); err != nil {
+ t.Fatalf("initialize database: %v", err)
+ }
+ t.Cleanup(func() { _ = database.Close() })
+ return database
+}
+
+func TestCreateAPIKeySeparatesSecretFromID(t *testing.T) {
+ database := testDatabase(t)
+ manager := NewApiKeyManager(database)
+ apiKey, rawKey, err := manager.CreateApiKey(1, "test", 30)
+ if err != nil {
+ t.Fatalf("create API key: %v", err)
+ }
+ if rawKey == "" || rawKey == apiKey.ID {
+ t.Fatalf("raw key %q must differ from public ID %q", rawKey, apiKey.ID)
+ }
+ if _, ok := manager.GetApiKey(rawKey); !ok {
+ t.Fatal("raw key did not authenticate")
+ }
+ if _, ok := manager.GetApiKey(apiKey.ID); ok {
+ t.Fatal("public ID authenticated as a secret")
+ }
+ var storedHash string
+ if err := database.QueryRow(`SELECT key_hash FROM api_keys WHERE id = ?`, apiKey.ID).Scan(&storedHash); err != nil {
+ t.Fatalf("load stored hash: %v", err)
+ }
+ if storedHash == rawKey || storedHash == "" {
+ t.Fatalf("database stored unsafe key value %q", storedHash)
+ }
+}
+
+func TestLegacyAPIKeyMigrationPreservesAuthentication(t *testing.T) {
+ database := testDatabase(t)
+ if _, err := database.Exec(`
+ CREATE TABLE api_keys (
+ id TEXT PRIMARY KEY,
+ user_id INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ created_at TIMESTAMP,
+ expires_at TIMESTAMP
+ )`); err != nil {
+ t.Fatalf("create legacy table: %v", err)
+ }
+ const legacyKey = "legacy-secret"
+ if _, err := database.Exec(`INSERT INTO api_keys (id, user_id, name, created_at, expires_at) VALUES (?, 1, 'legacy', CURRENT_TIMESTAMP, datetime('now', '+1 day'))`, legacyKey); err != nil {
+ t.Fatalf("insert legacy key: %v", err)
+ }
+ manager := NewApiKeyManager(database)
+ apiKey, ok := manager.GetApiKey(legacyKey)
+ if !ok {
+ t.Fatal("legacy key stopped authenticating after migration")
+ }
+ if apiKey.ID == legacyKey {
+ t.Fatal("legacy secret remained the public row ID")
+ }
+}
diff --git a/db/db.go b/db/db.go
index 1cefd99..f5489cf 100644
--- a/db/db.go
+++ b/db/db.go
@@ -71,6 +71,13 @@ func (db *DB) Initialize() error {
return err
}
+ // Older versions could store surrounding or whitespace-only values as a
+ // linked Last.fm account. Normalize them with the same Unicode-aware rule
+ // used when accepting new usernames.
+ if err = db.normalizeLastFMUsernames(); err != nil {
+ return err
+ }
+
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS tracks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
diff --git a/db/lfm.go b/db/lfm.go
index 983c1aa..436d7d1 100644
--- a/db/lfm.go
+++ b/db/lfm.go
@@ -2,11 +2,16 @@ package db
import (
"database/sql"
+ "strings"
"github.com/teal-fm/piper/models"
)
func (db *DB) AddLastFMUsername(userID int64, lastfmUsername string) error {
+ lastfmUsername = strings.TrimSpace(lastfmUsername)
+ if lastfmUsername == "" {
+ return db.ClearLastFMUsername(userID)
+ }
_, err := db.Exec(`
UPDATE users
SET lastfm_username = ?
@@ -15,6 +20,15 @@ func (db *DB) AddLastFMUsername(userID int64, lastfmUsername string) error {
return err
}
+func (db *DB) ClearLastFMUsername(userID int64) error {
+ _, err := db.Exec(`
+ UPDATE users
+ SET lastfm_username = NULL
+ WHERE id = ?`, userID)
+
+ return err
+}
+
func (db *DB) GetAllUsersWithLastFM() ([]*models.User, error) {
rows, err := db.Query(`
SELECT id, username, email, lastfm_username
@@ -41,12 +55,75 @@ func (db *DB) GetAllUsersWithLastFM() ([]*models.User, error) {
if err != nil {
return nil, err
}
+ lastfmUsername := strings.TrimSpace(*user.LastFMUsername)
+ if lastfmUsername == "" {
+ continue
+ }
+ user.LastFMUsername = &lastfmUsername
users = append(users, user)
}
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
return users, nil
}
+func (db *DB) normalizeLastFMUsernames() error {
+ rows, err := db.Query(`
+ SELECT id, lastfm_username
+ FROM users
+ WHERE lastfm_username IS NOT NULL`)
+ if err != nil {
+ return err
+ }
+
+ type usernameUpdate struct {
+ userID int64
+ username string
+ }
+ var updates []usernameUpdate
+ for rows.Next() {
+ var update usernameUpdate
+ if err := rows.Scan(&update.userID, &update.username); err != nil {
+ _ = rows.Close()
+ return err
+ }
+ trimmed := strings.TrimSpace(update.username)
+ if trimmed != update.username {
+ update.username = trimmed
+ updates = append(updates, update)
+ }
+ }
+ if err := rows.Err(); err != nil {
+ _ = rows.Close()
+ return err
+ }
+ if err := rows.Close(); err != nil {
+ return err
+ }
+
+ transaction, err := db.Begin()
+ if err != nil {
+ return err
+ }
+ defer func() { _ = transaction.Rollback() }()
+
+ for _, update := range updates {
+ if update.username == "" {
+ if _, err := transaction.Exec(`UPDATE users SET lastfm_username = NULL WHERE id = ?`, update.userID); err != nil {
+ return err
+ }
+ continue
+ }
+ if _, err := transaction.Exec(`UPDATE users SET lastfm_username = ? WHERE id = ?`, update.username, update.userID); err != nil {
+ return err
+ }
+ }
+
+ return transaction.Commit()
+}
+
func (db *DB) GetUserByLastFM(lastfmUsername string) (*models.User, error) {
row := db.QueryRow(`
SELECT id, username, email, atproto_did, most_recent_at_session_id, created_at, updated_at, lastfm_username
diff --git a/db/lfm_test.go b/db/lfm_test.go
new file mode 100644
index 0000000..f21ce0f
--- /dev/null
+++ b/db/lfm_test.go
@@ -0,0 +1,83 @@
+package db
+
+import "testing"
+
+func TestLastFMUsernameLifecycle(t *testing.T) {
+ database := newTestDB(t)
+ userID := createTestUser(t, database)
+
+ if err := database.AddLastFMUsername(userID, " listener "); err != nil {
+ t.Fatalf("add Last.fm username: %v", err)
+ }
+ user, err := database.GetUserByID(userID)
+ if err != nil {
+ t.Fatalf("load user: %v", err)
+ }
+ if user.LastFMUsername == nil || *user.LastFMUsername != "listener" {
+ t.Fatalf("got username %v, want listener", user.LastFMUsername)
+ }
+
+ if err := database.ClearLastFMUsername(userID); err != nil {
+ t.Fatalf("clear Last.fm username: %v", err)
+ }
+ user, err = database.GetUserByID(userID)
+ if err != nil {
+ t.Fatalf("reload user: %v", err)
+ }
+ if user.LastFMUsername != nil {
+ t.Fatalf("got username %q after clear, want nil", *user.LastFMUsername)
+ }
+}
+
+func TestBlankLastFMUsernameIsDisconnected(t *testing.T) {
+ database := newTestDB(t)
+ userID := createTestUser(t, database)
+
+ if err := database.AddLastFMUsername(userID, " "); err != nil {
+ t.Fatalf("add blank Last.fm username: %v", err)
+ }
+ user, err := database.GetUserByID(userID)
+ if err != nil {
+ t.Fatalf("load user: %v", err)
+ }
+ if user.LastFMUsername != nil {
+ t.Fatalf("blank username persisted as %q", *user.LastFMUsername)
+ }
+}
+
+func TestLegacyUnicodeWhitespaceLastFMUsernameIsNormalized(t *testing.T) {
+ database := newTestDB(t)
+ userID := createTestUser(t, database)
+ if _, err := database.Exec(`UPDATE users SET lastfm_username = ? WHERE id = ?`, "\t\n\u2003", userID); err != nil {
+ t.Fatalf("seed legacy username: %v", err)
+ }
+ if err := database.normalizeLastFMUsernames(); err != nil {
+ t.Fatalf("normalize usernames: %v", err)
+ }
+ user, err := database.GetUserByID(userID)
+ if err != nil {
+ t.Fatalf("load user: %v", err)
+ }
+ if user.LastFMUsername != nil {
+ t.Fatalf("legacy whitespace username remained %q", *user.LastFMUsername)
+ }
+}
+
+func TestGetAllUsersWithLastFMUsesUnicodeWhitespaceRules(t *testing.T) {
+ database := newTestDB(t)
+ blankUserID := createTestUser(t, database)
+ connectedUserID := createTestUser(t, database)
+ if _, err := database.Exec(`UPDATE users SET lastfm_username = ? WHERE id = ?`, "\t\u2003", blankUserID); err != nil {
+ t.Fatalf("seed blank username: %v", err)
+ }
+ if _, err := database.Exec(`UPDATE users SET lastfm_username = ? WHERE id = ?`, "\tlistener\u2003", connectedUserID); err != nil {
+ t.Fatalf("seed connected username: %v", err)
+ }
+ users, err := database.GetAllUsersWithLastFM()
+ if err != nil {
+ t.Fatalf("get connected users: %v", err)
+ }
+ if len(users) != 1 || users[0].LastFMUsername == nil || *users[0].LastFMUsername != "listener" {
+ t.Fatalf("got %#v, want one normalized listener", users)
+ }
+}
diff --git a/models/constants.go b/models/constants.go
index fa33662..e20ef14 100644
--- a/models/constants.go
+++ b/models/constants.go
@@ -1,3 +1,3 @@
package models
-const SubmissionAgent = "piper/v0.0.10"
+const SubmissionAgent = "piper/v0.0.11-newui-branch"
diff --git a/pages/pages.go b/pages/pages.go
index 77a11af..cead260 100644
--- a/pages/pages.go
+++ b/pages/pages.go
@@ -12,6 +12,8 @@ import (
"net/http"
"strings"
"time"
+
+ "github.com/teal-fm/piper/models"
)
//go:embed templates/* static/*
@@ -107,6 +109,31 @@ func (p *Pages) funcMap() template.FuncMap {
}
return t.Format("Jan 02, 2006 15:04")
},
+ "artistNames": func(artists []models.Artist) string {
+ if len(artists) == 0 {
+ return "Unknown artist"
+ }
+ names := make([]string, 0, len(artists))
+ for _, artist := range artists {
+ if artist.Name != "" {
+ names = append(names, artist.Name)
+ }
+ }
+ if len(names) == 0 {
+ return "Unknown artist"
+ }
+ return strings.Join(names, ", ")
+ },
+ "shortDID": func(did *string) string {
+ if did == nil || *did == "" {
+ return "Not available"
+ }
+ const prefix = "did:plc:"
+ if strings.HasPrefix(*did, prefix) && len(*did) > len(prefix)+10 {
+ return prefix + (*did)[len(prefix):len(prefix)+6] + "…" + (*did)[len(*did)-4:]
+ }
+ return *did
+ },
}
}
@@ -156,8 +183,14 @@ func (p *Pages) Execute(name string, w io.Writer, params any) error {
type NavBar struct {
IsLoggedIn bool
+ CurrentPage string
LastFMUsername string
SpotifyEnabled bool
LastFMEnabled bool
AppleMusicEnabled bool
}
+
+const (
+ NavConnections = "connections"
+ NavAPIAccess = "api-access"
+)
diff --git a/pages/static/base.css b/pages/static/base.css
index f1d8c73..efae71f 100644
--- a/pages/static/base.css
+++ b/pages/static/base.css
@@ -1 +1,394 @@
@import "tailwindcss";
+
+:root {
+ --ink: oklch(24% 0.018 175);
+ --muted: oklch(49% 0.018 175);
+ --faint: oklch(66% 0.012 175);
+ --paper: oklch(97.5% 0.009 155);
+ --paper-deep: oklch(94.5% 0.013 155);
+ --line: oklch(84% 0.016 160);
+ --teal: oklch(55% 0.105 180);
+ --teal-dark: oklch(38% 0.075 180);
+ --connected-bg: oklch(94% 0.055 150);
+ --connected-line: oklch(74% 0.085 150);
+ --connected: oklch(46% 0.12 150);
+ --warning-bg: oklch(94.5% 0.045 25);
+ --warning-line: oklch(78% 0.08 25);
+ --warning: oklch(50% 0.14 25);
+ --unavailable-bg: oklch(95.5% 0.008 175);
+ --unavailable-line: oklch(82% 0.012 175);
+ --shadow: 0 10px 28px color-mix(in oklab, var(--ink) 6%, transparent);
+ --space-xs: 0.25rem;
+ --space-sm: 0.5rem;
+ --space-md: 0.75rem;
+ --space-lg: 1rem;
+ --space-xl: 1.5rem;
+ --space-2xl: 2rem;
+ --space-3xl: 3rem;
+ --space-4xl: 4rem;
+ color-scheme: light;
+}
+
+* { box-sizing: border-box; }
+
+html { min-width: 20rem; background: var(--paper); }
+
+body {
+ margin: 0;
+ color: var(--ink);
+ background:
+ radial-gradient(circle at 50% -20%, color-mix(in oklab, var(--teal) 7%, transparent), transparent 42rem),
+ var(--paper);
+ font-family: "Geologica", sans-serif;
+ font-size: 0.9375rem;
+ font-weight: 400;
+ line-height: 1.55;
+ -webkit-font-smoothing: antialiased;
+}
+
+a { color: inherit; text-decoration: none; }
+button, input { font: inherit; }
+button, a { -webkit-tap-highlight-color: transparent; }
+
+:focus-visible {
+ outline: 3px solid color-mix(in oklab, var(--teal) 55%, transparent);
+ outline-offset: 3px;
+}
+
+::selection { color: var(--ink); background: color-mix(in oklab, var(--teal) 25%, transparent); }
+
+.site-shell {
+ width: min(100% - 2rem, 77rem);
+ margin-inline: auto;
+ padding-bottom: var(--space-4xl);
+}
+
+.site-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ min-height: 5.5rem;
+ gap: var(--space-xl);
+ border-bottom: 1px solid color-mix(in oklab, var(--line) 68%, transparent);
+}
+
+.wordmark {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.65rem;
+ font-family: "Gabarito", sans-serif;
+ font-size: 1.35rem;
+ font-weight: 700;
+ letter-spacing: -0.035em;
+}
+
+.wordmark-symbol {
+ position: relative;
+ display: inline-flex;
+ align-items: flex-end;
+ justify-content: center;
+ width: 1.7rem;
+ height: 1.7rem;
+ gap: 0.14rem;
+ padding: 0.34rem;
+ overflow: hidden;
+ border-radius: 50%;
+ color: var(--paper);
+ background: var(--ink);
+}
+
+.wordmark-symbol i { display: block; width: 0.18rem; border-radius: 1rem; background: currentColor; transform-origin: bottom; }
+.wordmark-symbol i:nth-child(1) { height: 45%; transform: rotate(-12deg); }
+.wordmark-symbol i:nth-child(2) { height: 90%; }
+.wordmark-symbol i:nth-child(3) { height: 62%; transform: rotate(12deg); }
+
+.site-header nav { display: flex; align-items: center; gap: var(--space-xl); color: var(--muted); font-size: 0.82rem; font-weight: 500; }
+.site-header nav a { position: relative; padding-block: 0.45rem; transition: color 180ms cubic-bezier(0.22, 1, 0.36, 1); }
+.site-header nav a:hover { color: var(--ink); }
+.site-header nav a[aria-current="page"] { color: var(--ink); }
+.site-header nav a[aria-current="page"]::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: var(--ink); }
+.site-header nav .logout-link { padding: 0.52rem 0.8rem; border: 1px solid var(--line); border-radius: 0.45rem; color: var(--ink); }
+
+.page-intro {
+ display: grid;
+ grid-template-columns: minmax(0, 1.2fr) minmax(18rem, 0.65fr);
+ align-items: end;
+ gap: var(--space-3xl);
+ padding-block: clamp(3rem, 7vw, 5.5rem) var(--space-3xl);
+}
+
+.detail-label {
+ margin: 0 0 var(--space-sm);
+ color: var(--teal-dark);
+ font-size: 0.68rem;
+ font-weight: 600;
+ letter-spacing: 0.11em;
+ line-height: 1.2;
+ text-transform: uppercase;
+}
+
+h1, h2, p { margin-top: 0; }
+h1, h2 { font-family: "Gabarito", sans-serif; }
+
+.page-intro h1 {
+ max-width: 12ch;
+ margin-bottom: 0;
+ font-size: clamp(2.75rem, 6vw, 5rem);
+ font-weight: 600;
+ letter-spacing: -0.055em;
+ line-height: 0.91;
+}
+
+.intro-copy { max-width: 38ch; margin: 0 0 0.2rem; color: var(--muted); font-size: 1rem; line-height: 1.65; }
+
+.connection-map { position: relative; }
+.service-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-lg); }
+
+.service-panel {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ min-height: 28rem;
+ overflow: hidden;
+ border: 1px solid var(--connected-line);
+ border-radius: 0.8rem;
+ background: var(--connected-bg);
+ transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1), border-color 220ms ease;
+}
+
+.service-panel:hover { transform: translateY(-2px); }
+.service-panel.is-not-connected { border-color: var(--line); background: color-mix(in oklab, var(--paper) 65%, var(--paper-deep)); }
+.service-panel.is-unavailable { border-color: var(--unavailable-line); background: var(--unavailable-bg); }
+
+.service-header { padding: var(--space-xl) var(--space-xl) var(--space-lg); border-bottom: 1px solid color-mix(in oklab, currentColor 10%, transparent); }
+.service-identity { display: flex; align-items: center; gap: var(--space-md); }
+.service-identity h2 { margin-bottom: 0.16rem; font-size: 1.28rem; font-weight: 600; letter-spacing: -0.03em; }
+
+.service-mark {
+ display: grid;
+ place-items: center;
+ width: 2.5rem;
+ height: 2.5rem;
+ flex: 0 0 auto;
+ border: 1px solid color-mix(in oklab, currentColor 13%, transparent);
+ border-radius: 50%;
+ background: color-mix(in oklab, var(--paper) 65%, transparent);
+ font-family: "Gabarito", sans-serif;
+ font-weight: 700;
+}
+
+.spotify-mark { color: oklch(45% 0.17 150); }
+.apple-mark { color: oklch(52% 0.21 20); font-size: 1.2rem; }
+.lastfm-mark { color: oklch(49% 0.2 25); font-size: 0.76rem; letter-spacing: -0.05em; }
+
+.status-label { display: flex; align-items: center; gap: 0.4rem; margin: 0; color: var(--connected); font-size: 0.72rem; font-weight: 500; }
+.is-not-connected .status-label { color: var(--muted); }
+.is-unavailable .status-label { color: var(--muted); }
+.status-dot { display: inline-block; width: 0.42rem; height: 0.42rem; flex: 0 0 auto; border-radius: 50%; background: currentColor; box-shadow: 0 0 0 3px color-mix(in oklab, currentColor 12%, transparent); }
+
+.service-body { display: flex; flex: 1; flex-direction: column; gap: var(--space-xl); padding: var(--space-xl); }
+.account-line { margin: 0; color: var(--muted); font-size: 0.78rem; }
+.account-line strong { color: var(--ink); font-weight: 500; }
+.service-note { max-width: 28ch; margin: auto 0; color: var(--muted); font-size: 0.9rem; line-height: 1.65; }
+.latest-listen { margin-top: auto; }
+.latest-listen .detail-label, .empty-listen .detail-label { color: var(--muted); }
+.track-name { margin-bottom: 0.05rem; font-family: "Gabarito", sans-serif; font-size: 1.4rem; font-weight: 600; letter-spacing: -0.025em; line-height: 1.15; }
+.track-artist { margin-bottom: var(--space-md); color: var(--muted); font-size: 0.85rem; }
+.latest-listen time { display: block; color: var(--faint); font-size: 0.72rem; }
+.empty-listen { margin-top: auto; color: var(--muted); font-size: 0.82rem; }
+.empty-listen p:last-child { max-width: 27ch; margin-bottom: 0; }
+
+.service-actions { display: flex; align-items: center; min-height: 4.2rem; gap: var(--space-md); padding: var(--space-lg) var(--space-xl); border-top: 1px solid color-mix(in oklab, currentColor 10%, transparent); }
+.service-actions button { border-style: solid; cursor: pointer; font-family: inherit; }
+.service-actions button:disabled { cursor: wait; opacity: 0.62; }
+.service-inline-status { min-height: 0; margin: 0; padding-inline: var(--space-xl); color: var(--muted); font-size: 0.7rem; }
+.service-inline-status:not(:empty) { min-height: 2.25rem; padding-bottom: var(--space-lg); }
+.primary-action, .quiet-action, .text-action { display: inline-flex; align-items: center; justify-content: center; min-height: 2.35rem; border-radius: 999px; font-size: 0.76rem; font-weight: 600; transition: transform 160ms cubic-bezier(0.22, 1, 0.36, 1), background 160ms ease; }
+.primary-action { padding-inline: 1rem; color: var(--paper); background: var(--ink); }
+.primary-action:hover { transform: translateY(-1px); background: var(--teal-dark); }
+.quiet-action { padding-inline: 0.9rem; border: 1px solid color-mix(in oklab, var(--ink) 18%, transparent); }
+.quiet-action:hover { background: color-mix(in oklab, var(--paper) 45%, transparent); }
+.text-action { margin-right: auto; padding: 0; border-radius: 0; }
+.text-action span { margin-left: 0.35rem; transition: transform 160ms ease; }
+.text-action:hover span { transform: translate(2px, -2px); }
+
+.connection-lines { position: relative; display: grid; grid-template-columns: repeat(3, 1fr); height: 4.5rem; margin-inline: 8%; }
+.connection-lines::after { content: ""; position: absolute; left: 16.666%; right: 16.666%; bottom: 1.4rem; border-top: 1px solid var(--line); }
+.connection-lines span { position: relative; }
+.connection-lines span::before { content: ""; position: absolute; top: 0; bottom: 1.4rem; left: 50%; border-left: 1px solid var(--line); }
+.connection-lines span:nth-child(2)::after { content: ""; position: absolute; top: calc(100% - 1.4rem); left: 50%; height: 1.4rem; border-left: 1px solid var(--line); }
+
+.atmosphere-account {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ align-items: center;
+ width: min(100%, 36rem);
+ gap: var(--space-lg);
+ margin-inline: auto;
+ padding: 1.2rem 1.35rem;
+ border: 1px solid color-mix(in oklab, var(--teal) 42%, var(--line));
+ border-radius: 0.7rem;
+ background: color-mix(in oklab, var(--teal) 10%, var(--paper));
+ box-shadow: var(--shadow);
+}
+
+.atmosphere-symbol { display: inline-flex; align-items: flex-end; justify-content: center; width: 2.65rem; height: 2.65rem; gap: 0.2rem; padding: 0.62rem; border-radius: 50%; color: var(--paper); background: var(--teal-dark); }
+.atmosphere-avatar { width: 2.65rem; height: 2.65rem; border: 1px solid color-mix(in oklab, var(--teal) 32%, var(--line)); border-radius: 50%; object-fit: cover; }
+.atmosphere-symbol span { display: block; width: 0.22rem; border-radius: 99px; background: currentColor; }
+.atmosphere-symbol span:nth-child(1) { height: 55%; transform: rotate(-10deg); }
+.atmosphere-symbol span:nth-child(2) { height: 100%; }
+.atmosphere-symbol span:nth-child(3) { height: 70%; transform: rotate(10deg); }
+.atmosphere-account .detail-label { margin-bottom: 0.18rem; }
+.atmosphere-account h2 { margin-bottom: 0.08rem; font-size: 1.18rem; font-weight: 600; letter-spacing: -0.025em; }
+.atmosphere-account h2 + p { margin: 0; color: var(--muted); font-size: 0.76rem; }
+.atmosphere-logout { display: inline-block; margin-top: var(--space-md); color: var(--teal-dark); font-size: 0.74rem; font-weight: 600; text-decoration: underline; text-decoration-color: color-mix(in oklab, var(--teal) 45%, transparent); text-underline-offset: 0.2em; }
+.atmosphere-logout:hover { text-decoration-color: currentColor; }
+.record-action { display: inline-flex; align-items: center; width: fit-content; min-height: 2.15rem; margin-top: var(--space-md); padding-inline: 0.85rem; border: 1px solid color-mix(in oklab, var(--teal) 36%, var(--line)); border-radius: 999px; color: var(--teal-dark); font-size: 0.74rem; font-weight: 600; white-space: nowrap; transition: border-color 160ms ease, background 160ms ease; }
+.record-action[hidden] { display: none; }
+.record-action:hover { border-color: var(--teal); background: color-mix(in oklab, var(--mint) 55%, transparent); }
+.record-action span { display: inline-block; margin-left: 0.2rem; transition: transform 160ms ease; }
+.record-action:hover span { transform: translate(2px, -2px); }
+
+.remove-connection { display: flex; align-items: center; justify-content: space-between; gap: var(--space-xl); }
+.remove-connection h2 { margin-bottom: var(--space-sm); }
+.remove-connection p { margin: 0; color: var(--muted); font-size: 0.82rem; }
+.danger-button { min-height: 2.7rem; padding-inline: 1rem; border: 1px solid color-mix(in oklab, var(--warning) 35%, var(--line)); border-radius: 0.4rem; color: var(--warning); background: transparent; cursor: pointer; font-size: 0.78rem; font-weight: 600; }
+.danger-button:hover { background: var(--warning-bg); }
+
+.login-shell { display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(22rem, 0.75fr); align-items: center; min-height: calc(100vh - 5.5rem); gap: clamp(3rem, 9vw, 8rem); padding-block: var(--space-4xl); }
+.login-copy h1 { max-width: 13ch; margin-bottom: var(--space-xl); font-size: clamp(3rem, 6vw, 5.5rem); font-weight: 600; letter-spacing: -0.065em; line-height: 0.94; }
+.login-copy > p:last-child { max-width: 48ch; margin: 0; color: var(--muted); font-size: 1rem; line-height: 1.7; }
+.login-panel { padding: clamp(1.5rem, 4vw, 2.5rem); border: 1px solid var(--line); border-radius: 0.75rem; background: color-mix(in oklab, var(--paper) 80%, var(--paper-deep)); box-shadow: var(--shadow); transform: rotate(0.45deg); transition: transform 240ms cubic-bezier(0.22, 1, 0.36, 1); }
+.login-panel:focus-within { transform: rotate(0deg); }
+.login-panel-heading { display: flex; align-items: center; gap: var(--space-lg); margin-bottom: var(--space-2xl); }
+.login-panel-heading h2 { margin: 0; font-size: 1.45rem; letter-spacing: -0.03em; }
+
+.stacked-form { display: flex; flex-direction: column; gap: var(--space-md); }
+.stacked-form label { font-size: 0.78rem; font-weight: 600; }
+.typeahead-control { position: relative; }
+.typeahead-control::after { content: ""; position: absolute; z-index: 21; top: calc(100% - 2px); right: 0.55rem; left: 0.55rem; height: 2px; border-radius: 99px; background: var(--teal); opacity: 0; transform: scaleX(0); transform-origin: left; }
+.typeahead-control.is-loading::after { opacity: 1; animation: typeahead-progress 1.1s cubic-bezier(0.22, 1, 0.36, 1) infinite; }
+.input-group { display: grid; grid-template-columns: 1fr auto; gap: var(--space-sm); padding: 0.35rem; border: 1px solid var(--line); border-radius: 0.55rem; background: color-mix(in oklab, var(--paper) 88%, var(--paper-deep)); transition: border-color 160ms ease, box-shadow 160ms ease; }
+.input-group:focus-within { border-color: var(--teal); box-shadow: 0 0 0 3px color-mix(in oklab, var(--teal) 14%, transparent); }
+.input-group input { min-width: 0; padding: 0.7rem 0.65rem; border: 0; outline: 0; background: transparent; }
+.input-group input::placeholder { color: var(--faint); }
+.input-group button, .form-button { min-height: 2.7rem; padding-inline: 1rem; border: 0; border-radius: 0.4rem; color: var(--paper); background: var(--teal-dark); cursor: pointer; font-size: 0.78rem; font-weight: 600; }
+.field-help { margin: 0; color: var(--faint); font-size: 0.7rem; line-height: 1.55; }
+
+.typeahead-results {
+ position: absolute;
+ z-index: 20;
+ top: calc(100% + 0.45rem);
+ right: 0;
+ left: 0;
+ max-height: min(22rem, 55vh);
+ overflow-x: hidden;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ padding: 0.32rem;
+ border: 1px solid var(--line);
+ border-radius: 0.65rem;
+ background: var(--paper);
+ box-shadow: 0 14px 30px color-mix(in oklab, var(--ink) 10%, transparent);
+}
+
+.typeahead-option {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: center;
+ width: 100%;
+ gap: var(--space-md);
+ padding: 0.62rem 0.7rem;
+ border: 0;
+ border-radius: 0.4rem;
+ color: var(--ink);
+ background: transparent;
+ cursor: pointer;
+ text-align: left;
+}
+
+.typeahead-option:hover,
+.typeahead-option[aria-selected="true"] {
+ background: var(--paper-deep);
+}
+
+.actor-avatar {
+ display: grid;
+ place-items: center;
+ width: 2.1rem;
+ height: 2.1rem;
+ overflow: hidden;
+ border: 1px solid var(--line);
+ border-radius: 50%;
+ color: var(--teal-dark);
+ background: color-mix(in oklab, var(--teal) 12%, var(--paper));
+ font-family: "Gabarito", sans-serif;
+ font-size: 0.8rem;
+ font-weight: 600;
+}
+
+.actor-avatar img { width: 100%; height: 100%; object-fit: cover; }
+.actor-identity { display: flex; min-width: 0; flex-direction: column; }
+.actor-identity strong, .actor-identity span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.actor-identity strong { font-size: 0.78rem; font-weight: 600; }
+.actor-identity span { color: var(--muted); font-size: 0.68rem; }
+
+@keyframes typeahead-progress {
+ 0% { transform: scaleX(0); transform-origin: left; }
+ 48% { transform: scaleX(0.72); transform-origin: left; }
+ 52% { transform: scaleX(0.72); transform-origin: right; }
+ 100% { transform: scaleX(0); transform-origin: right; }
+}
+
+.content-page { width: min(100%, 48rem); margin-inline: auto; padding-block: var(--space-4xl); }
+.content-page-heading { margin-bottom: var(--space-2xl); }
+.content-page-heading h1 { margin-bottom: var(--space-md); font-size: clamp(2.5rem, 5vw, 4rem); font-weight: 600; letter-spacing: -0.055em; line-height: 1; }
+.content-page-heading p { max-width: 58ch; margin: 0; color: var(--muted); }
+.settings-section { padding-block: var(--space-2xl); border-top: 1px solid var(--line); }
+.settings-section h2 { margin-bottom: var(--space-sm); font-size: 1.4rem; letter-spacing: -0.03em; }
+.settings-section > p { color: var(--muted); }
+.field { display: flex; flex-direction: column; gap: var(--space-sm); margin-top: var(--space-xl); }
+.field label { font-size: 0.78rem; font-weight: 600; }
+.text-input { width: 100%; min-height: 3rem; padding-inline: var(--space-lg); border: 1px solid var(--line); border-radius: 0.75rem; background: color-mix(in oklab, var(--paper) 88%, var(--paper-deep)); outline: 0; }
+.text-input:focus { border-color: var(--teal); box-shadow: 0 0 0 3px color-mix(in oklab, var(--teal) 14%, transparent); }
+.form-actions { display: flex; align-items: center; gap: var(--space-md); margin-top: var(--space-xl); }
+.danger-button { color: oklch(49% 0.16 25); background: transparent; border: 1px solid oklch(76% 0.08 25); }
+.inline-status { margin-top: var(--space-lg); color: var(--muted); font-size: 0.78rem; white-space: pre-wrap; }
+.code-sample { display: block; overflow-x: auto; margin-block: var(--space-md); padding: var(--space-lg); border: 1px solid var(--line); border-radius: 0.7rem; background: var(--paper-deep); font-family: ui-monospace, monospace; font-size: 0.78rem; }
+.key-table { width: 100%; margin-top: var(--space-lg); border-collapse: collapse; font-size: 0.82rem; }
+.key-table th, .key-table td { padding: var(--space-md); border-bottom: 1px solid var(--line); text-align: left; }
+.key-table th { color: var(--muted); font-size: 0.68rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; }
+
+@media (max-width: 56rem) {
+ .page-intro { grid-template-columns: 1fr; gap: var(--space-lg); }
+ .page-intro h1 { max-width: 12ch; }
+ .service-grid { grid-template-columns: 1fr; }
+ .service-panel { min-height: 21rem; }
+ .connection-lines { display: block; height: 3rem; margin: 0; }
+ .connection-lines::after { left: 50%; right: auto; top: 0; bottom: 0; border-top: 0; border-left: 1px solid var(--line); }
+ .connection-lines span { display: none; }
+ .login-shell { grid-template-columns: 1fr; align-content: center; min-height: auto; gap: var(--space-3xl); }
+ .login-copy h1 { max-width: 15ch; }
+}
+
+@media (max-width: 38rem) {
+ .site-shell { width: min(100% - 1.25rem, 77rem); }
+ .site-header { min-height: 4.5rem; }
+ .site-header nav { gap: var(--space-lg); }
+ .site-header nav a[aria-current="page"], .site-header nav .logout-link { display: none; }
+ .page-intro { padding-block: var(--space-3xl) var(--space-xl); }
+ .page-intro h1 { font-size: 2.7rem; }
+ .service-header, .service-body { padding: var(--space-lg); }
+ .service-actions { padding-inline: var(--space-lg); }
+ .atmosphere-account { grid-template-columns: auto 1fr; }
+ .remove-connection { align-items: flex-start; flex-direction: column; }
+ .login-shell { padding-block: var(--space-3xl); }
+ .login-copy h1 { font-size: 3rem; }
+ .input-group { grid-template-columns: 1fr; }
+ .input-group button { width: 100%; }
+ .key-table { display: block; overflow-x: auto; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; }
+}
diff --git a/pages/static/main.css b/pages/static/main.css
index d4dedb1..ad48f0f 100644
--- a/pages/static/main.css
+++ b/pages/static/main.css
@@ -7,23 +7,6 @@
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
- --color-gray-100: oklch(96.7% 0.003 264.542);
- --color-gray-200: oklch(92.8% 0.006 264.531);
- --color-gray-300: oklch(87.2% 0.01 258.338);
- --color-gray-400: oklch(70.7% 0.022 261.325);
- --color-gray-600: oklch(44.6% 0.03 256.802);
- --color-white: #fff;
- --spacing: 0.25rem;
- --text-lg: 1.125rem;
- --text-lg--line-height: calc(1.75 / 1.125);
- --text-xl: 1.25rem;
- --text-xl--line-height: calc(1.75 / 1.25);
- --font-weight-semibold: 600;
- --font-weight-bold: 700;
- --leading-relaxed: 1.625;
- --radius-lg: 0.5rem;
- --default-transition-duration: 150ms;
- --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
--default-font-family: var(--font-sans);
--default-mono-font-family: var(--font-mono);
}
@@ -177,6 +160,20 @@
}
}
@layer utilities {
+ .sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ border-width: 0;
+ }
+ .fixed {
+ position: fixed;
+ }
.static {
position: static;
}
@@ -198,218 +195,1084 @@
max-width: 96rem;
}
}
- .mx-auto {
- margin-inline: auto;
- }
- .my-5 {
- margin-block: calc(var(--spacing) * 5);
- }
- .mt-1 {
- margin-top: calc(var(--spacing) * 1);
- }
- .mt-3 {
- margin-top: calc(var(--spacing) * 3);
- }
- .mb-1 {
- margin-bottom: calc(var(--spacing) * 1);
- }
- .mb-2 {
- margin-bottom: calc(var(--spacing) * 2);
- }
- .mb-3 {
- margin-bottom: calc(var(--spacing) * 3);
- }
- .mb-4 {
- margin-bottom: calc(var(--spacing) * 4);
- }
- .mb-5 {
- margin-bottom: calc(var(--spacing) * 5);
- }
.block {
display: block;
}
.contents {
display: contents;
}
- .flex {
- display: flex;
+ .hidden {
+ display: none;
}
.table {
display: table;
}
- .w-\[95\%\] {
- width: 95%;
- }
- .w-full {
- width: 100%;
- }
- .max-w-\[600px\] {
- max-width: 600px;
- }
- .max-w-\[800px\] {
- max-width: 800px;
- }
- .flex-shrink {
- flex-shrink: 1;
- }
- .border-collapse {
- border-collapse: collapse;
+ .grow {
+ flex-grow: 1;
}
.transform {
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
}
- .cursor-not-allowed {
- cursor: not-allowed;
- }
- .cursor-pointer {
- cursor: pointer;
+ .lowercase {
+ text-transform: lowercase;
}
- .list-disc {
- list-style-type: disc;
+ .filter {
+ 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,);
}
- .flex-wrap {
- flex-wrap: wrap;
+}
+:root {
+ --ink: oklch(24% 0.018 175);
+ --muted: oklch(49% 0.018 175);
+ --faint: oklch(66% 0.012 175);
+ --paper: oklch(97.5% 0.009 155);
+ --paper-deep: oklch(94.5% 0.013 155);
+ --line: oklch(84% 0.016 160);
+ --teal: oklch(55% 0.105 180);
+ --teal-dark: oklch(38% 0.075 180);
+ --connected-bg: oklch(94% 0.055 150);
+ --connected-line: oklch(74% 0.085 150);
+ --connected: oklch(46% 0.12 150);
+ --warning-bg: oklch(94.5% 0.045 25);
+ --warning-line: oklch(78% 0.08 25);
+ --warning: oklch(50% 0.14 25);
+ --unavailable-bg: oklch(95.5% 0.008 175);
+ --unavailable-line: oklch(82% 0.012 175);
+ --shadow: 0 10px 28px var(--ink);
+ @supports (color: color-mix(in lab, red, red)) {
+ --shadow: 0 10px 28px color-mix(in oklab, var(--ink) 6%, transparent);
+ }
+ --space-xs: 0.25rem;
+ --space-sm: 0.5rem;
+ --space-md: 0.75rem;
+ --space-lg: 1rem;
+ --space-xl: 1.5rem;
+ --space-2xl: 2rem;
+ --space-3xl: 3rem;
+ --space-4xl: 4rem;
+ color-scheme: light;
+}
+* {
+ box-sizing: border-box;
+}
+html {
+ min-width: 20rem;
+ background: var(--paper);
+}
+body {
+ margin: 0;
+ color: var(--ink);
+ background: radial-gradient(circle at 50% -20%, var(--teal), transparent 42rem), var(--paper);
+ @supports (color: color-mix(in lab, red, red)) {
+ background: radial-gradient(circle at 50% -20%, color-mix(in oklab, var(--teal) 7%, transparent), transparent 42rem), var(--paper);
+ }
+ font-family: "Geologica", sans-serif;
+ font-size: 0.9375rem;
+ font-weight: 400;
+ line-height: 1.55;
+ -webkit-font-smoothing: antialiased;
+}
+a {
+ color: inherit;
+ text-decoration: none;
+}
+button, input {
+ font: inherit;
+}
+button, a {
+ -webkit-tap-highlight-color: transparent;
+}
+:focus-visible {
+ outline: 3px solid var(--teal);
+ @supports (color: color-mix(in lab, red, red)) {
+ outline: 3px solid color-mix(in oklab, var(--teal) 55%, transparent);
}
- .space-y-2 {
- :where(& > :not(:last-child)) {
- --tw-space-y-reverse: 0;
- margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));
- margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)));
- }
+ outline-offset: 3px;
+}
+::selection {
+ color: var(--ink);
+ background: var(--teal);
+ @supports (color: color-mix(in lab, red, red)) {
+ background: color-mix(in oklab, var(--teal) 25%, transparent);
}
- .gap-x-4 {
- column-gap: calc(var(--spacing) * 4);
+}
+.site-shell {
+ width: min(100% - 2rem, 77rem);
+ margin-inline: auto;
+ padding-bottom: var(--space-4xl);
+}
+.site-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ min-height: 5.5rem;
+ gap: var(--space-xl);
+ border-bottom: 1px solid var(--line);
+ @supports (color: color-mix(in lab, red, red)) {
+ border-bottom: 1px solid color-mix(in oklab, var(--line) 68%, transparent);
}
- .gap-y-1 {
- row-gap: calc(var(--spacing) * 1);
+}
+.wordmark {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.65rem;
+ font-family: "Gabarito", sans-serif;
+ font-size: 1.35rem;
+ font-weight: 700;
+ letter-spacing: -0.035em;
+}
+.wordmark-symbol {
+ position: relative;
+ display: inline-flex;
+ align-items: flex-end;
+ justify-content: center;
+ width: 1.7rem;
+ height: 1.7rem;
+ gap: 0.14rem;
+ padding: 0.34rem;
+ overflow: hidden;
+ border-radius: 50%;
+ color: var(--paper);
+ background: var(--ink);
+}
+.wordmark-symbol i {
+ display: block;
+ width: 0.18rem;
+ border-radius: 1rem;
+ background: currentColor;
+ transform-origin: bottom;
+}
+.wordmark-symbol i:nth-child(1) {
+ height: 45%;
+ transform: rotate(-12deg);
+}
+.wordmark-symbol i:nth-child(2) {
+ height: 90%;
+}
+.wordmark-symbol i:nth-child(3) {
+ height: 62%;
+ transform: rotate(12deg);
+}
+.site-header nav {
+ display: flex;
+ align-items: center;
+ gap: var(--space-xl);
+ color: var(--muted);
+ font-size: 0.82rem;
+ font-weight: 500;
+}
+.site-header nav a {
+ position: relative;
+ padding-block: 0.45rem;
+ transition: color 180ms cubic-bezier(0.22, 1, 0.36, 1);
+}
+.site-header nav a:hover {
+ color: var(--ink);
+}
+.site-header nav a[aria-current="page"] {
+ color: var(--ink);
+}
+.site-header nav a[aria-current="page"]::after {
+ content: "";
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ height: 1px;
+ background: var(--ink);
+}
+.site-header nav .logout-link {
+ padding: 0.52rem 0.8rem;
+ border: 1px solid var(--line);
+ border-radius: 0.45rem;
+ color: var(--ink);
+}
+.page-intro {
+ display: grid;
+ grid-template-columns: minmax(0, 1.2fr) minmax(18rem, 0.65fr);
+ align-items: end;
+ gap: var(--space-3xl);
+ padding-block: clamp(3rem, 7vw, 5.5rem) var(--space-3xl);
+}
+.detail-label {
+ margin: 0 0 var(--space-sm);
+ color: var(--teal-dark);
+ font-size: 0.68rem;
+ font-weight: 600;
+ letter-spacing: 0.11em;
+ line-height: 1.2;
+ text-transform: uppercase;
+}
+h1, h2, p {
+ margin-top: 0;
+}
+h1, h2 {
+ font-family: "Gabarito", sans-serif;
+}
+.page-intro h1 {
+ max-width: 12ch;
+ margin-bottom: 0;
+ font-size: clamp(2.75rem, 6vw, 5rem);
+ font-weight: 600;
+ letter-spacing: -0.055em;
+ line-height: 0.91;
+}
+.intro-copy {
+ max-width: 38ch;
+ margin: 0 0 0.2rem;
+ color: var(--muted);
+ font-size: 1rem;
+ line-height: 1.65;
+}
+.connection-map {
+ position: relative;
+}
+.service-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: var(--space-lg);
+}
+.service-panel {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ min-height: 28rem;
+ overflow: hidden;
+ border: 1px solid var(--connected-line);
+ border-radius: 0.8rem;
+ background: var(--connected-bg);
+ transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1), border-color 220ms ease;
+}
+.service-panel:hover {
+ transform: translateY(-2px);
+}
+.service-panel.is-not-connected {
+ border-color: var(--line);
+ background: var(--paper);
+ @supports (color: color-mix(in lab, red, red)) {
+ background: color-mix(in oklab, var(--paper) 65%, var(--paper-deep));
}
- .rounded {
- border-radius: 0.25rem;
+}
+.service-panel.is-unavailable {
+ border-color: var(--unavailable-line);
+ background: var(--unavailable-bg);
+}
+.service-header {
+ padding: var(--space-xl) var(--space-xl) var(--space-lg);
+ border-bottom: 1px solid currentColor;
+ @supports (color: color-mix(in lab, red, red)) {
+ border-bottom: 1px solid color-mix(in oklab, currentColor 10%, transparent);
}
- .rounded-lg {
- border-radius: var(--radius-lg);
+}
+.service-identity {
+ display: flex;
+ align-items: center;
+ gap: var(--space-md);
+}
+.service-identity h2 {
+ margin-bottom: 0.16rem;
+ font-size: 1.28rem;
+ font-weight: 600;
+ letter-spacing: -0.03em;
+}
+.service-mark {
+ display: grid;
+ place-items: center;
+ width: 2.5rem;
+ height: 2.5rem;
+ flex: 0 0 auto;
+ border: 1px solid currentColor;
+ @supports (color: color-mix(in lab, red, red)) {
+ border: 1px solid color-mix(in oklab, currentColor 13%, transparent);
+ }
+ border-radius: 50%;
+ background: var(--paper);
+ @supports (color: color-mix(in lab, red, red)) {
+ background: color-mix(in oklab, var(--paper) 65%, transparent);
+ }
+ font-family: "Gabarito", sans-serif;
+ font-weight: 700;
+}
+.spotify-mark {
+ color: oklch(45% 0.17 150);
+}
+.apple-mark {
+ color: oklch(52% 0.21 20);
+ font-size: 1.2rem;
+}
+.lastfm-mark {
+ color: oklch(49% 0.2 25);
+ font-size: 0.76rem;
+ letter-spacing: -0.05em;
+}
+.status-label {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ margin: 0;
+ color: var(--connected);
+ font-size: 0.72rem;
+ font-weight: 500;
+}
+.is-not-connected .status-label {
+ color: var(--muted);
+}
+.is-unavailable .status-label {
+ color: var(--muted);
+}
+.status-dot {
+ display: inline-block;
+ width: 0.42rem;
+ height: 0.42rem;
+ flex: 0 0 auto;
+ border-radius: 50%;
+ background: currentColor;
+ box-shadow: 0 0 0 3px currentColor;
+ @supports (color: color-mix(in lab, red, red)) {
+ box-shadow: 0 0 0 3px color-mix(in oklab, currentColor 12%, transparent);
}
- .border {
- border-style: var(--tw-border-style);
- border-width: 1px;
+}
+.service-body {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ gap: var(--space-xl);
+ padding: var(--space-xl);
+}
+.account-line {
+ margin: 0;
+ color: var(--muted);
+ font-size: 0.78rem;
+}
+.account-line strong {
+ color: var(--ink);
+ font-weight: 500;
+}
+.service-note {
+ max-width: 28ch;
+ margin: auto 0;
+ color: var(--muted);
+ font-size: 0.9rem;
+ line-height: 1.65;
+}
+.latest-listen {
+ margin-top: auto;
+}
+.latest-listen .detail-label, .empty-listen .detail-label {
+ color: var(--muted);
+}
+.track-name {
+ margin-bottom: 0.05rem;
+ font-family: "Gabarito", sans-serif;
+ font-size: 1.4rem;
+ font-weight: 600;
+ letter-spacing: -0.025em;
+ line-height: 1.15;
+}
+.track-artist {
+ margin-bottom: var(--space-md);
+ color: var(--muted);
+ font-size: 0.85rem;
+}
+.latest-listen time {
+ display: block;
+ color: var(--faint);
+ font-size: 0.72rem;
+}
+.empty-listen {
+ margin-top: auto;
+ color: var(--muted);
+ font-size: 0.82rem;
+}
+.empty-listen p:last-child {
+ max-width: 27ch;
+ margin-bottom: 0;
+}
+.service-actions {
+ display: flex;
+ align-items: center;
+ min-height: 4.2rem;
+ gap: var(--space-md);
+ padding: var(--space-lg) var(--space-xl);
+ border-top: 1px solid currentColor;
+ @supports (color: color-mix(in lab, red, red)) {
+ border-top: 1px solid color-mix(in oklab, currentColor 10%, transparent);
}
- .border-b {
- border-bottom-style: var(--tw-border-style);
- border-bottom-width: 1px;
+}
+.service-actions button {
+ border-style: solid;
+ cursor: pointer;
+ font-family: inherit;
+}
+.service-actions button:disabled {
+ cursor: wait;
+ opacity: 0.62;
+}
+.service-inline-status {
+ min-height: 0;
+ margin: 0;
+ padding-inline: var(--space-xl);
+ color: var(--muted);
+ font-size: 0.7rem;
+}
+.service-inline-status:not(:empty) {
+ min-height: 2.25rem;
+ padding-bottom: var(--space-lg);
+}
+.primary-action, .quiet-action, .text-action {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 2.35rem;
+ border-radius: 999px;
+ font-size: 0.76rem;
+ font-weight: 600;
+ transition: transform 160ms cubic-bezier(0.22, 1, 0.36, 1), background 160ms ease;
+}
+.primary-action {
+ padding-inline: 1rem;
+ color: var(--paper);
+ background: var(--ink);
+}
+.primary-action:hover {
+ transform: translateY(-1px);
+ background: var(--teal-dark);
+}
+.quiet-action {
+ padding-inline: 0.9rem;
+ border: 1px solid var(--ink);
+ @supports (color: color-mix(in lab, red, red)) {
+ border: 1px solid color-mix(in oklab, var(--ink) 18%, transparent);
}
- .border-l-4 {
- border-left-style: var(--tw-border-style);
- border-left-width: 4px;
+}
+.quiet-action:hover {
+ background: var(--paper);
+ @supports (color: color-mix(in lab, red, red)) {
+ background: color-mix(in oklab, var(--paper) 45%, transparent);
}
- .border-\[\#1DB954\] {
- border-color: #1DB954;
+}
+.text-action {
+ margin-right: auto;
+ padding: 0;
+ border-radius: 0;
+}
+.text-action span {
+ margin-left: 0.35rem;
+ transition: transform 160ms ease;
+}
+.text-action:hover span {
+ transform: translate(2px, -2px);
+}
+.connection-lines {
+ position: relative;
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ height: 4.5rem;
+ margin-inline: 8%;
+}
+.connection-lines::after {
+ content: "";
+ position: absolute;
+ left: 16.666%;
+ right: 16.666%;
+ bottom: 1.4rem;
+ border-top: 1px solid var(--line);
+}
+.connection-lines span {
+ position: relative;
+}
+.connection-lines span::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ bottom: 1.4rem;
+ left: 50%;
+ border-left: 1px solid var(--line);
+}
+.connection-lines span:nth-child(2)::after {
+ content: "";
+ position: absolute;
+ top: calc(100% - 1.4rem);
+ left: 50%;
+ height: 1.4rem;
+ border-left: 1px solid var(--line);
+}
+.atmosphere-account {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ align-items: center;
+ width: min(100%, 36rem);
+ gap: var(--space-lg);
+ margin-inline: auto;
+ padding: 1.2rem 1.35rem;
+ border: 1px solid var(--teal);
+ @supports (color: color-mix(in lab, red, red)) {
+ border: 1px solid color-mix(in oklab, var(--teal) 42%, var(--line));
+ }
+ border-radius: 0.7rem;
+ background: var(--teal);
+ @supports (color: color-mix(in lab, red, red)) {
+ background: color-mix(in oklab, var(--teal) 10%, var(--paper));
+ }
+ box-shadow: var(--shadow);
+}
+.atmosphere-symbol {
+ display: inline-flex;
+ align-items: flex-end;
+ justify-content: center;
+ width: 2.65rem;
+ height: 2.65rem;
+ gap: 0.2rem;
+ padding: 0.62rem;
+ border-radius: 50%;
+ color: var(--paper);
+ background: var(--teal-dark);
+}
+.atmosphere-avatar {
+ width: 2.65rem;
+ height: 2.65rem;
+ border: 1px solid var(--teal);
+ @supports (color: color-mix(in lab, red, red)) {
+ border: 1px solid color-mix(in oklab, var(--teal) 32%, var(--line));
+ }
+ border-radius: 50%;
+ object-fit: cover;
+}
+.atmosphere-symbol span {
+ display: block;
+ width: 0.22rem;
+ border-radius: 99px;
+ background: currentColor;
+}
+.atmosphere-symbol span:nth-child(1) {
+ height: 55%;
+ transform: rotate(-10deg);
+}
+.atmosphere-symbol span:nth-child(2) {
+ height: 100%;
+}
+.atmosphere-symbol span:nth-child(3) {
+ height: 70%;
+ transform: rotate(10deg);
+}
+.atmosphere-account .detail-label {
+ margin-bottom: 0.18rem;
+}
+.atmosphere-account h2 {
+ margin-bottom: 0.08rem;
+ font-size: 1.18rem;
+ font-weight: 600;
+ letter-spacing: -0.025em;
+}
+.atmosphere-account h2 + p {
+ margin: 0;
+ color: var(--muted);
+ font-size: 0.76rem;
+}
+.atmosphere-logout {
+ display: inline-block;
+ margin-top: var(--space-md);
+ color: var(--teal-dark);
+ font-size: 0.74rem;
+ font-weight: 600;
+ text-decoration: underline;
+ text-decoration-color: var(--teal);
+ @supports (color: color-mix(in lab, red, red)) {
+ text-decoration-color: color-mix(in oklab, var(--teal) 45%, transparent);
+ }
+ text-underline-offset: 0.2em;
+}
+.atmosphere-logout:hover {
+ text-decoration-color: currentColor;
+}
+.record-action {
+ display: inline-flex;
+ align-items: center;
+ width: fit-content;
+ min-height: 2.15rem;
+ margin-top: var(--space-md);
+ padding-inline: 0.85rem;
+ border: 1px solid var(--teal);
+ @supports (color: color-mix(in lab, red, red)) {
+ border: 1px solid color-mix(in oklab, var(--teal) 36%, var(--line));
+ }
+ border-radius: 999px;
+ color: var(--teal-dark);
+ font-size: 0.74rem;
+ font-weight: 600;
+ white-space: nowrap;
+ transition: border-color 160ms ease, background 160ms ease;
+}
+.record-action[hidden] {
+ display: none;
+}
+.record-action:hover {
+ border-color: var(--teal);
+ background: var(--mint);
+ @supports (color: color-mix(in lab, red, red)) {
+ background: color-mix(in oklab, var(--mint) 55%, transparent);
}
- .border-gray-200 {
- border-color: var(--color-gray-200);
+}
+.record-action span {
+ display: inline-block;
+ margin-left: 0.2rem;
+ transition: transform 160ms ease;
+}
+.record-action:hover span {
+ transform: translate(2px, -2px);
+}
+.remove-connection {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-xl);
+}
+.remove-connection h2 {
+ margin-bottom: var(--space-sm);
+}
+.remove-connection p {
+ margin: 0;
+ color: var(--muted);
+ font-size: 0.82rem;
+}
+.danger-button {
+ min-height: 2.7rem;
+ padding-inline: 1rem;
+ border: 1px solid var(--warning);
+ @supports (color: color-mix(in lab, red, red)) {
+ border: 1px solid color-mix(in oklab, var(--warning) 35%, var(--line));
+ }
+ border-radius: 0.4rem;
+ color: var(--warning);
+ background: transparent;
+ cursor: pointer;
+ font-size: 0.78rem;
+ font-weight: 600;
+}
+.danger-button:hover {
+ background: var(--warning-bg);
+}
+.login-shell {
+ display: grid;
+ grid-template-columns: minmax(0, 1.05fr) minmax(22rem, 0.75fr);
+ align-items: center;
+ min-height: calc(100vh - 5.5rem);
+ gap: clamp(3rem, 9vw, 8rem);
+ padding-block: var(--space-4xl);
+}
+.login-copy h1 {
+ max-width: 13ch;
+ margin-bottom: var(--space-xl);
+ font-size: clamp(3rem, 6vw, 5.5rem);
+ font-weight: 600;
+ letter-spacing: -0.065em;
+ line-height: 0.94;
+}
+.login-copy > p:last-child {
+ max-width: 48ch;
+ margin: 0;
+ color: var(--muted);
+ font-size: 1rem;
+ line-height: 1.7;
+}
+.login-panel {
+ padding: clamp(1.5rem, 4vw, 2.5rem);
+ border: 1px solid var(--line);
+ border-radius: 0.75rem;
+ background: var(--paper);
+ @supports (color: color-mix(in lab, red, red)) {
+ background: color-mix(in oklab, var(--paper) 80%, var(--paper-deep));
+ }
+ box-shadow: var(--shadow);
+ transform: rotate(0.45deg);
+ transition: transform 240ms cubic-bezier(0.22, 1, 0.36, 1);
+}
+.login-panel:focus-within {
+ transform: rotate(0deg);
+}
+.login-panel-heading {
+ display: flex;
+ align-items: center;
+ gap: var(--space-lg);
+ margin-bottom: var(--space-2xl);
+}
+.login-panel-heading h2 {
+ margin: 0;
+ font-size: 1.45rem;
+ letter-spacing: -0.03em;
+}
+.stacked-form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-md);
+}
+.stacked-form label {
+ font-size: 0.78rem;
+ font-weight: 600;
+}
+.typeahead-control {
+ position: relative;
+}
+.typeahead-control::after {
+ content: "";
+ position: absolute;
+ z-index: 21;
+ top: calc(100% - 2px);
+ right: 0.55rem;
+ left: 0.55rem;
+ height: 2px;
+ border-radius: 99px;
+ background: var(--teal);
+ opacity: 0;
+ transform: scaleX(0);
+ transform-origin: left;
+}
+.typeahead-control.is-loading::after {
+ opacity: 1;
+ animation: typeahead-progress 1.1s cubic-bezier(0.22, 1, 0.36, 1) infinite;
+}
+.input-group {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: var(--space-sm);
+ padding: 0.35rem;
+ border: 1px solid var(--line);
+ border-radius: 0.55rem;
+ background: var(--paper);
+ @supports (color: color-mix(in lab, red, red)) {
+ background: color-mix(in oklab, var(--paper) 88%, var(--paper-deep));
+ }
+ transition: border-color 160ms ease, box-shadow 160ms ease;
+}
+.input-group:focus-within {
+ border-color: var(--teal);
+ box-shadow: 0 0 0 3px var(--teal);
+ @supports (color: color-mix(in lab, red, red)) {
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--teal) 14%, transparent);
}
- .border-gray-300 {
- border-color: var(--color-gray-300);
+}
+.input-group input {
+ min-width: 0;
+ padding: 0.7rem 0.65rem;
+ border: 0;
+ outline: 0;
+ background: transparent;
+}
+.input-group input::placeholder {
+ color: var(--faint);
+}
+.input-group button, .form-button {
+ min-height: 2.7rem;
+ padding-inline: 1rem;
+ border: 0;
+ border-radius: 0.4rem;
+ color: var(--paper);
+ background: var(--teal-dark);
+ cursor: pointer;
+ font-size: 0.78rem;
+ font-weight: 600;
+}
+.field-help {
+ margin: 0;
+ color: var(--faint);
+ font-size: 0.7rem;
+ line-height: 1.55;
+}
+.typeahead-results {
+ position: absolute;
+ z-index: 20;
+ top: calc(100% + 0.45rem);
+ right: 0;
+ left: 0;
+ max-height: min(22rem, 55vh);
+ overflow-x: hidden;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ padding: 0.32rem;
+ border: 1px solid var(--line);
+ border-radius: 0.65rem;
+ background: var(--paper);
+ box-shadow: 0 14px 30px var(--ink);
+ @supports (color: color-mix(in lab, red, red)) {
+ box-shadow: 0 14px 30px color-mix(in oklab, var(--ink) 10%, transparent);
}
- .bg-\[\#1DB954\] {
- background-color: #1DB954;
+}
+.typeahead-option {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: center;
+ width: 100%;
+ gap: var(--space-md);
+ padding: 0.62rem 0.7rem;
+ border: 0;
+ border-radius: 0.4rem;
+ color: var(--ink);
+ background: transparent;
+ cursor: pointer;
+ text-align: left;
+}
+.typeahead-option:hover, .typeahead-option[aria-selected="true"] {
+ background: var(--paper-deep);
+}
+.actor-avatar {
+ display: grid;
+ place-items: center;
+ width: 2.1rem;
+ height: 2.1rem;
+ overflow: hidden;
+ border: 1px solid var(--line);
+ border-radius: 50%;
+ color: var(--teal-dark);
+ background: var(--teal);
+ @supports (color: color-mix(in lab, red, red)) {
+ background: color-mix(in oklab, var(--teal) 12%, var(--paper));
+ }
+ font-family: "Gabarito", sans-serif;
+ font-size: 0.8rem;
+ font-weight: 600;
+}
+.actor-avatar img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+.actor-identity {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+}
+.actor-identity strong, .actor-identity span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.actor-identity strong {
+ font-size: 0.78rem;
+ font-weight: 600;
+}
+.actor-identity span {
+ color: var(--muted);
+ font-size: 0.68rem;
+}
+@keyframes typeahead-progress {
+ 0% {
+ transform: scaleX(0);
+ transform-origin: left;
}
- .bg-\[\#d51007\] {
- background-color: #d51007;
+ 48% {
+ transform: scaleX(0.72);
+ transform-origin: left;
}
- .bg-\[\#dc3545\] {
- background-color: #dc3545;
+ 52% {
+ transform: scaleX(0.72);
+ transform-origin: right;
}
- .bg-gray-100 {
- background-color: var(--color-gray-100);
+ 100% {
+ transform: scaleX(0);
+ transform-origin: right;
}
- .p-2 {
- padding: calc(var(--spacing) * 2);
+}
+.content-page {
+ width: min(100%, 48rem);
+ margin-inline: auto;
+ padding-block: var(--space-4xl);
+}
+.content-page-heading {
+ margin-bottom: var(--space-2xl);
+}
+.content-page-heading h1 {
+ margin-bottom: var(--space-md);
+ font-size: clamp(2.5rem, 5vw, 4rem);
+ font-weight: 600;
+ letter-spacing: -0.055em;
+ line-height: 1;
+}
+.content-page-heading p {
+ max-width: 58ch;
+ margin: 0;
+ color: var(--muted);
+}
+.settings-section {
+ padding-block: var(--space-2xl);
+ border-top: 1px solid var(--line);
+}
+.settings-section h2 {
+ margin-bottom: var(--space-sm);
+ font-size: 1.4rem;
+ letter-spacing: -0.03em;
+}
+.settings-section > p {
+ color: var(--muted);
+}
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-sm);
+ margin-top: var(--space-xl);
+}
+.field label {
+ font-size: 0.78rem;
+ font-weight: 600;
+}
+.text-input {
+ width: 100%;
+ min-height: 3rem;
+ padding-inline: var(--space-lg);
+ border: 1px solid var(--line);
+ border-radius: 0.75rem;
+ background: var(--paper);
+ @supports (color: color-mix(in lab, red, red)) {
+ background: color-mix(in oklab, var(--paper) 88%, var(--paper-deep));
+ }
+ outline: 0;
+}
+.text-input:focus {
+ border-color: var(--teal);
+ box-shadow: 0 0 0 3px var(--teal);
+ @supports (color: color-mix(in lab, red, red)) {
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--teal) 14%, transparent);
}
- .p-4 {
- padding: calc(var(--spacing) * 4);
+}
+.form-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--space-md);
+ margin-top: var(--space-xl);
+}
+.danger-button {
+ color: oklch(49% 0.16 25);
+ background: transparent;
+ border: 1px solid oklch(76% 0.08 25);
+}
+.inline-status {
+ margin-top: var(--space-lg);
+ color: var(--muted);
+ font-size: 0.78rem;
+ white-space: pre-wrap;
+}
+.code-sample {
+ display: block;
+ overflow-x: auto;
+ margin-block: var(--space-md);
+ padding: var(--space-lg);
+ border: 1px solid var(--line);
+ border-radius: 0.7rem;
+ background: var(--paper-deep);
+ font-family: ui-monospace, monospace;
+ font-size: 0.78rem;
+}
+.key-table {
+ width: 100%;
+ margin-top: var(--space-lg);
+ border-collapse: collapse;
+ font-size: 0.82rem;
+}
+.key-table th, .key-table td {
+ padding: var(--space-md);
+ border-bottom: 1px solid var(--line);
+ text-align: left;
+}
+.key-table th {
+ color: var(--muted);
+ font-size: 0.68rem;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+@media (max-width: 56rem) {
+ .page-intro {
+ grid-template-columns: 1fr;
+ gap: var(--space-lg);
}
- .p-5 {
- padding: calc(var(--spacing) * 5);
+ .page-intro h1 {
+ max-width: 12ch;
}
- .px-3 {
- padding-inline: calc(var(--spacing) * 3);
+ .service-grid {
+ grid-template-columns: 1fr;
}
- .px-4 {
- padding-inline: calc(var(--spacing) * 4);
+ .service-panel {
+ min-height: 21rem;
}
- .py-1\.5 {
- padding-block: calc(var(--spacing) * 1.5);
+ .connection-lines {
+ display: block;
+ height: 3rem;
+ margin: 0;
}
- .py-2 {
- padding-block: calc(var(--spacing) * 2);
+ .connection-lines::after {
+ left: 50%;
+ right: auto;
+ top: 0;
+ bottom: 0;
+ border-top: 0;
+ border-left: 1px solid var(--line);
}
- .py-2\.5 {
- padding-block: calc(var(--spacing) * 2.5);
+ .connection-lines span {
+ display: none;
}
- .pl-5 {
- padding-left: calc(var(--spacing) * 5);
+ .login-shell {
+ grid-template-columns: 1fr;
+ align-content: center;
+ min-height: auto;
+ gap: var(--space-3xl);
}
- .text-left {
- text-align: left;
+ .login-copy h1 {
+ max-width: 15ch;
}
- .font-mono {
- font-family: var(--font-mono);
+}
+@media (max-width: 38rem) {
+ .site-shell {
+ width: min(100% - 1.25rem, 77rem);
}
- .font-sans {
- font-family: var(--font-sans);
+ .site-header {
+ min-height: 4.5rem;
}
- .text-lg {
- font-size: var(--text-lg);
- line-height: var(--tw-leading, var(--text-lg--line-height));
+ .site-header nav {
+ gap: var(--space-lg);
}
- .text-xl {
- font-size: var(--text-xl);
- line-height: var(--tw-leading, var(--text-xl--line-height));
+ .site-header nav a[aria-current="page"], .site-header nav .logout-link {
+ display: none;
}
- .leading-relaxed {
- --tw-leading: var(--leading-relaxed);
- line-height: var(--leading-relaxed);
+ .page-intro {
+ padding-block: var(--space-3xl) var(--space-xl);
}
- .font-bold {
- --tw-font-weight: var(--font-weight-bold);
- font-weight: var(--font-weight-bold);
+ .page-intro h1 {
+ font-size: 2.7rem;
}
- .font-semibold {
- --tw-font-weight: var(--font-weight-semibold);
- font-weight: var(--font-weight-semibold);
+ .service-header, .service-body {
+ padding: var(--space-lg);
}
- .text-\[\#1DB954\] {
- color: #1DB954;
+ .service-actions {
+ padding-inline: var(--space-lg);
}
- .text-gray-400 {
- color: var(--color-gray-400);
+ .atmosphere-account {
+ grid-template-columns: auto 1fr;
}
- .text-gray-600 {
- color: var(--color-gray-600);
+ .remove-connection {
+ align-items: flex-start;
+ flex-direction: column;
}
- .text-white {
- color: var(--color-white);
+ .login-shell {
+ padding-block: var(--space-3xl);
}
- .italic {
- font-style: italic;
+ .login-copy h1 {
+ font-size: 3rem;
}
- .no-underline {
- text-decoration-line: none;
+ .input-group {
+ grid-template-columns: 1fr;
}
- .filter {
- 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,);
+ .input-group button {
+ width: 100%;
}
- .transition {
- transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
- transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
- transition-duration: var(--tw-duration, var(--default-transition-duration));
+ .key-table {
+ display: block;
+ overflow-x: auto;
}
- .hover\:opacity-90 {
- &:hover {
- @media (hover: hover) {
- opacity: 90%;
- }
- }
+}
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ scroll-behavior: auto !important;
+ transition-duration: 0.01ms !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
}
}
@property --tw-rotate-x {
@@ -432,24 +1295,6 @@
syntax: "*";
inherits: false;
}
-@property --tw-space-y-reverse {
- syntax: "*";
- inherits: false;
- initial-value: 0;
-}
-@property --tw-border-style {
- syntax: "*";
- inherits: false;
- initial-value: solid;
-}
-@property --tw-leading {
- syntax: "*";
- inherits: false;
-}
-@property --tw-font-weight {
- syntax: "*";
- inherits: false;
-}
@property --tw-blur {
syntax: "*";
inherits: false;
@@ -511,10 +1356,6 @@
--tw-rotate-z: initial;
--tw-skew-x: initial;
--tw-skew-y: initial;
- --tw-space-y-reverse: 0;
- --tw-border-style: solid;
- --tw-leading: initial;
- --tw-font-weight: initial;
--tw-blur: initial;
--tw-brightness: initial;
--tw-contrast: initial;
diff --git a/pages/templates/apiKeys.gohtml b/pages/templates/apiKeys.gohtml
index c0d13bc..e558056 100644
--- a/pages/templates/apiKeys.gohtml
+++ b/pages/templates/apiKeys.gohtml
@@ -1,90 +1,72 @@
-
{{ define "content" }}
-
{{ template "components/navBar" .NavBar }}
+
+
+ API keys
+ Create a key when another app needs to read or submit listening data through your Piper account.
+
+ {{ if .NewKey }}
+
+ {{ .NewKey }}
+ Store this API key now. Piper will not show it again.
+
+ {{ end }}
-API Key Management
-
-
-
Create New API Key
-
API keys allow programmatic access to your Piper account data.
-
+
-{{if .NewKeyID}}
-
-
Your new API key (ID: {{.NewKeyID}}) has been created
-
-
Important: 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.
-
-{{end}}
+
+ Your keys
+ {{ if .Keys }}
+
+ Name Key Created Expires Actions
+
+ {{ range .Keys }}
+
+ {{ .Name }}
+ {{ .KeyPrefix }}…
+ {{ formatTime .CreatedAt }}
+ {{ formatTime .ExpiresAt }}
+ Delete
+
+ {{ end }}
+
+
+ {{ else }}
+ You have not created any API keys.
+ {{ end }}
+
-
-
Your API Keys
- {{if .Keys}}
-
-
-
- Name
- Created
- Expires
- Actions
-
-
-
- {{range .Keys}}
-
- {{.Name}}
- {{formatTime .CreatedAt}}
- {{formatTime .ExpiresAt}}
-
- Delete
-
-
- {{end}}
-
-
- {{else}}
-
You don't have any API keys yet.
- {{end}}
-
-
-
-
API Usage
-
To use your API key, include it in the Authorization header of your HTTP requests:
-
Authorization: Bearer YOUR_API_KEY
-
Or include it as a query parameter (less secure for the key itself):
-
https://your-piper-instance.com/endpoint?api_key=YOUR_API_KEY
-
+
+ Using a key
+ Send the key in the Authorization header of each request.
+ Authorization: Bearer YOUR_API_KEY
+ Query parameters also work, but URLs often appear in logs. Prefer the header.
+ https://your-piper-instance.com/endpoint?api_key=YOUR_API_KEY
+
+
-
-{{ end }}
\ No newline at end of file
+{{ end }}
diff --git a/pages/templates/applemusic_link.gohtml b/pages/templates/applemusic_link.gohtml
deleted file mode 100644
index a5357da..0000000
--- a/pages/templates/applemusic_link.gohtml
+++ /dev/null
@@ -1,244 +0,0 @@
-{{ define "applemusic_link" }}
-{{ template "layouts/base" . }}
-{{ end }}
-
-{{ define "layouts/base" }}
-
-
-
-
- Link Apple Music
-
-
-
-
-
-
- Link Apple Music
-
-
- Authorize with Apple Music to enable MusicKit features and sync your
- library.
-
-
-
- 🎵 Authorize Apple Music
-
-
-
-
-
-
- Unlink Apple Music
-
-
-
-
-
-
-
-
-
-{{ end }}
diff --git a/pages/templates/components/navBar.gohtml b/pages/templates/components/navBar.gohtml
index dcdb94c..e164587 100644
--- a/pages/templates/components/navBar.gohtml
+++ b/pages/templates/components/navBar.gohtml
@@ -1,50 +1,16 @@
{{ define "components/navBar" }}
-
-
- Home
-
- {{if .IsLoggedIn}}
- {{ if .SpotifyEnabled }}
- Spotify Current
- Spotify History
- Connect Spotify Account
- {{ else }}
- Spotify (disabled)
- {{ end }}
-
- {{ if .LastFMEnabled }}
- Link Last.fm
- {{ if .LastFMUsername }}
- Last.fm Recent
- {{ end }}
- {{ else }}
- Last.fm (disabled)
- {{ end }}
-
- {{ if .AppleMusicEnabled }}
- Link Apple Music
- {{ else }}
- Apple Music (disabled)
- {{ end }}
-
- API Keys
- Logout
- {{ else }}
- Login with ATProto
- {{ end }}
-
+
{{ end }}
diff --git a/pages/templates/home.gohtml b/pages/templates/home.gohtml
index 513bbb4..2617a6e 100644
--- a/pages/templates/home.gohtml
+++ b/pages/templates/home.gohtml
@@ -1,112 +1,554 @@
{{ define "content" }}
-
-
- Piper - Multi-User Spotify & Last.fm Tracker via ATProto
-
{{ template "components/navBar" .NavBar }}
-
-
Welcome to Piper
-
- Piper is a multi-user application that records what you're listening to on
- Spotify and Last.fm, saving your listening history.
-
-
- {{if .NavBar.IsLoggedIn}}
-
You're logged in!
-
- {{ if .NavBar.SpotifyEnabled }}
-
- Connect your Spotify account
- to start tracking.
-
- {{ else }}
-
- Spotify tracking (disabled on this server)
-
- {{ end }}
+{{ if .NavBar.IsLoggedIn }}
+
+
+
your music, piped into one place.
+
+ See which sources you use and the latest listen Piper received from each one.
+
- {{ if .NavBar.LastFMEnabled }}
-
- Link your Last.fm account
- to track scrobbles.
-
- {{ else }}
-
- Last.fm tracking (disabled on this server)
-
- {{ end }}
+
+
+
+
+
+ {{ if not .NavBar.SpotifyEnabled }}
+
The server owner has turned off Spotify tracking.
+ {{ else if and .User .User.SpotifyID }}
+
Signed in as {{ .User.SpotifyID }}
+ {{ if .SpotifyTrack }}
+
+
Last tracked play
+
{{ .SpotifyTrack.Name }}
+
{{ artistNames .SpotifyTrack.Artist }}
+
{{ formatTime .SpotifyTrack.Timestamp }}
+
View Record ↗
+
+ {{ else }}
+
Last tracked play
Nothing yet. Play something on Spotify and Piper will pick it up.
+ {{ end }}
+ {{ else }}
+
Connect Spotify to start recording what you play.
+ {{ end }}
+
+
+
+
+
+
+
+ {{ if not .NavBar.AppleMusicEnabled }}
+
The server owner has turned off Apple Music tracking.
+ {{ else if and .User .User.AppleMusicUserToken }}
+
MusicKit authorization is active.
+ {{ if .AppleMusicTrack }}
+
+
Last tracked play
+
{{ .AppleMusicTrack.Name }}
+
{{ artistNames .AppleMusicTrack.Artist }}
+
{{ formatTime .AppleMusicTrack.Timestamp }}
+
View Record ↗
+
+ {{ else }}
+
Last tracked play
Nothing yet. Your next recent play will appear here.
+ {{ end }}
+ {{ else }}
+
Authorize Apple Music to import your recent plays.
+ {{ end }}
+
+
+ {{ if .NavBar.AppleMusicEnabled }}
+ {{ if and .User .User.AppleMusicUserToken }}
+ Unlink Apple Music
+ {{ else }}
+ Connect Apple Music
+ {{ end }}
+ {{ end }}
+
+
+
- {{ if .NavBar.AppleMusicEnabled }}
-
- Link your Apple Music account
- to fetch recently played.
-
+
+
+
+ {{ if not .NavBar.LastFMEnabled }}
+
The server owner has turned off Last.fm syncing.
+ {{ else if .NavBar.LastFMUsername }}
+
Syncing {{ .NavBar.LastFMUsername }}
+ {{ if .LastFMTrack }}
+
+
Last tracked play
+
{{ .LastFMTrack.Name }}
+
{{ artistNames .LastFMTrack.Artist }}
+
{{ formatTime .LastFMTrack.Timestamp }}
+
View Record ↗
+
+ {{ else }}
+
Last tracked play
No scrobbles have reached Piper yet.
+ {{ end }}
+ {{ else }}
+
Add your Last.fm username to begin syncing scrobbles.
+ {{ end }}
+
+
+
+
+
+
+
+
+ {{ if .Atmosphere.AvatarURL }}
+
{{ else }}
-
- Apple Music tracking (disabled on this server)
-
- {{ end }}
-
-
Once connected, you can check out your:
-
-
- You can also manage your
- API keys for
- programmatic access.
-
-
- {{ if .NavBar.LastFMUsername }}
-
- Last.fm Username: {{ .NavBar.LastFMUsername }}
-
- {{else }}
-
Last.fm account not linked.
- {{ end }}
-
- {{ else }}
-
-
Login with ATProto to get started!
-
- handle:
-
-
-
-
- {{ end }}
-
-
+
+
Atmosphere account
+
{{ if .Atmosphere.Handle }}@{{ .Atmosphere.Handle }}{{ else }}Loading account…{{ end }}
+
PDS: {{ if .Atmosphere.PDS }}{{ .Atmosphere.PDS }}{{ else }}Loading…{{ end }}
+
Logout
+
+
+
+
+
+
+{{ if .NavBar.AppleMusicEnabled }}
+
+{{ end }}
+
+{{ else }}
+
+
+ your music, in your atmosphere.
+ Connect the services you listen with. Piper sends each play to the Atmosphere account you own.
+
+
+
+
+
Sign in with ATProto
+
+
+ Your handle
+
+
+ Piper uses your handle to start ATProto sign-in. It never asks for your password.
+
+
+
+
+
+{{ end }}
{{ end }}
diff --git a/pages/templates/lastFMForm.gohtml b/pages/templates/lastFMForm.gohtml
index 923f56c..5b0d3d8 100644
--- a/pages/templates/lastFMForm.gohtml
+++ b/pages/templates/lastFMForm.gohtml
@@ -1,14 +1,33 @@
{{ define "content" }}
- {{ template "components/navBar" .NavBar }}
-
-
-
Link Your Last.fm Account
-
Enter your Last.fm username to start tracking your scrobbles.
-
- Last.fm Username:
-
-
-
+{{ template "components/navBar" .NavBar }}
+
+
+ {{ if .CurrentUsername }}Update your username.{{ else }}Connect Last.fm.{{ end }}
+ Piper reads your public scrobbles and adds them to the listening record tied to your Atmosphere account.
+
+
+ {{ if .CurrentUsername }}
+
+
+
Remove Last.fm
+
Stop syncing this account and remove the username from Piper.
-
-{{ end }}
\ No newline at end of file
+
+ Remove Last.fm account
+
+
+ {{ end }}
+
+{{ end }}
diff --git a/pages/templates/layouts/base.gohtml b/pages/templates/layouts/base.gohtml
index 62900b8..b885014 100644
--- a/pages/templates/layouts/base.gohtml
+++ b/pages/templates/layouts/base.gohtml
@@ -1,13 +1,20 @@
{{ define "layouts/base" }}
-
+
-
Piper - Spotify & Last.fm Tracker
-
+
+
+
+
Piper · Listening connections
+
+
+
+
-
-{{ block "content" . }}{{ end }}
-
+
+
+ {{ block "content" . }}{{ end }}
+
{{ end }}
diff --git a/service/apikey/apikey.go b/service/apikey/apikey.go
index 7abcd7e..cc8aa71 100644
--- a/service/apikey/apikey.go
+++ b/service/apikey/apikey.go
@@ -5,6 +5,7 @@ import (
"fmt"
"log"
"net/http"
+ "sync"
"time"
"github.com/teal-fm/piper/db"
@@ -16,15 +17,41 @@ import (
type Service struct {
db *db.DB
sessions *session.Manager
+ flashMu sync.Mutex
+ newKeys map[string]string
}
func NewAPIKeyService(database *db.DB, sessionManager *session.Manager) *Service {
return &Service{
db: database,
sessions: sessionManager,
+ newKeys: make(map[string]string),
}
}
+func (s *Service) storeNewKey(r *http.Request, key string) bool {
+ cookie, err := r.Cookie("session")
+ if err != nil || cookie.Value == "" {
+ return false
+ }
+ s.flashMu.Lock()
+ s.newKeys[cookie.Value] = key
+ s.flashMu.Unlock()
+ return true
+}
+
+func (s *Service) takeNewKey(r *http.Request) string {
+ cookie, err := r.Cookie("session")
+ if err != nil || cookie.Value == "" {
+ return ""
+ }
+ s.flashMu.Lock()
+ defer s.flashMu.Unlock()
+ key := s.newKeys[cookie.Value]
+ delete(s.newKeys, cookie.Value)
+ return key
+}
+
// jsonResponse is a helper to send JSON responses
func jsonResponse(w http.ResponseWriter, statusCode int, data any) {
w.Header().Set("Content-Type", "application/json")
@@ -95,7 +122,7 @@ func (s *Service) HandleAPIKeyManagement(database *db.DB, pg *pages.Pages) http.
// IMPORTANT: Assumes CreateAPIKeyAndReturnRawKey method exists on SessionManager
// and returns the database object and the raw key string.
// Signature: (apiKey *db_apikey.ApiKey, rawKeyString string, err error)
- apiKeyObj, err := s.sessions.CreateAPIKey(userID, keyName, validityDays)
+ apiKeyObj, rawKey, err := s.sessions.CreateAPIKey(userID, keyName, validityDays)
if err != nil {
jsonError(w, fmt.Sprintf("Error creating API key: %v", err), http.StatusInternalServerError)
return
@@ -103,6 +130,7 @@ func (s *Service) HandleAPIKeyManagement(database *db.DB, pg *pages.Pages) http.
jsonResponse(w, http.StatusCreated, map[string]any{
"id": apiKeyObj.ID,
+ "key": rawKey,
"name": apiKeyObj.Name,
"created_at": apiKeyObj.CreatedAt,
"expires_at": apiKeyObj.ExpiresAt,
@@ -146,18 +174,16 @@ func (s *Service) HandleAPIKeyManagement(database *db.DB, pg *pages.Pages) http.
}
validityDays := 1024
- // Uses the existing CreateAPIKey, which likely doesn't return the raw key.
- // The HTML flow currently redirects and shows the key ID.
- // The template message about "only time you'll see this key" is misleading if it shows ID.
- // This might require a separate enhancement if the HTML view should show the raw key.
- apiKey, err := s.sessions.CreateAPIKey(userID, keyName, validityDays)
+ _, rawKey, err := s.sessions.CreateAPIKey(userID, keyName, validityDays)
if err != nil {
http.Error(w, fmt.Sprintf("Error creating API key: %v", err), http.StatusInternalServerError)
return
}
- // Redirects, passing the ID of the created key.
- // The template shows this ID in the ".NewKey" section.
- http.Redirect(w, r, "/api-keys?created="+apiKey.ID, http.StatusSeeOther)
+ if !s.storeNewKey(r, rawKey) {
+ http.Error(w, "Failed to prepare the new API key for display", http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, "/api-keys", http.StatusSeeOther)
return
}
@@ -191,27 +217,16 @@ func (s *Service) HandleAPIKeyManagement(database *db.DB, pg *pages.Pages) http.
return
}
- // newlyCreatedKey will be the ID from the redirect after form POST
- newlyCreatedKeyID := r.URL.Query().Get("created")
- var newKeyValueToShow string
-
- if newlyCreatedKeyID != "" {
- // For HTML, we only have the ID. The template message should be adjusted
- // if it implies the raw key is shown.
- // If you enhance CreateAPIKey for HTML to also pass the raw key (e.g. via flash message),
- // this logic would change. For now, it's the ID.
- newKeyValueToShow = newlyCreatedKeyID
- }
-
data := struct {
- Keys []*dbapikey.ApiKey // Assuming GetUserApiKeys returns this type
- NewKeyID string // Changed from NewKey for clarity as it's an ID
- NavBar pages.NavBar
+ Keys []*dbapikey.ApiKey
+ NewKey string
+ NavBar pages.NavBar
}{
- Keys: keys,
- NewKeyID: newKeyValueToShow,
+ Keys: keys,
+ NewKey: s.takeNewKey(r),
NavBar: pages.NavBar{
- IsLoggedIn: ok,
+ IsLoggedIn: ok,
+ CurrentPage: pages.NavAPIAccess,
//Just leaving empty so we don't have to pull in the db here, may change
LastFMUsername: lastfmUsername,
},
diff --git a/service/apikey/apikey_test.go b/service/apikey/apikey_test.go
new file mode 100644
index 0000000..e6759db
--- /dev/null
+++ b/service/apikey/apikey_test.go
@@ -0,0 +1,31 @@
+package apikey
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestNewKeyFlashIsScopedAndConsumedOnce(t *testing.T) {
+ service := &Service{newKeys: make(map[string]string)}
+ request := httptest.NewRequest(http.MethodGet, "/api-keys", nil)
+ request.AddCookie(&http.Cookie{Name: "session", Value: "session-a"})
+
+ if !service.storeNewKey(request, "secret-key") {
+ t.Fatal("storeNewKey returned false")
+ }
+ if got := service.takeNewKey(request); got != "secret-key" {
+ t.Fatalf("first take got %q, want secret-key", got)
+ }
+ if got := service.takeNewKey(request); got != "" {
+ t.Fatalf("second take got %q, want empty", got)
+ }
+}
+
+func TestNewKeyFlashRequiresSessionCookie(t *testing.T) {
+ service := &Service{newKeys: make(map[string]string)}
+ request := httptest.NewRequest(http.MethodGet, "/api-keys", nil)
+ if service.storeNewKey(request, "secret-key") {
+ t.Fatal("storeNewKey succeeded without a session cookie")
+ }
+}
diff --git a/service/profile/profile.go b/service/profile/profile.go
new file mode 100644
index 0000000..5d23191
--- /dev/null
+++ b/service/profile/profile.go
@@ -0,0 +1,364 @@
+package profile
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net"
+ "net/http"
+ "net/netip"
+ "net/url"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/bluesky-social/indigo/atproto/identity"
+ "github.com/bluesky-social/indigo/atproto/syntax"
+)
+
+const tealProfileCollection = "fm.teal.actor.profile"
+const playCollection = "fm.teal.feed.play"
+
+var ErrProfilePending = errors.New("profile lookup is still pending")
+
+type Account struct {
+ Handle string `json:"handle"`
+ PDS string `json:"pds"`
+ AvatarURL string `json:"avatar_url"`
+}
+
+type RecordTarget struct {
+ TrackName string
+ PlayedAt time.Time
+}
+
+type cacheEntry struct {
+ account Account
+ expiresAt time.Time
+}
+
+type Resolver struct {
+ directory identity.Directory
+ client *http.Client
+ refreshTimeout time.Duration
+
+ mu sync.RWMutex
+ cache map[string]cacheEntry
+ refreshing map[string]bool
+}
+
+func NewResolver() *Resolver {
+ transport := http.DefaultTransport.(*http.Transport).Clone()
+ transport.Proxy = nil
+ transport.DialContext = safeDialContext
+ client := &http.Client{
+ Timeout: 4 * time.Second,
+ Transport: transport,
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ if len(via) >= 10 {
+ return errors.New("too many redirects")
+ }
+ return validateHTTPSURL(req.URL)
+ },
+ }
+ return &Resolver{
+ directory: identity.DefaultDirectory(),
+ client: client,
+ refreshTimeout: 5 * time.Second,
+ cache: make(map[string]cacheEntry),
+ refreshing: make(map[string]bool),
+ }
+}
+
+// Cached returns immediately. Expired data remains usable while one background
+// refresh runs, and a first-time lookup never delays the connections page.
+func (r *Resolver) Cached(rawDID string) (Account, bool) {
+ r.mu.Lock()
+ cached, ok := r.cache[rawDID]
+ needsRefresh := !ok || time.Now().After(cached.expiresAt)
+ if needsRefresh && !r.refreshing[rawDID] {
+ r.refreshing[rawDID] = true
+ go r.refresh(rawDID)
+ }
+ r.mu.Unlock()
+ return cached.account, ok
+}
+
+func (r *Resolver) refresh(rawDID string) {
+ ctx, cancel := context.WithTimeout(context.Background(), r.refreshTimeout)
+ defer cancel()
+ account, err := r.resolve(ctx, rawDID)
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ delete(r.refreshing, rawDID)
+ if err == nil {
+ r.cache[rawDID] = cacheEntry{account: account, expiresAt: time.Now().Add(10 * time.Minute)}
+ }
+}
+
+func (r *Resolver) Resolve(ctx context.Context, rawDID string) (Account, error) {
+ r.mu.RLock()
+ cached, ok := r.cache[rawDID]
+ r.mu.RUnlock()
+ if ok && time.Now().Before(cached.expiresAt) {
+ return cached.account, nil
+ }
+
+ account, err := r.resolve(ctx, rawDID)
+ if err != nil {
+ return Account{}, err
+ }
+ r.mu.Lock()
+ r.cache[rawDID] = cacheEntry{account: account, expiresAt: time.Now().Add(10 * time.Minute)}
+ r.mu.Unlock()
+ return account, nil
+}
+
+func (r *Resolver) resolve(ctx context.Context, rawDID string) (Account, error) {
+ did, err := syntax.ParseDID(rawDID)
+ if err != nil {
+ return Account{}, fmt.Errorf("parse DID: %w", err)
+ }
+ ident, err := r.directory.LookupDID(ctx, did)
+ if err != nil {
+ return Account{}, fmt.Errorf("resolve DID: %w", err)
+ }
+
+ handle := ident.Handle.String()
+ if handle == "handle.invalid" {
+ handle = ""
+ }
+ pds := strings.TrimRight(ident.PDSEndpoint(), "/")
+ account := Account{
+ Handle: handle,
+ PDS: strings.TrimPrefix(strings.TrimPrefix(pds, "https://"), "http://"),
+ }
+ account.AvatarURL = r.tealAvatar(ctx, pds, rawDID)
+ if account.AvatarURL == "" {
+ account.AvatarURL = r.blueskyAvatar(ctx, rawDID)
+ }
+
+ return account, nil
+}
+
+func (r *Resolver) tealAvatar(ctx context.Context, pds, did string) string {
+ if pds == "" {
+ return ""
+ }
+ endpoint, err := url.Parse(pds + "/xrpc/com.atproto.repo.getRecord")
+ if err != nil {
+ return ""
+ }
+ query := endpoint.Query()
+ query.Set("repo", did)
+ query.Set("collection", tealProfileCollection)
+ query.Set("rkey", "self")
+ endpoint.RawQuery = query.Encode()
+
+ var record struct {
+ Value struct {
+ Avatar struct {
+ Ref struct {
+ Link string `json:"$link"`
+ } `json:"ref"`
+ } `json:"avatar"`
+ } `json:"value"`
+ }
+ if err := r.getJSON(ctx, endpoint.String(), &record); err != nil || record.Value.Avatar.Ref.Link == "" {
+ return ""
+ }
+
+ blobURL, err := url.Parse(pds + "/xrpc/com.atproto.sync.getBlob")
+ if err != nil {
+ return ""
+ }
+ query = blobURL.Query()
+ query.Set("did", did)
+ query.Set("cid", record.Value.Avatar.Ref.Link)
+ blobURL.RawQuery = query.Encode()
+ return blobURL.String()
+}
+
+func (r *Resolver) blueskyAvatar(ctx context.Context, did string) string {
+ endpoint, _ := url.Parse("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile")
+ query := endpoint.Query()
+ query.Set("actor", did)
+ endpoint.RawQuery = query.Encode()
+
+ var actor struct {
+ Avatar string `json:"avatar"`
+ }
+ if err := r.getJSON(ctx, endpoint.String(), &actor); err != nil {
+ return ""
+ }
+ return actor.Avatar
+}
+
+func (r *Resolver) LatestRecords(ctx context.Context, rawDID string, targets map[string]RecordTarget) (map[string]string, error) {
+ account, ready := r.Cached(rawDID)
+ if !ready || account.PDS == "" {
+ return nil, ErrProfilePending
+ }
+ endpoint, err := url.Parse("https://" + account.PDS + "/xrpc/com.atproto.repo.listRecords")
+ if err != nil {
+ return nil, err
+ }
+ records := make(map[string]string)
+ if len(targets) == 0 {
+ return records, nil
+ }
+ cursor := ""
+ for page := 0; page < 10 && len(records) < len(targets); page++ {
+ query := endpoint.Query()
+ query.Set("repo", rawDID)
+ query.Set("collection", playCollection)
+ query.Set("limit", "100")
+ if cursor != "" {
+ query.Set("cursor", cursor)
+ }
+ endpoint.RawQuery = query.Encode()
+ var result struct {
+ Cursor string `json:"cursor"`
+ Records []struct {
+ URI string `json:"uri"`
+ Value struct {
+ MusicServiceURI string `json:"musicServiceUri"`
+ TrackName string `json:"trackName"`
+ PlayedTime string `json:"playedTime"`
+ } `json:"value"`
+ } `json:"records"`
+ }
+ if err := r.getJSON(ctx, endpoint.String(), &result); err != nil {
+ return nil, err
+ }
+ for _, record := range result.Records {
+ service := musicServiceName(record.Value.MusicServiceURI)
+ target, wanted := targets[service]
+ if !wanted || records[service] != "" || !recordMatchesTarget(record.Value.TrackName, record.Value.PlayedTime, target) {
+ continue
+ }
+ atURI, err := syntax.ParseATURI(record.URI)
+ if err != nil {
+ continue
+ }
+ records[service] = atURI.String()
+ }
+ cursor = result.Cursor
+ if cursor == "" {
+ break
+ }
+ }
+ return records, nil
+}
+
+func recordMatchesTarget(trackName, playedTime string, target RecordTarget) bool {
+ if target.TrackName != "" && !strings.EqualFold(strings.TrimSpace(trackName), strings.TrimSpace(target.TrackName)) {
+ return false
+ }
+ if target.PlayedAt.IsZero() {
+ return true
+ }
+ recordedAt, err := time.Parse(time.RFC3339Nano, playedTime)
+ return err == nil && recordedAt.Equal(target.PlayedAt)
+}
+
+func musicServiceName(rawURI string) string {
+ normalized := strings.ToLower(rawURI)
+ switch {
+ case strings.Contains(normalized, "spotify"):
+ return "spotify"
+ case strings.Contains(normalized, "music.apple"), strings.Contains(normalized, "applemusic"):
+ return "applemusic"
+ case strings.Contains(normalized, "last.fm"), strings.Contains(normalized, "lastfm"):
+ return "lastfm"
+ default:
+ return ""
+ }
+}
+
+func (r *Resolver) getJSON(ctx context.Context, endpoint string, target any) error {
+ parsed, err := url.Parse(endpoint)
+ if err != nil {
+ return err
+ }
+ if err := validateHTTPSURL(parsed); err != nil {
+ return err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
+ if err != nil {
+ return err
+ }
+ resp, err := r.client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("unexpected status %s", resp.Status)
+ }
+ return json.NewDecoder(resp.Body).Decode(target)
+}
+
+func validateHTTPSURL(target *url.URL) error {
+ if target == nil || target.Scheme != "https" || target.Hostname() == "" || target.User != nil {
+ return errors.New("remote profile URL must be an HTTPS URL without user information")
+ }
+ return nil
+}
+
+func safeDialContext(ctx context.Context, network, address string) (net.Conn, error) {
+ host, port, err := net.SplitHostPort(address)
+ if err != nil {
+ return nil, err
+ }
+ addresses, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
+ if err != nil {
+ return nil, err
+ }
+ for _, address := range addresses {
+ if !safePublicAddress(address) {
+ continue
+ }
+ return (&net.Dialer{}).DialContext(ctx, network, net.JoinHostPort(address.String(), port))
+ }
+ return nil, fmt.Errorf("remote profile host %q has no allowed public address", host)
+}
+
+var blockedAddressRanges = []netip.Prefix{
+ netip.MustParsePrefix("0.0.0.0/8"),
+ netip.MustParsePrefix("10.0.0.0/8"),
+ netip.MustParsePrefix("100.64.0.0/10"),
+ netip.MustParsePrefix("127.0.0.0/8"),
+ netip.MustParsePrefix("169.254.0.0/16"),
+ netip.MustParsePrefix("172.16.0.0/12"),
+ netip.MustParsePrefix("192.0.0.0/24"),
+ netip.MustParsePrefix("192.0.2.0/24"),
+ netip.MustParsePrefix("192.168.0.0/16"),
+ netip.MustParsePrefix("198.18.0.0/15"),
+ netip.MustParsePrefix("198.51.100.0/24"),
+ netip.MustParsePrefix("203.0.113.0/24"),
+ netip.MustParsePrefix("224.0.0.0/4"),
+ netip.MustParsePrefix("240.0.0.0/4"),
+ netip.MustParsePrefix("::/128"),
+ netip.MustParsePrefix("::1/128"),
+ netip.MustParsePrefix("fc00::/7"),
+ netip.MustParsePrefix("fe80::/10"),
+ netip.MustParsePrefix("ff00::/8"),
+ netip.MustParsePrefix("2001:db8::/32"),
+}
+
+func safePublicAddress(address netip.Addr) bool {
+ if address.Is4In6() {
+ address = address.Unmap()
+ }
+ if !address.IsValid() || !address.IsGlobalUnicast() {
+ return false
+ }
+ for _, blocked := range blockedAddressRanges {
+ if blocked.Contains(address) {
+ return false
+ }
+ }
+ return true
+}
diff --git a/service/profile/profile_test.go b/service/profile/profile_test.go
new file mode 100644
index 0000000..deafeca
--- /dev/null
+++ b/service/profile/profile_test.go
@@ -0,0 +1,104 @@
+package profile
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "net/netip"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestCachedReturnsExpiredAccountWithoutWaiting(t *testing.T) {
+ resolver := &Resolver{
+ cache: map[string]cacheEntry{
+ "did:plc:test": {
+ account: Account{Handle: "listener.example"},
+ expiresAt: time.Now().Add(-time.Minute),
+ },
+ },
+ refreshing: map[string]bool{"did:plc:test": true},
+ }
+
+ start := time.Now()
+ account, ok := resolver.Cached("did:plc:test")
+ if !ok || account.Handle != "listener.example" {
+ t.Fatalf("got %#v, %v", account, ok)
+ }
+ if elapsed := time.Since(start); elapsed > 20*time.Millisecond {
+ t.Fatalf("cached lookup took %s", elapsed)
+ }
+}
+
+func TestLatestRecordsReturnsNewestRecordPerService(t *testing.T) {
+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/xrpc/com.atproto.repo.listRecords" || r.URL.Query().Has("reverse") || r.URL.Query().Get("limit") != "100" {
+ t.Errorf("unexpected request: %s", r.URL.String())
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"records":[
+ {"uri":"at://did:plc:listener/fm.teal.feed.play/3wrong","value":{"musicServiceUri":"https://last.fm","trackName":"Older","playedTime":"2026-08-26T22:36:19Z"}},
+ {"uri":"at://did:plc:listener/fm.teal.feed.play/3lastfm","value":{"musicServiceUri":"https://last.fm","trackName":"Latest Last.fm","playedTime":"2026-08-27T22:36:19Z"}},
+ {"uri":"at://did:plc:listener/fm.teal.feed.play/3spotify","value":{"musicServiceUri":"https://open.spotify.com","trackName":"Latest Spotify","playedTime":"2026-08-27T21:00:00Z"}},
+ {"uri":"at://did:plc:listener/fm.teal.feed.play/3apple","value":{"musicServiceUri":"https://music.apple.com","trackName":"Latest Apple","playedTime":"2026-08-27T20:00:00Z"}}
+ ]}`))
+ }))
+ defer server.Close()
+ resolver := &Resolver{
+ client: server.Client(),
+ cache: map[string]cacheEntry{
+ "did:plc:listener": {
+ account: Account{PDS: strings.TrimPrefix(server.URL, "https://")},
+ expiresAt: time.Now().Add(time.Minute),
+ },
+ },
+ refreshing: make(map[string]bool),
+ }
+ records, err := resolver.LatestRecords(context.Background(), "did:plc:listener", map[string]RecordTarget{
+ "lastfm": {TrackName: "Latest Last.fm", PlayedAt: time.Date(2026, 8, 27, 22, 36, 19, 0, time.UTC)},
+ "spotify": {TrackName: "Latest Spotify", PlayedAt: time.Date(2026, 8, 27, 21, 0, 0, 0, time.UTC)},
+ "applemusic": {TrackName: "Latest Apple", PlayedAt: time.Date(2026, 8, 27, 20, 0, 0, 0, time.UTC)},
+ })
+ if err != nil {
+ t.Fatalf("LatestRecords: %v", err)
+ }
+ if records["lastfm"] != "at://did:plc:listener/fm.teal.feed.play/3lastfm" ||
+ records["spotify"] != "at://did:plc:listener/fm.teal.feed.play/3spotify" ||
+ records["applemusic"] != "at://did:plc:listener/fm.teal.feed.play/3apple" {
+ t.Fatalf("got %#v", records)
+ }
+}
+
+func TestValidateHTTPSURL(t *testing.T) {
+ for _, rawURL := range []string{"http://pds.example", "https://user@pds.example", "https://"} {
+ target, _ := url.Parse(rawURL)
+ if err := validateHTTPSURL(target); err == nil {
+ t.Errorf("validateHTTPSURL(%q) succeeded", rawURL)
+ }
+ }
+ target, _ := url.Parse("https://pds.example")
+ if err := validateHTTPSURL(target); err != nil {
+ t.Fatalf("valid HTTPS URL rejected: %v", err)
+ }
+}
+
+func TestSafePublicAddress(t *testing.T) {
+ tests := map[string]bool{
+ "127.0.0.1": false,
+ "10.0.0.1": false,
+ "169.254.169.254": false,
+ "192.0.2.10": false,
+ "::1": false,
+ "fc00::1": false,
+ "2001:db8::1": false,
+ "1.1.1.1": true,
+ "2606:4700:4700::1111": true,
+ }
+ for rawAddress, want := range tests {
+ if got := safePublicAddress(netip.MustParseAddr(rawAddress)); got != want {
+ t.Errorf("safePublicAddress(%s) = %v, want %v", rawAddress, got, want)
+ }
+ }
+}
diff --git a/session/session.go b/session/session.go
index 8b619bd..f23c614 100644
--- a/session/session.go
+++ b/session/session.go
@@ -25,10 +25,15 @@ type Session struct {
}
type Manager struct {
- db *db.DB
- sessions map[string]*Session // use in memory cache if necessary
- ApiKeyMgr *apikey.Manager
- mu sync.RWMutex
+ db *db.DB
+ sessions map[string]*Session // use in memory cache if necessary
+ ApiKeyMgr *apikey.Manager
+ mu sync.RWMutex
+ secureCookies bool
+}
+
+func (sm *Manager) SetSecureCookies(secure bool) {
+ sm.secureCookies = secure
}
func NewSessionManager(database *db.DB) *Manager {
@@ -192,7 +197,8 @@ func (sm *Manager) SetSessionCookie(w http.ResponseWriter, session *Session) {
Value: session.ID,
Path: "/",
HttpOnly: true,
- Secure: false,
+ Secure: sm.secureCookies,
+ SameSite: http.SameSiteLaxMode,
Expires: session.ExpiresAt,
}
http.SetCookie(w, cookie)
@@ -205,7 +211,8 @@ func (sm *Manager) ClearSessionCookie(w http.ResponseWriter) {
Value: "",
Path: "/",
HttpOnly: true,
- Secure: false,
+ Secure: sm.secureCookies,
+ SameSite: http.SameSiteLaxMode,
MaxAge: -1,
}
http.SetCookie(w, cookie)
@@ -215,7 +222,7 @@ func (sm *Manager) GetAPIKeyManager() *apikey.Manager {
return sm.ApiKeyMgr
}
-func (sm *Manager) CreateAPIKey(userID int64, name string, validityDays int) (*apikey.ApiKey, error) {
+func (sm *Manager) CreateAPIKey(userID int64, name string, validityDays int) (*apikey.ApiKey, string, error) {
return sm.ApiKeyMgr.CreateApiKey(userID, name, validityDays)
}
diff --git a/session/session_cookie_test.go b/session/session_cookie_test.go
new file mode 100644
index 0000000..d547955
--- /dev/null
+++ b/session/session_cookie_test.go
@@ -0,0 +1,22 @@
+package session
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+func TestSessionCookieSecurityAttributes(t *testing.T) {
+ manager := &Manager{secureCookies: true}
+ recorder := httptest.NewRecorder()
+ manager.SetSessionCookie(recorder, &Session{ID: "session-id", ExpiresAt: time.Now().Add(time.Hour)})
+ cookies := recorder.Result().Cookies()
+ if len(cookies) != 1 {
+ t.Fatalf("got %d cookies, want 1", len(cookies))
+ }
+ cookie := cookies[0]
+ if !cookie.HttpOnly || !cookie.Secure || cookie.SameSite != http.SameSiteLaxMode {
+ t.Fatalf("unexpected cookie attributes: %#v", cookie)
+ }
+}