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

Merge pull request #14 from fatfingers23/feature/HtmlTemplates

Feature: HTML templates

authored by natalie and committed by GitHub 473cb159 9420ff6d

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