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:
parent
94aeda9e8a
commit
3da0c389d1
13 changed files with 384 additions and 16 deletions
2
go.mod
2
go.mod
|
@ -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
4
go.sum
|
@ -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=
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
69
nowplaying/internal/httpsrv/pages.go
Normal file
69
nowplaying/internal/httpsrv/pages.go
Normal 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))
|
||||
}
|
|
@ -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"
|
||||
)
|
35
nowplaying/internal/httpsrv/script.go
Normal file
35
nowplaying/internal/httpsrv/script.go
Normal 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
|
||||
}
|
1
nowplaying/internal/httpsrv/script.js
Normal file
1
nowplaying/internal/httpsrv/script.js
Normal file
|
@ -0,0 +1 @@
|
|||
console.log("hello!")
|
109
nowplaying/internal/httpsrv/session.go
Normal file
109
nowplaying/internal/httpsrv/session.go
Normal 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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
33
nowplaying/internal/httpsrv/templates/base.go
Normal file
33
nowplaying/internal/httpsrv/templates/base.go
Normal 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)
|
||||
}
|
38
nowplaying/internal/httpsrv/templates/controlPanel.go
Normal file
38
nowplaying/internal/httpsrv/templates/controlPanel.go
Normal 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")),
|
||||
},
|
||||
}
|
||||
}
|
22
nowplaying/internal/httpsrv/templates/home.go
Normal file
22
nowplaying/internal/httpsrv/templates/home.go
Normal 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"),
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue