+2
-2
.air.toml
+2
-2
.air.toml
···
14
14
follow_symlink = false
15
15
full_bin = ""
16
16
include_dir = []
17
-
include_ext = ["go", "tpl", "tmpl", "html"]
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
+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
+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
+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
+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
+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
+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
+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
pages/static/base.css
···
1
+
@import "tailwindcss";
+531
pages/static/main.css
+531
pages/static/main.css
···
1
+
/*! tailwindcss v4.1.13 | MIT License | https://tailwindcss.com */
2
+
@layer properties;
3
+
@layer theme, base, components, utilities;
4
+
@layer theme {
5
+
:root, :host {
6
+
--font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
7
+
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
8
+
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
9
+
"Courier New", monospace;
10
+
--color-gray-100: oklch(96.7% 0.003 264.542);
11
+
--color-gray-200: oklch(92.8% 0.006 264.531);
12
+
--color-gray-300: oklch(87.2% 0.01 258.338);
13
+
--color-gray-600: oklch(44.6% 0.03 256.802);
14
+
--color-white: #fff;
15
+
--spacing: 0.25rem;
16
+
--text-lg: 1.125rem;
17
+
--text-lg--line-height: calc(1.75 / 1.125);
18
+
--text-xl: 1.25rem;
19
+
--text-xl--line-height: calc(1.75 / 1.25);
20
+
--font-weight-semibold: 600;
21
+
--font-weight-bold: 700;
22
+
--leading-relaxed: 1.625;
23
+
--radius-lg: 0.5rem;
24
+
--default-font-family: var(--font-sans);
25
+
--default-mono-font-family: var(--font-mono);
26
+
}
27
+
}
28
+
@layer base {
29
+
*, ::after, ::before, ::backdrop, ::file-selector-button {
30
+
box-sizing: border-box;
31
+
margin: 0;
32
+
padding: 0;
33
+
border: 0 solid;
34
+
}
35
+
html, :host {
36
+
line-height: 1.5;
37
+
-webkit-text-size-adjust: 100%;
38
+
tab-size: 4;
39
+
font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");
40
+
font-feature-settings: var(--default-font-feature-settings, normal);
41
+
font-variation-settings: var(--default-font-variation-settings, normal);
42
+
-webkit-tap-highlight-color: transparent;
43
+
}
44
+
hr {
45
+
height: 0;
46
+
color: inherit;
47
+
border-top-width: 1px;
48
+
}
49
+
abbr:where([title]) {
50
+
-webkit-text-decoration: underline dotted;
51
+
text-decoration: underline dotted;
52
+
}
53
+
h1, h2, h3, h4, h5, h6 {
54
+
font-size: inherit;
55
+
font-weight: inherit;
56
+
}
57
+
a {
58
+
color: inherit;
59
+
-webkit-text-decoration: inherit;
60
+
text-decoration: inherit;
61
+
}
62
+
b, strong {
63
+
font-weight: bolder;
64
+
}
65
+
code, kbd, samp, pre {
66
+
font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
67
+
font-feature-settings: var(--default-mono-font-feature-settings, normal);
68
+
font-variation-settings: var(--default-mono-font-variation-settings, normal);
69
+
font-size: 1em;
70
+
}
71
+
small {
72
+
font-size: 80%;
73
+
}
74
+
sub, sup {
75
+
font-size: 75%;
76
+
line-height: 0;
77
+
position: relative;
78
+
vertical-align: baseline;
79
+
}
80
+
sub {
81
+
bottom: -0.25em;
82
+
}
83
+
sup {
84
+
top: -0.5em;
85
+
}
86
+
table {
87
+
text-indent: 0;
88
+
border-color: inherit;
89
+
border-collapse: collapse;
90
+
}
91
+
:-moz-focusring {
92
+
outline: auto;
93
+
}
94
+
progress {
95
+
vertical-align: baseline;
96
+
}
97
+
summary {
98
+
display: list-item;
99
+
}
100
+
ol, ul, menu {
101
+
list-style: none;
102
+
}
103
+
img, svg, video, canvas, audio, iframe, embed, object {
104
+
display: block;
105
+
vertical-align: middle;
106
+
}
107
+
img, video {
108
+
max-width: 100%;
109
+
height: auto;
110
+
}
111
+
button, input, select, optgroup, textarea, ::file-selector-button {
112
+
font: inherit;
113
+
font-feature-settings: inherit;
114
+
font-variation-settings: inherit;
115
+
letter-spacing: inherit;
116
+
color: inherit;
117
+
border-radius: 0;
118
+
background-color: transparent;
119
+
opacity: 1;
120
+
}
121
+
:where(select:is([multiple], [size])) optgroup {
122
+
font-weight: bolder;
123
+
}
124
+
:where(select:is([multiple], [size])) optgroup option {
125
+
padding-inline-start: 20px;
126
+
}
127
+
::file-selector-button {
128
+
margin-inline-end: 4px;
129
+
}
130
+
::placeholder {
131
+
opacity: 1;
132
+
}
133
+
@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
134
+
::placeholder {
135
+
color: currentcolor;
136
+
@supports (color: color-mix(in lab, red, red)) {
137
+
color: color-mix(in oklab, currentcolor 50%, transparent);
138
+
}
139
+
}
140
+
}
141
+
textarea {
142
+
resize: vertical;
143
+
}
144
+
::-webkit-search-decoration {
145
+
-webkit-appearance: none;
146
+
}
147
+
::-webkit-date-and-time-value {
148
+
min-height: 1lh;
149
+
text-align: inherit;
150
+
}
151
+
::-webkit-datetime-edit {
152
+
display: inline-flex;
153
+
}
154
+
::-webkit-datetime-edit-fields-wrapper {
155
+
padding: 0;
156
+
}
157
+
::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
158
+
padding-block: 0;
159
+
}
160
+
::-webkit-calendar-picker-indicator {
161
+
line-height: 1;
162
+
}
163
+
:-moz-ui-invalid {
164
+
box-shadow: none;
165
+
}
166
+
button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button {
167
+
appearance: button;
168
+
}
169
+
::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
170
+
height: auto;
171
+
}
172
+
[hidden]:where(:not([hidden="until-found"])) {
173
+
display: none !important;
174
+
}
175
+
}
176
+
@layer utilities {
177
+
.absolute {
178
+
position: absolute;
179
+
}
180
+
.relative {
181
+
position: relative;
182
+
}
183
+
.static {
184
+
position: static;
185
+
}
186
+
.sticky {
187
+
position: sticky;
188
+
}
189
+
.container {
190
+
width: 100%;
191
+
@media (width >= 40rem) {
192
+
max-width: 40rem;
193
+
}
194
+
@media (width >= 48rem) {
195
+
max-width: 48rem;
196
+
}
197
+
@media (width >= 64rem) {
198
+
max-width: 64rem;
199
+
}
200
+
@media (width >= 80rem) {
201
+
max-width: 80rem;
202
+
}
203
+
@media (width >= 96rem) {
204
+
max-width: 96rem;
205
+
}
206
+
}
207
+
.mx-auto {
208
+
margin-inline: auto;
209
+
}
210
+
.my-5 {
211
+
margin-block: calc(var(--spacing) * 5);
212
+
}
213
+
.mt-1 {
214
+
margin-top: calc(var(--spacing) * 1);
215
+
}
216
+
.mt-3 {
217
+
margin-top: calc(var(--spacing) * 3);
218
+
}
219
+
.mb-1 {
220
+
margin-bottom: calc(var(--spacing) * 1);
221
+
}
222
+
.mb-2 {
223
+
margin-bottom: calc(var(--spacing) * 2);
224
+
}
225
+
.mb-3 {
226
+
margin-bottom: calc(var(--spacing) * 3);
227
+
}
228
+
.mb-4 {
229
+
margin-bottom: calc(var(--spacing) * 4);
230
+
}
231
+
.mb-5 {
232
+
margin-bottom: calc(var(--spacing) * 5);
233
+
}
234
+
.block {
235
+
display: block;
236
+
}
237
+
.contents {
238
+
display: contents;
239
+
}
240
+
.flex {
241
+
display: flex;
242
+
}
243
+
.hidden {
244
+
display: none;
245
+
}
246
+
.table {
247
+
display: table;
248
+
}
249
+
.w-\[95\%\] {
250
+
width: 95%;
251
+
}
252
+
.w-full {
253
+
width: 100%;
254
+
}
255
+
.max-w-\[600px\] {
256
+
max-width: 600px;
257
+
}
258
+
.max-w-\[800px\] {
259
+
max-width: 800px;
260
+
}
261
+
.border-collapse {
262
+
border-collapse: collapse;
263
+
}
264
+
.transform {
265
+
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
266
+
}
267
+
.cursor-pointer {
268
+
cursor: pointer;
269
+
}
270
+
.list-disc {
271
+
list-style-type: disc;
272
+
}
273
+
.flex-wrap {
274
+
flex-wrap: wrap;
275
+
}
276
+
.space-y-2 {
277
+
:where(& > :not(:last-child)) {
278
+
--tw-space-y-reverse: 0;
279
+
margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));
280
+
margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)));
281
+
}
282
+
}
283
+
.gap-x-4 {
284
+
column-gap: calc(var(--spacing) * 4);
285
+
}
286
+
.gap-y-1 {
287
+
row-gap: calc(var(--spacing) * 1);
288
+
}
289
+
.rounded {
290
+
border-radius: 0.25rem;
291
+
}
292
+
.rounded-lg {
293
+
border-radius: var(--radius-lg);
294
+
}
295
+
.border {
296
+
border-style: var(--tw-border-style);
297
+
border-width: 1px;
298
+
}
299
+
.border-b {
300
+
border-bottom-style: var(--tw-border-style);
301
+
border-bottom-width: 1px;
302
+
}
303
+
.border-l-4 {
304
+
border-left-style: var(--tw-border-style);
305
+
border-left-width: 4px;
306
+
}
307
+
.border-\[\#1DB954\] {
308
+
border-color: #1DB954;
309
+
}
310
+
.border-gray-200 {
311
+
border-color: var(--color-gray-200);
312
+
}
313
+
.border-gray-300 {
314
+
border-color: var(--color-gray-300);
315
+
}
316
+
.bg-\[\#1DB954\] {
317
+
background-color: #1DB954;
318
+
}
319
+
.bg-\[\#d51007\] {
320
+
background-color: #d51007;
321
+
}
322
+
.bg-\[\#dc3545\] {
323
+
background-color: #dc3545;
324
+
}
325
+
.bg-gray-100 {
326
+
background-color: var(--color-gray-100);
327
+
}
328
+
.p-2 {
329
+
padding: calc(var(--spacing) * 2);
330
+
}
331
+
.p-4 {
332
+
padding: calc(var(--spacing) * 4);
333
+
}
334
+
.p-5 {
335
+
padding: calc(var(--spacing) * 5);
336
+
}
337
+
.px-3 {
338
+
padding-inline: calc(var(--spacing) * 3);
339
+
}
340
+
.px-4 {
341
+
padding-inline: calc(var(--spacing) * 4);
342
+
}
343
+
.py-1\.5 {
344
+
padding-block: calc(var(--spacing) * 1.5);
345
+
}
346
+
.py-2 {
347
+
padding-block: calc(var(--spacing) * 2);
348
+
}
349
+
.py-2\.5 {
350
+
padding-block: calc(var(--spacing) * 2.5);
351
+
}
352
+
.pl-5 {
353
+
padding-left: calc(var(--spacing) * 5);
354
+
}
355
+
.text-left {
356
+
text-align: left;
357
+
}
358
+
.font-mono {
359
+
font-family: var(--font-mono);
360
+
}
361
+
.font-sans {
362
+
font-family: var(--font-sans);
363
+
}
364
+
.text-lg {
365
+
font-size: var(--text-lg);
366
+
line-height: var(--tw-leading, var(--text-lg--line-height));
367
+
}
368
+
.text-xl {
369
+
font-size: var(--text-xl);
370
+
line-height: var(--tw-leading, var(--text-xl--line-height));
371
+
}
372
+
.leading-relaxed {
373
+
--tw-leading: var(--leading-relaxed);
374
+
line-height: var(--leading-relaxed);
375
+
}
376
+
.font-bold {
377
+
--tw-font-weight: var(--font-weight-bold);
378
+
font-weight: var(--font-weight-bold);
379
+
}
380
+
.font-semibold {
381
+
--tw-font-weight: var(--font-weight-semibold);
382
+
font-weight: var(--font-weight-semibold);
383
+
}
384
+
.text-\[\#1DB954\] {
385
+
color: #1DB954;
386
+
}
387
+
.text-gray-600 {
388
+
color: var(--color-gray-600);
389
+
}
390
+
.text-white {
391
+
color: var(--color-white);
392
+
}
393
+
.lowercase {
394
+
text-transform: lowercase;
395
+
}
396
+
.italic {
397
+
font-style: italic;
398
+
}
399
+
.no-underline {
400
+
text-decoration-line: none;
401
+
}
402
+
.filter {
403
+
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
404
+
}
405
+
.hover\:opacity-90 {
406
+
&:hover {
407
+
@media (hover: hover) {
408
+
opacity: 90%;
409
+
}
410
+
}
411
+
}
412
+
}
413
+
@property --tw-rotate-x {
414
+
syntax: "*";
415
+
inherits: false;
416
+
}
417
+
@property --tw-rotate-y {
418
+
syntax: "*";
419
+
inherits: false;
420
+
}
421
+
@property --tw-rotate-z {
422
+
syntax: "*";
423
+
inherits: false;
424
+
}
425
+
@property --tw-skew-x {
426
+
syntax: "*";
427
+
inherits: false;
428
+
}
429
+
@property --tw-skew-y {
430
+
syntax: "*";
431
+
inherits: false;
432
+
}
433
+
@property --tw-space-y-reverse {
434
+
syntax: "*";
435
+
inherits: false;
436
+
initial-value: 0;
437
+
}
438
+
@property --tw-border-style {
439
+
syntax: "*";
440
+
inherits: false;
441
+
initial-value: solid;
442
+
}
443
+
@property --tw-leading {
444
+
syntax: "*";
445
+
inherits: false;
446
+
}
447
+
@property --tw-font-weight {
448
+
syntax: "*";
449
+
inherits: false;
450
+
}
451
+
@property --tw-blur {
452
+
syntax: "*";
453
+
inherits: false;
454
+
}
455
+
@property --tw-brightness {
456
+
syntax: "*";
457
+
inherits: false;
458
+
}
459
+
@property --tw-contrast {
460
+
syntax: "*";
461
+
inherits: false;
462
+
}
463
+
@property --tw-grayscale {
464
+
syntax: "*";
465
+
inherits: false;
466
+
}
467
+
@property --tw-hue-rotate {
468
+
syntax: "*";
469
+
inherits: false;
470
+
}
471
+
@property --tw-invert {
472
+
syntax: "*";
473
+
inherits: false;
474
+
}
475
+
@property --tw-opacity {
476
+
syntax: "*";
477
+
inherits: false;
478
+
}
479
+
@property --tw-saturate {
480
+
syntax: "*";
481
+
inherits: false;
482
+
}
483
+
@property --tw-sepia {
484
+
syntax: "*";
485
+
inherits: false;
486
+
}
487
+
@property --tw-drop-shadow {
488
+
syntax: "*";
489
+
inherits: false;
490
+
}
491
+
@property --tw-drop-shadow-color {
492
+
syntax: "*";
493
+
inherits: false;
494
+
}
495
+
@property --tw-drop-shadow-alpha {
496
+
syntax: "<percentage>";
497
+
inherits: false;
498
+
initial-value: 100%;
499
+
}
500
+
@property --tw-drop-shadow-size {
501
+
syntax: "*";
502
+
inherits: false;
503
+
}
504
+
@layer properties {
505
+
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
506
+
*, ::before, ::after, ::backdrop {
507
+
--tw-rotate-x: initial;
508
+
--tw-rotate-y: initial;
509
+
--tw-rotate-z: initial;
510
+
--tw-skew-x: initial;
511
+
--tw-skew-y: initial;
512
+
--tw-space-y-reverse: 0;
513
+
--tw-border-style: solid;
514
+
--tw-leading: initial;
515
+
--tw-font-weight: initial;
516
+
--tw-blur: initial;
517
+
--tw-brightness: initial;
518
+
--tw-contrast: initial;
519
+
--tw-grayscale: initial;
520
+
--tw-hue-rotate: initial;
521
+
--tw-invert: initial;
522
+
--tw-opacity: initial;
523
+
--tw-saturate: initial;
524
+
--tw-sepia: initial;
525
+
--tw-drop-shadow: initial;
526
+
--tw-drop-shadow-color: initial;
527
+
--tw-drop-shadow-alpha: 100%;
528
+
--tw-drop-shadow-size: initial;
529
+
}
530
+
}
531
+
}
+92
pages/templates/apiKeys.gohtml
+92
pages/templates/apiKeys.gohtml
···
1
+
2
+
{{ define "content" }}
3
+
4
+
{{ template "components/navBar" .NavBar }}
5
+
6
+
7
+
<h1 class="text-[#1DB954]">API Key Management</h1>
8
+
9
+
<div class="border border-gray-300 rounded-lg p-5 mb-5">
10
+
<h2 class="text-[#1DB954] text-xl font-semibold mb-2">Create New API Key</h2>
11
+
<p class="mb-3">API keys allow programmatic access to your Piper account data.</p>
12
+
<form method="POST" action="/api-keys">
13
+
<div class="mb-4">
14
+
<label class="block" for="name">Key Name (for your reference):</label>
15
+
<input class="mt-1 w-full p-2 border border-gray-300 rounded" type="text" id="name" name="name" placeholder="My Application">
16
+
</div>
17
+
<button type="submit" class="bg-[#1DB954] text-white px-4 py-2 rounded cursor-pointer hover:opacity-90">Generate New API Key</button>
18
+
</form>
19
+
</div>
20
+
21
+
{{if .NewKeyID}} <!-- Changed from .NewKey to .NewKeyID for clarity -->
22
+
<div class="bg-gray-100 border-l-4 border-[#1DB954] p-4 mb-5">
23
+
<h3 class="text-[#1DB954] text-lg font-semibold mb-1">Your new API key (ID: {{.NewKeyID}}) has been created</h3>
24
+
<!-- The message below is misleading if only the ID is shown.
25
+
Consider changing this text or modifying the flow to show the actual key once for HTML. -->
26
+
<p><strong>Important:</strong> If this is an ID, ensure you have copied the actual key if it was displayed previously. For keys generated via the API, the key is returned in the API response.</p>
27
+
</div>
28
+
{{end}}
29
+
30
+
<div class="border border-gray-300 rounded-lg p-5 mb-5">
31
+
<h2 class="text-[#1DB954] text-xl font-semibold mb-2">Your API Keys</h2>
32
+
{{if .Keys}}
33
+
<table class="w-full border-collapse">
34
+
<thead>
35
+
<tr class="text-left border-b border-gray-300">
36
+
<th class="p-2">Name</th>
37
+
<th class="p-2">Prefix</th>
38
+
<th class="p-2">Created</th>
39
+
<th class="p-2">Expires</th>
40
+
<th class="p-2">Actions</th>
41
+
</tr>
42
+
</thead>
43
+
<tbody>
44
+
{{range .Keys}}
45
+
<tr class="border-b border-gray-200">
46
+
<td class="p-2">{{.Name}}</td>
47
+
<td class="p-2">{{.KeyPrefix}}</td> <!-- Added KeyPrefix for better identification -->
48
+
<td class="p-2">{{formatTime .CreatedAt}}</td>
49
+
<td class="p-2">{{formatTime .ExpiresAt}}</td>
50
+
<td class="p-2">
51
+
<button class="bg-[#dc3545] text-white px-3 py-1.5 rounded cursor-pointer hover:opacity-90" onclick="deleteKey('{{.ID}}')">Delete</button>
52
+
</td>
53
+
</tr>
54
+
{{end}}
55
+
</tbody>
56
+
</table>
57
+
{{else}}
58
+
<p>You don't have any API keys yet.</p>
59
+
{{end}}
60
+
</div>
61
+
62
+
<div class="border border-gray-300 rounded-lg p-5 mb-5">
63
+
<h2 class="text-[#1DB954] text-xl font-semibold mb-2">API Usage</h2>
64
+
<p class="mb-2">To use your API key, include it in the Authorization header of your HTTP requests:</p>
65
+
<pre class="font-mono p-2 bg-gray-100 border border-gray-300 rounded">Authorization: Bearer YOUR_API_KEY</pre>
66
+
<p class="mt-3 mb-2">Or include it as a query parameter (less secure for the key itself):</p>
67
+
<pre class="font-mono p-2 bg-gray-100 border border-gray-300 rounded">https://your-piper-instance.com/endpoint?api_key=YOUR_API_KEY</pre>
68
+
</div>
69
+
70
+
<script>
71
+
function deleteKey(keyId) {
72
+
if (confirm('Are you sure you want to delete this API key? This action cannot be undone.')) {
73
+
fetch('/api-keys?key_id=' + keyId, { // This endpoint is handled by HandleAPIKeyManagement
74
+
method: 'DELETE',
75
+
})
76
+
.then(response => response.json())
77
+
.then(data => {
78
+
if (data.success) {
79
+
window.location.reload();
80
+
} else {
81
+
alert('Failed to delete API key: ' + (data.error || 'Unknown error'));
82
+
}
83
+
})
84
+
.catch(error => {
85
+
console.error('Error:', error);
86
+
alert('Failed to delete API key due to a network or processing error.');
87
+
});
88
+
}
89
+
}
90
+
</script>
91
+
92
+
{{ end }}
+48
pages/templates/home.gohtml
+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
+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
+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
+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
+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 {