Alter 13 files

Update go.mod
Update go.sum
Update data.go
Update http.go
Add pages.go
Rename urls.go to routes.go
Add script.go
Add script.js
Add session.go
Update spotifyOAuth.go
Add base.go
Add controlPanel.go
Add home.go
This commit is contained in:
akp 2025-02-13 01:05:17 +00:00
parent 94aeda9e8a
commit 3da0c389d1
No known key found for this signature in database
GPG key ID: CF8D58F3DEB20755
13 changed files with 384 additions and 16 deletions

2
go.mod
View file

@ -4,8 +4,10 @@ go 1.24.0
require (
crawshaw.dev/jsonfile v0.0.0-20240206193014-699d1dad804e
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/zmb3/spotify/v2 v2.4.3
golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5
maragu.dev/gomponents v1.0.0
)
require (

4
go.sum
View file

@ -109,6 +109,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
@ -408,6 +410,8 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
maragu.dev/gomponents v1.0.0 h1:eeLScjq4PqP1l+r5z/GC+xXZhLHXa6RWUWGW7gSfLh4=
maragu.dev/gomponents v1.0.0/go.mod h1:oEDahza2gZoXDoDHhw8jBNgH+3UR5ni7Ur648HORydM=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=

View file

@ -5,6 +5,7 @@ import (
"golang.org/x/oauth2"
"log/slog"
"os"
"strings"
)
import "crawshaw.dev/jsonfile"
@ -27,6 +28,9 @@ func init() {
if s.Users == nil {
s.Users = make(map[string]*User)
}
if s.DomainToUserID == nil {
s.DomainToUserID = make(map[string]string)
}
return nil
}); err != nil {
slog.Error("failed to setup datastore", "error", err)
@ -35,13 +39,15 @@ func init() {
}
type schema struct {
Users map[string]*User
Users map[string]*User
DomainToUserID map[string]string
}
type User struct {
ID string
Username string
Token *oauth2.Token
Domain string
}
func GetUser(id string) (*User, error) {
@ -60,6 +66,31 @@ func GetUser(id string) (*User, error) {
return u, err
}
func GetUserByDomain(domain string) (*User, error) {
domain = strings.ToLower(domain)
var (
user *User
err error
)
driver.Read(func(s *schema) {
d, found := s.DomainToUserID[domain]
if !found {
err = ErrNotFound
return
}
u, found := s.Users[d]
if !found {
err = ErrNotFound
return
}
user = u
})
return user, err
}
func StoreUser(id, username string, token *oauth2.Token) (*User, error) {
u := &User{
ID: id,
@ -71,3 +102,22 @@ func StoreUser(id, username string, token *oauth2.Token) (*User, error) {
return nil
})
}
func SetUserDomain(id string, domain string) error {
domain = strings.ToLower(domain)
return driver.Write(func(s *schema) error {
u, found := s.Users[id]
if !found {
return ErrNotFound
}
if _, found := s.DomainToUserID[u.Domain]; found {
delete(s.DomainToUserID, u.Domain)
}
u.Domain = domain
s.DomainToUserID[domain] = id
return nil
})
}

View file

@ -2,6 +2,7 @@ package httpsrv
import (
"git.tdpain.net/codemicro/now-playing/nowplaying/internal/config"
"git.tdpain.net/codemicro/now-playing/nowplaying/internal/httpsrv/routes"
"log/slog"
"net/http"
)
@ -25,9 +26,12 @@ func setupEndpoints(mux *http.ServeMux) *endpoints {
pattern string
handler func(http.ResponseWriter, *http.Request) error
}{
{"GET /", e.index},
{"GET " + URLAuthStart, e.spotifyAuthStart},
{"GET " + URLAuthCallback, e.spotifyAuthCallback},
{"GET /{$}", e.index},
{"GET " + routes.URLScript, e.script},
{"GET " + routes.URLAuthStart, e.spotifyAuthStart},
{"GET " + routes.URLAuthCallback, e.spotifyAuthCallback},
{"GET " + routes.URLControlPanel, e.controlPanel},
{"POST " + routes.URLControlPanel, e.controlPanel},
}
for _, h := range handlers {
@ -51,12 +55,7 @@ func errorWrapper(f func(http.ResponseWriter, *http.Request) error) http.Handler
}
func makeCallbackURL() string {
return config.ExternalURL + URLAuthCallback
return config.ExternalURL + routes.URLAuthCallback
}
type endpoints struct{}
func (endpoints) index(rw http.ResponseWriter, rq *http.Request) error {
rw.Write([]byte("hello!!"))
return nil
}

View file

@ -0,0 +1,69 @@
package httpsrv
import (
"errors"
"fmt"
"git.tdpain.net/codemicro/now-playing/nowplaying/internal/datastore"
"git.tdpain.net/codemicro/now-playing/nowplaying/internal/httpsrv/routes"
"git.tdpain.net/codemicro/now-playing/nowplaying/internal/httpsrv/templates"
"net/http"
"strings"
)
func (endpoints) index(rw http.ResponseWriter, rq *http.Request) error {
if getSession(rq) != nil {
rw.Header().Set("Location", routes.URLControlPanel)
rw.WriteHeader(http.StatusFound)
return nil
}
return templates.SendNode(rw, templates.HomePage())
}
func (endpoints) controlPanel(rw http.ResponseWriter, rq *http.Request) error {
session := ensureAuth(rw, rq)
if session == nil {
return nil
}
var respProps templates.ControlPanelProps
if rq.Method == http.MethodPost {
if !checkCSRF(rq, rq.FormValue("csrf")) {
respProps.FormMessage = "Invalid CSRF"
} else {
inputDomain := rq.FormValue("domain")
inputDomain = strings.TrimSpace(inputDomain)
if len(inputDomain) == 0 {
respProps.FormMessage = "Invalid domain"
} else {
du, err := datastore.GetUserByDomain(inputDomain)
if err == nil && du.ID != session.UserID {
respProps.FormMessage = "Domain already in use"
} else if err != nil && !errors.Is(err, datastore.ErrNotFound) {
return fmt.Errorf("check if domain is in use: %w", err)
} else {
if err := datastore.SetUserDomain(session.UserID, inputDomain); err != nil {
return fmt.Errorf("set user domain: %w", err)
}
respProps.FormMessage = "Updated!"
}
}
}
}
if csrfToken, err := setCSRF(rw); err != nil {
return fmt.Errorf("set CSRF token: %w", err)
} else {
respProps.CSRFToken = csrfToken
}
user, err := datastore.GetUser(session.UserID)
if err != nil {
return fmt.Errorf("get user: %w", err)
}
respProps.Username = user.Username
respProps.Domain = user.Domain
return templates.SendNode(rw, templates.ControlPanel(respProps))
}

View file

@ -1,4 +1,4 @@
package httpsrv
package routes
const (
urlAuth = "/auth"
@ -7,4 +7,8 @@ const (
URLAuthStart = urlSpotifyAuth + "/start"
URLAuthCallback = urlSpotifyAuth + "/callback"
URLControlPanel = "/controls"
URLScript = "/script.js"
)

View file

@ -0,0 +1,35 @@
package httpsrv
import (
_ "embed"
"errors"
"fmt"
"git.tdpain.net/codemicro/now-playing/nowplaying/internal/datastore"
"net/http"
"net/url"
)
//go:embed script.js
var scriptSrc []byte
func (endpoints) script(rw http.ResponseWriter, rq *http.Request) error {
parsedReferer, err := url.Parse(rq.Referer())
if err != nil {
http.Error(rw, "bad referer", http.StatusBadRequest)
return nil
}
u, err := datastore.GetUserByDomain(parsedReferer.Host)
if err != nil {
if errors.Is(err, datastore.ErrNotFound) {
http.Error(rw, "domain not registered", http.StatusBadRequest)
return nil
}
return fmt.Errorf("get user by domain: %w", err)
}
rw.Header().Set("Content-Type", "application/javascript")
rw.Header().Set("Access-Control-Allow-Origin", u.Domain)
_, _ = rw.Write(scriptSrc)
return nil
}

View file

@ -0,0 +1 @@
console.log("hello!")

View file

@ -0,0 +1,109 @@
package httpsrv
import (
"crypto/rand"
"encoding/hex"
"fmt"
"git.tdpain.net/codemicro/now-playing/nowplaying/internal/httpsrv/routes"
"github.com/patrickmn/go-cache"
"net/http"
"time"
)
const (
sessionCookieName = "np-session"
sessionExpiry = time.Hour * 1
)
var sessions = cache.New(sessionExpiry, time.Minute*30)
type session struct {
UserID string
}
func getSession(rq *http.Request) *session {
var sess *session
cookie, err := rq.Cookie(sessionCookieName)
if err != nil {
return nil
}
{
s, found := sessions.Get(cookie.Value)
if !found {
return nil
}
sess = s.(*session)
}
return sess
}
func ensureAuth(rw http.ResponseWriter, rq *http.Request) *session {
sess := getSession(rq)
if sess == nil {
rw.Header().Set("Location", routes.URLAuthStart)
rw.WriteHeader(http.StatusFound)
return nil
}
return sess
}
func setAuth(rw http.ResponseWriter, userID string) error {
var tok string
{
sb := make([]byte, 32)
if _, err := rand.Read(sb); err != nil {
return fmt.Errorf("generate session token: %w", err)
}
tok = hex.EncodeToString(sb)
}
if err := sessions.Add(tok, &session{
UserID: userID,
}, cache.DefaultExpiration); err != nil {
return fmt.Errorf("store new session: %w", err)
}
http.SetCookie(rw, &http.Cookie{
Name: sessionCookieName,
Path: "/",
Value: tok,
Expires: time.Now().UTC().Add(sessionExpiry),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
return nil
}
const csrfCookieName = "np-csrf"
func setCSRF(rw http.ResponseWriter) (string, error) {
var tok string
{
sb := make([]byte, 32)
if _, err := rand.Read(sb); err != nil {
return "", fmt.Errorf("generate session token: %w", err)
}
tok = hex.EncodeToString(sb)
}
http.SetCookie(rw, &http.Cookie{
Name: csrfCookieName,
Path: "/",
Value: tok,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
return tok, nil
}
func checkCSRF(rq *http.Request, v string) bool {
ck, err := rq.Cookie(csrfCookieName)
if err != nil {
return false
}
return ck.Value == v
}

View file

@ -7,6 +7,7 @@ import (
"fmt"
"git.tdpain.net/codemicro/now-playing/nowplaying/internal/config"
"git.tdpain.net/codemicro/now-playing/nowplaying/internal/datastore"
"git.tdpain.net/codemicro/now-playing/nowplaying/internal/httpsrv/routes"
"github.com/zmb3/spotify/v2"
"github.com/zmb3/spotify/v2/auth"
"net/http"
@ -78,7 +79,6 @@ func (endpoints) spotifyAuthCallback(rw http.ResponseWriter, rq *http.Request) e
}
if _, err := datastore.GetUser(user.ID); err != nil {
fmt.Println("user not exists")
if errors.Is(err, datastore.ErrNotFound) {
if _, err := datastore.StoreUser(user.ID, user.DisplayName, token); err != nil {
return fmt.Errorf("store new user info: %w", err)
@ -86,11 +86,13 @@ func (endpoints) spotifyAuthCallback(rw http.ResponseWriter, rq *http.Request) e
} else {
return fmt.Errorf("check if user exists: %w", err)
}
} else {
fmt.Println("user exists")
}
// TODO: now what
rw.Write([]byte("ok - hi " + user.DisplayName + "/" + user.ID))
if err := setAuth(rw, user.ID); err != nil {
return fmt.Errorf("setup session: %w", err)
}
rw.Header().Set("Location", routes.URLControlPanel)
rw.WriteHeader(http.StatusFound)
return nil
}

View file

@ -0,0 +1,33 @@
package templates
import (
"io"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/components"
. "maragu.dev/gomponents/html"
"net/http"
)
type BasePage struct {
Title string
HeadContent Group
BodyContent Group
}
var _ Node = (*BasePage)(nil)
func (b *BasePage) Render(w io.Writer) error {
b.BodyContent = append(Group{A(Class("home"), Text("NowPlaying"), Href("/"))}, b.BodyContent)
b.HeadContent = append(Group{Link(Rel("stylesheet"), Href("https://cdn.tedted.dev/neat/neat.css"))}, b.HeadContent)
return HTML5(HTML5Props{
Title: b.Title,
Language: "en-GB",
Head: b.HeadContent,
Body: b.BodyContent,
}).Render(w)
}
func SendNode(rw http.ResponseWriter, n Node) error {
rw.Header().Set("Content-Type", "text/html")
return n.Render(rw)
}

View file

@ -0,0 +1,38 @@
package templates
import (
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
type ControlPanelProps struct {
Username string
Domain string
FormMessage string
CSRFToken string
}
func ControlPanel(props ControlPanelProps) Node {
return &BasePage{
Title: "Controls :: NowPlaying",
BodyContent: Group{
H1(Textf("Hi %s!", props.Username)),
Form(
Method("post"),
Div(
Class("row"),
Label(For("domain-input"), Text("Your domain: ")),
Input(Type("text"), Name("domain"), ID("domain-input"), Value(props.Domain)),
Input(Type("submit"), Text("Save")),
Div(Text(props.FormMessage)),
),
Input(Type("hidden"), Name("csrf"), Value(props.CSRFToken)),
),
P(Text("// TODO: domain selection, instructions")),
},
}
}

View file

@ -0,0 +1,22 @@
package templates
import (
"git.tdpain.net/codemicro/now-playing/nowplaying/internal/httpsrv/routes"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func HomePage() Node {
return &BasePage{
Title: "NowPlaying",
BodyContent: Group{
H1(Text("Hi there!")),
P(Text("To get started with the premier Spotify-now-playing applet, hit the login button below to login with Spotify!")),
A(
Class("button"),
Href(routes.URLAuthStart),
Text("Login"),
),
},
}
}