diff options
| author | Thibaut Horel <thibaut.horel@gmail.com> | 2017-06-03 18:00:51 -0400 |
|---|---|---|
| committer | Thibaut Horel <thibaut.horel@gmail.com> | 2017-06-03 18:00:51 -0400 |
| commit | f154ae1ec88146017abf3de9d14d119facb5fc4c (patch) | |
| tree | cd857864dd52b088ccc8943b64fe9bbd59c04dc8 | |
| parent | 3f3cb7c7cede379914eed51c57e58f66ffdd1856 (diff) | |
| download | lastfm-api-f154ae1ec88146017abf3de9d14d119facb5fc4c.tar.gz | |
Basic web app
| -rw-r--r-- | Makefile | 14 | ||||
| -rw-r--r-- | apiv1.go | 192 | ||||
| -rw-r--r-- | config.go | 34 | ||||
| -rw-r--r-- | data.go | 53 | ||||
| -rw-r--r-- | lfmclient.go | 47 | ||||
| -rw-r--r-- | main.go | 228 | ||||
| -rw-r--r-- | migration.sql | 17 | ||||
| -rw-r--r-- | modern.go | 2 | ||||
| -rw-r--r-- | schema.sql | 35 | ||||
| -rw-r--r-- | static/google.png | bin | 0 -> 4099 bytes | |||
| -rw-r--r-- | static/style.css | 154 | ||||
| -rw-r--r-- | templates/base.tmpl | 25 | ||||
| -rw-r--r-- | templates/index.tmpl | 15 | ||||
| -rw-r--r-- | templates/login.tmpl | 3 | ||||
| -rw-r--r-- | templates/settings.tmpl | 37 | ||||
| -rw-r--r-- | utils.go | 27 | ||||
| -rw-r--r-- | web.go | 132 |
17 files changed, 792 insertions, 223 deletions
diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0fa6116 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +all: kill build start + +build: + go build . + +kill: + -kill $$(cat pid) + -rm -f pid + +start: + ./lastfm & echo $$! > pid + +watch: + watchman-make -p '*.go' 'templates/*.tmpl' -t all diff --git a/apiv1.go b/apiv1.go new file mode 100644 index 0000000..f36f022 --- /dev/null +++ b/apiv1.go @@ -0,0 +1,192 @@ +package main + +import ( + "errors" + "fmt" + "log" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +func (app *App) mainHandler(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + if _, ok := r.Form["hs"]; ok { + + protocol := r.FormValue("p") + if protocol != "1.2.1" && protocol != "1.2" { + fmt.Fprintln(w, "FAILED Protocol mismatch") + return + } + + timestamp := r.FormValue("t") + ts, err := strconv.ParseInt(timestamp, 10, 0) + if err != nil { + fmt.Fprintln(w, "FAILED Invalid timestamp") + return + } + + delta := time.Now().Unix() - ts + if delta > 30 || delta < -30 { + fmt.Fprintln(w, "BADTIME") + return + } + + user := r.FormValue("u") + auth := r.FormValue("a") + password, err := app.GetPassword(user) + if (md5hex(password+timestamp) != auth) || err != nil { + fmt.Fprintln(w, "BADAUTH") + return + } + + client := r.FormValue("c") + s := NewSession(user, client, protocol) + app.PutSession(s) + fmt.Fprintln(w, "OK") + fmt.Fprintf(w, "%s\n", s.Key) + fmt.Fprintln(w, "http://post.audioscrobbler.com:80/np") + fmt.Fprintln(w, "http://post.audioscrobbler.com:80/scrobble") + } else { + fmt.Fprintf(w, "<html>This is an endpoint, see <a href=\"http://www.last.fm/api/submissions\">here</a></html>") + } +} + +func parseValues(values url.Values) map[int]url.Values { + parts := make(map[int]url.Values) + var field string + for key, value := range values { + splitted_key := strings.SplitN(key, "[", 2) + if len(splitted_key) < 2 { + continue + } + field = splitted_key[0] + idx, err := strconv.Atoi(strings.TrimSuffix(splitted_key[1], "]")) + if err != nil { + continue + } else { + if _, ok := parts[idx]; !ok { + parts[idx] = make(url.Values) + } + parts[idx][field] = value + } + } + return parts +} + +func parsePart1(values url.Values) (Scrobble, error) { + var scrobble Scrobble + scrobble.TrackName = NewCorrectable(values.Get("t")) + scrobble.Artist = NewCorrectable(values.Get("a")) + if t, err := strconv.Atoi(values.Get("i")); err != nil { + return scrobble, errors.New("Could not parse timestamp") + } else { + scrobble.Time = time.Unix(int64(t), 0) + } + scrobble.Album = NewCorrectable(values.Get("b")) + scrobble.Mbid = values.Get("m") + if tn, err := strconv.Atoi(values.Get("n")); err != nil { + return scrobble, errors.New("Could not parse track number") + } else { + scrobble.TrackNumber = tn + } + if duration, err := strconv.Atoi(values.Get("l")); err != nil { + return scrobble, errors.New("Could not parse duration") + } else { + scrobble.Duration = duration + } + chosen := values.Get("o") + if chosen == "P" || chosen == "" { + scrobble.Chosen = true + } + return scrobble, nil +} + +func parsePart2(values url.Values) (Scrobble, error) { + var scrobble Scrobble + scrobble.TrackName = NewCorrectable(values.Get("track")) + scrobble.Artist = NewCorrectable(values.Get("artist")) + if t, err := strconv.Atoi(values.Get("timestamp")); err != nil { + return scrobble, errors.New("Could not parse timestamp") + } else { + scrobble.Time = time.Unix(int64(t), 0) + } + scrobble.Album = NewCorrectable(values.Get("album")) + scrobble.Mbid = values.Get("mbid") + if tn, ok := values["trackNumber"]; ok { + if tn, err := strconv.Atoi(tn[0]); err != nil { + return scrobble, errors.New("Could not parse track number") + } else { + scrobble.TrackNumber = tn + } + } + if duration, err := strconv.Atoi(values.Get("duration")); err != nil { + return scrobble, errors.New("Could not parse duration") + } else { + scrobble.Duration = duration + } + chosen := values.Get("chosenByUser") + if chosen == "1" || chosen == "" { + scrobble.Chosen = true + } + return scrobble, nil +} + +func parseScrobbles(values url.Values, session *Session) ([]Scrobble, int) { + scrobbles := make([]Scrobble, 0, 1) + parts := parseValues(values) + var parsePart func(url.Values) (Scrobble, error) + if session.Protocol == "2.0" { + parsePart = parsePart2 + } else { + parsePart = parsePart1 + } + ignored := 0 + for i, c := 0, 0; i < 50 && c < len(parts); i++ { + if part, ok := parts[i]; ok { + c++ + if scrobble, err := parsePart(part); err != nil { + fmt.Printf("%v\n", err) + ignored++ + } else { + scrobble.Session = session.Key + scrobbles = append(scrobbles, scrobble) + } + } + } + return scrobbles, ignored +} + +func (app *App) scrobbleHandler(w http.ResponseWriter, r *http.Request) { + if session, err := app.GetSession(r.FormValue("s")); err != nil { + fmt.Fprintln(w, "BADSESSION") + } else { + scrobbles, _ := parseScrobbles(r.Form, session) + + for i, s := range scrobbles { + t := app.TrackInfo(s.TrackName.Name, s.Artist.Name) + s.Mbid = t.Mbid + scrobbles[i] = s + _, err = app.DB.Exec("INSERT INTO songs VALUES ($1, $2) "+ + "ON CONFLICT (mbid) DO UPDATE SET mbid=$1, image=$2", + t.Mbid, t.GetImage("medium")) + if err != nil { + log.Println(err) + } + } + + fmt.Printf("%v\n", scrobbles) + app.PutScrobbles(scrobbles) + fmt.Fprintln(w, "OK") + } +} + +func (app *App) nowPlayingHandler(w http.ResponseWriter, r *http.Request) { + if _, err := app.GetSession(r.FormValue("s")); err != nil { + fmt.Fprintln(w, "BADSESSION") + } else { + fmt.Fprintln(w, "OK") + } +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..0f0b853 --- /dev/null +++ b/config.go @@ -0,0 +1,34 @@ +package main + +import ( + "encoding/json" + "io/ioutil" + "log" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" +) + +type Config struct { + OAuth oauth2.Config + HashKey string + BlockKey string + Database string + Lfm LfmConfig +} + +type LfmConfig struct { + ApiKey string + SharedSecret string +} + +func NewConfig() *Config { + f, err := ioutil.ReadFile("config.json") + if err != nil { + log.Fatal(err) + } + conf := new(Config) + json.Unmarshal(f, conf) + conf.OAuth.Endpoint = google.Endpoint + return conf +} @@ -4,6 +4,7 @@ import ( "database/sql" "encoding/xml" "fmt" + "log" "time" _ "github.com/mattn/go-sqlite3" @@ -16,10 +17,11 @@ type Scrobble struct { Album Correctable `xml:"album" json:"album"` TrackNumber int `xml:"-" json:"-"` Duration int `xml:"-" json:"-"` - Time int `xml:"timestamp" json:"timestamp,string"` + Time time.Time `xml:"timestamp" json:"timestamp,string"` Chosen bool `xml:"-" json:"-"` Mbid string `xml:"-" json:"-"` Session string `xml:"-" json:"-"` + Image string } type Session struct { @@ -28,7 +30,7 @@ type Session struct { Key string `json:"key" xml:"key"` Client string Protocol string - Created int64 + Created time.Time Subscriber int64 `json:"subscriber" xml:"subscriber"` } @@ -37,6 +39,7 @@ type DataStore interface { GetSession(key string) (*Session, error) GetPassword(userName string) (string, error) PutScrobbles([]Scrobble) + RecentScrobbles(lfmName string) []*Scrobble Api } @@ -44,43 +47,34 @@ type SqlStore struct { *sql.DB } -func NewSqlStore() *SqlStore { - db, err := sql.Open("sqlite3", "./test.db") - if err != nil { - fmt.Println(err) - } - err = db.Ping() - if err != nil { - fmt.Println(err) - } - return &SqlStore{db} -} - func NewSession(user string, client string, protocol string) *Session { return &Session{ User: user, Key: randomToken(16), Client: client, Protocol: protocol, - Created: time.Now().Unix(), + Created: time.Now(), } } func (store *SqlStore) PutSession(s *Session) { - store.Exec("INSERT INTO sessions VALUES (?, ?, ?, ?, ?)", + _, err := store.Exec("INSERT INTO scrobbling_sessions VALUES ($1, $2, $3, $4, $5)", s.User, s.Key, s.Client, s.Protocol, s.Created) + if err != nil { + log.Println(err) + } } func (store *SqlStore) GetSession(key string) (*Session, error) { s := &Session{} - row := store.QueryRow("SELECT * FROM sessions WHERE key = ?", key) + row := store.QueryRow("SELECT * FROM scrobbling_sessions WHERE key = $1", key) err := row.Scan(&s.User, &s.Key, &s.Client, &s.Protocol, &s.Created) return s, err } func (store *SqlStore) GetPassword(name string) (string, error) { var password string - row := store.QueryRow("SELECT password FROM users WHERE name = ?", name) + row := store.QueryRow("SELECT lfm_password FROM users WHERE lfm_name = $1", name) err := row.Scan(&password) return password, err } @@ -88,7 +82,7 @@ func (store *SqlStore) GetPassword(name string) (string, error) { func (store *SqlStore) PutScrobbles(scrobbles []Scrobble) { for _, s := range scrobbles { if _, err := store.Exec( - "INSERT INTO scrobbles VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO scrobbles VALUES ($1, $2, $3, $4,$5, $6, $7, $8, $9, $10)", s.Artist, s.AlbumArtist, s.TrackName, @@ -104,3 +98,24 @@ func (store *SqlStore) PutScrobbles(scrobbles []Scrobble) { } } } + +func (store *SqlStore) RecentScrobbles(lfmName string) []*Scrobble { + scrobbles := make([]*Scrobble, 0, 10) + rows, err := store.Query("SELECT artist, album, trackname, time, image "+ + "FROM scrobbles JOIN scrobbling_sessions ON session = key "+ + "LEFT JOIN songs ON scrobbles.mbid = songs.mbid "+ + "WHERE lfm_name=$1 ORDER BY time DESC LIMIT 10", + lfmName) + if err != nil { + log.Println(err) + } + + for rows.Next() { + scrobble := new(Scrobble) + rows.Scan(&scrobble.Artist.Name, &scrobble.Album.Name, + &scrobble.TrackName.Name, &scrobble.Time, &scrobble.Image) + scrobbles = append(scrobbles, scrobble) + } + rows.Close() + return scrobbles +} diff --git a/lfmclient.go b/lfmclient.go new file mode 100644 index 0000000..244ab8c --- /dev/null +++ b/lfmclient.go @@ -0,0 +1,47 @@ +package main + +import ( + "encoding/json" + "io/ioutil" + "net/http" +) + +type AlbumImage struct { + Size string `json:"size"` + Href string `json:"#text"` +} + +type TrackAlbum struct { + Images []AlbumImage `json:"image"` +} + +func (t TrackGetInfo) GetImage(size string) string { + images := t.Album.Images + for _, image := range images { + if image.Size == size { + return image.Href + } + } + return "https://lastfm-img2.akamaized.net/i/u/64s/4128a6eb29f94943c9d206c08e625904.png" +} + +type TrackGetInfo struct { + Mbid string `json:"mbid"` + Album TrackAlbum `jon:"album"` +} + +func (app *App) TrackInfo(track string, artist string) TrackGetInfo { + r, _ := http.NewRequest("GET", "http://ws.audioscrobbler.com/2.0/", nil) + values := r.URL.Query() + values.Add("method", "track.getInfo") + values.Add("api_key", app.Config.Lfm.ApiKey) + values.Add("artist", artist) + values.Add("track", track) + values.Add("format", "json") + r.URL.RawQuery = values.Encode() + resp, _ := http.DefaultClient.Do(r) + body, _ := ioutil.ReadAll(resp.Body) + var dst map[string]TrackGetInfo + json.Unmarshal(body, &dst) + return dst["track"] +} @@ -1,199 +1,75 @@ package main import ( - "errors" + "database/sql" + "encoding/hex" "fmt" + "html/template" + "log" "net/http" "net/http/httputil" - "net/url" - "strconv" - "strings" - "time" -) - -func mainHandler(ds DataStore, w http.ResponseWriter, r *http.Request) { - r.ParseForm() - if _, ok := r.Form["hs"]; ok { - - protocol := r.FormValue("p") - if protocol != "1.2.1" && protocol != "1.2" { - fmt.Fprintln(w, "FAILED Protocol mismatch") - return - } - - timestamp := r.FormValue("t") - ts, err := strconv.ParseInt(timestamp, 10, 0) - if err != nil { - fmt.Fprintln(w, "FAILED Invalid timestamp") - return - } - - delta := time.Now().Unix() - ts - if delta > 30 || delta < -30 { - fmt.Fprintln(w, "BADTIME") - return - } - - user := r.FormValue("u") - auth := r.FormValue("a") - password, err := ds.GetPassword(user) - if (md5hex(password+timestamp) != auth) || err != nil { - fmt.Fprintln(w, "BADAUTH") - return - } - - client := r.FormValue("c") - s := NewSession(user, client, protocol) - ds.PutSession(s) - fmt.Fprintln(w, "OK") - fmt.Fprintf(w, "%s\n", s.Key) - fmt.Fprintln(w, "http://post.audioscrobbler.com:80/np") - fmt.Fprintln(w, "http://post.audioscrobbler.com:80/scrobble") - } else { - fmt.Fprintf(w, "<html>This is an endpoint, see <a href=\"http://www.last.fm/api/submissions\">here</a></html>") - } -} - -func parseValues(values url.Values) map[int]url.Values { - parts := make(map[int]url.Values) - var field string - for key, value := range values { - splitted_key := strings.SplitN(key, "[", 2) - if len(splitted_key) < 2 { - continue - } - field = splitted_key[0] - idx, err := strconv.Atoi(strings.TrimSuffix(splitted_key[1], "]")) - if err != nil { - continue - } else { - if _, ok := parts[idx]; !ok { - parts[idx] = make(url.Values) - } - parts[idx][field] = value - } - } - return parts -} - -func parsePart1(values url.Values) (Scrobble, error) { - var scrobble Scrobble - scrobble.TrackName = NewCorrectable(values.Get("t")) - scrobble.Artist = NewCorrectable(values.Get("a")) - if time, err := strconv.Atoi(values.Get("i")); err != nil { - return scrobble, errors.New("Could not parse timestamp") - } else { - scrobble.Time = time - } - scrobble.Album = NewCorrectable(values.Get("b")) - scrobble.Mbid = values.Get("m") - if tn, err := strconv.Atoi(values.Get("n")); err != nil { - return scrobble, errors.New("Could not parse track number") - } else { - scrobble.TrackNumber = tn - } - if duration, err := strconv.Atoi(values.Get("l")); err != nil { - return scrobble, errors.New("Could not parse duration") - } else { - scrobble.Duration = duration - } - chosen := values.Get("o") - if chosen == "P" || chosen == "" { - scrobble.Chosen = true - } - return scrobble, nil -} -func parsePart2(values url.Values) (Scrobble, error) { - var scrobble Scrobble - scrobble.TrackName = NewCorrectable(values.Get("track")) - scrobble.Artist = NewCorrectable(values.Get("artist")) - if time, err := strconv.Atoi(values.Get("timestamp")); err != nil { - return scrobble, errors.New("Could not parse timestamp") - } else { - scrobble.Time = time - } - scrobble.Album = NewCorrectable(values.Get("album")) - scrobble.Mbid = values.Get("mbid") - if tn, ok := values["trackNumber"]; ok { - if tn, err := strconv.Atoi(tn[0]); err != nil { - return scrobble, errors.New("Could not parse track number") - } else { - scrobble.TrackNumber = tn - } - } - if duration, err := strconv.Atoi(values.Get("duration")); err != nil { - return scrobble, errors.New("Could not parse duration") - } else { - scrobble.Duration = duration - } - chosen := values.Get("chosenByUser") - if chosen == "1" || chosen == "" { - scrobble.Chosen = true - } - return scrobble, nil -} + "github.com/gorilla/securecookie" +) -func parseScrobbles(values url.Values, session *Session) ([]Scrobble, int) { - scrobbles := make([]Scrobble, 0, 1) - parts := parseValues(values) - var parsePart func(url.Values) (Scrobble, error) - if session.Protocol == "2.0" { - parsePart = parsePart2 - } else { - parsePart = parsePart1 - } - ignored := 0 - for i, c := 0, 0; i < 50 && c < len(parts); i++ { - if part, ok := parts[i]; ok { - c++ - if scrobble, err := parsePart(part); err != nil { - fmt.Printf("%v\n", err) - ignored++ - } else { - scrobble.Session = session.Key - scrobbles = append(scrobbles, scrobble) - } - } - } - return scrobbles, ignored -} +type handler func(DataStore, http.ResponseWriter, *http.Request) -func scrobbleHandler(ds DataStore, w http.ResponseWriter, r *http.Request) { - if session, err := ds.GetSession(r.FormValue("s")); err != nil { - fmt.Fprintln(w, "BADSESSION") - } else { - scrobbles, _ := parseScrobbles(r.Form, session) - fmt.Printf("%v\n", scrobbles) - ds.PutScrobbles(scrobbles) - fmt.Fprintln(w, "OK") - } +type App struct { + DataStore + DB *sql.DB + Config *Config + CookieHandler *securecookie.SecureCookie + Template *template.Template } -func nowPlayingHandler(ds DataStore, w http.ResponseWriter, r *http.Request) { - if _, err := ds.GetSession(r.FormValue("s")); err != nil { - fmt.Fprintln(w, "BADSESSION") - } else { - fmt.Fprintln(w, "OK") +func New() *App { + app := new(App) + config := NewConfig() + app.Config = config + db, err := sql.Open("postgres", config.Database) + if err = db.Ping(); err != nil { + log.Fatal(err) } + app.DB = db + app.DataStore = &SqlStore{db} + hashKey, err := hex.DecodeString(config.HashKey) + blockKey, err := hex.DecodeString(config.HashKey) + s := securecookie.New(hashKey, blockKey) + app.CookieHandler = s + templates := template.Must(template.ParseGlob("templates/*.tmpl")) + app.Template = templates + return app } -type handler func(DataStore, http.ResponseWriter, *http.Request) - -func wrap(ds DataStore, fn handler) http.HandlerFunc { +func logg(fn http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { d, _ := httputil.DumpRequest(r, true) fmt.Printf("--------------------------\n%s\n", d) r.ParseForm() - fn(ds, w, r) + fn(w, r) } } func main() { - store := NewSqlStore() - http.HandleFunc("/", wrap(store, handler(mainHandler))) - http.HandleFunc("/np", wrap(store, nowPlayingHandler)) - http.HandleFunc("/scrobble", wrap(store, scrobbleHandler)) - http.HandleFunc("/2.0/", wrap(store, ApiHandler)) - http.ListenAndServe(":3001", nil) + app := New() + + go func() { + sm := http.NewServeMux() + sm.HandleFunc("/", logg(app.mainHandler)) + sm.HandleFunc("/np", logg(app.nowPlayingHandler)) + sm.HandleFunc("/scrobble", logg(app.scrobbleHandler)) + //http.HandleFunc("/2.0/", logg(app.ApiHandler)) + http.ListenAndServe(":3001", sm) + }() + + app.TrackInfo("believe", "cher") + + sm := http.NewServeMux() + fs := http.FileServer(http.Dir("static")) + sm.Handle("/static/", http.StripPrefix("/static/", fs)) + sm.HandleFunc("/", app.root) + sm.HandleFunc("/callback", app.callback) + sm.HandleFunc("/login", app.login) + sm.HandleFunc("/settings", app.settings) + http.ListenAndServe(":8000", sm) } diff --git a/migration.sql b/migration.sql deleted file mode 100644 index cd95209..0000000 --- a/migration.sql +++ /dev/null @@ -1,17 +0,0 @@ -BEGIN; - - CREATE TABLE new_sessions ( - user string, - key string PRIMARY KEY, - client string, - protocol string, - created int - ); - - INSERT INTO new_sessions SELECT * FROM sessions; - - DROP TABLE sessions; - - ALTER TABLE new_sessions RENAME TO sessions; - -END; @@ -59,7 +59,7 @@ func NewCorrectable(name string) Correctable { } } -func (field *Correctable) String() string { +func (field Correctable) String() string { return field.Name } @@ -1,25 +1,40 @@ -CREATE TABLE users ( - name text PRIMARY KEY, - password text -); - -CREATE TABLE sessions ( - user text, +CREATE TABLE IF NOT EXISTS scrobbling_sessions ( + lfm_name text, key text PRIMARY KEY, client text, protocol text, - created int DEFAULT (strftime('%s', 'now')) + created timestamptz DEFAULT current_timestamp ); -CREATE TABLE scrobbles ( +CREATE TABLE IF NOT EXISTS scrobbles ( artist text, albumartist text, trackname text, album text, tracknumber int, duration int, - time int, + time timestamptz, chosen bool, mbid text, session text ); + +CREATE TABLE IF NOT EXISTS users ( + user_id SERIAL PRIMARY KEY, + type text, + op_id text, + name text, + email text, + lfm_name text, + lfm_password text +); + +CREATE TABLE IF NOT EXISTS user_sessions ( + id text PRIMARY KEY, + user_id integer REFERENCES users +); + +CREATE TABLE IF NOT EXISTS songs ( + mbid text PRIMARY KEY, + image text +); diff --git a/static/google.png b/static/google.png Binary files differnew file mode 100644 index 0000000..29ab511 --- /dev/null +++ b/static/google.png diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..fca47c8 --- /dev/null +++ b/static/style.css @@ -0,0 +1,154 @@ +body { + font-family: 'Roboto'; + font-size: 16px; + margin: 0; +} + +a { + text-decoration: none; +} + +header { + height: 50px; + background-color: #3F51B5; + box-sizing: border-box; + color: white; + margin-bottom: 1em; + box-shadow: 0 0 4px #1A237E; +} + +h1 { + font-size: 24px; + font-weight: 500; + margin: 0; + padding: 0; +} + +h1 > a { + color: white; +} + +h2 { + font-weight: 500; + font-size: 20px; + margin: 16px; +} + +ul.scrobbles { + padding: 0; + margin: 0; + list-style: none; +} + +ul.scrobbles li { + display: flex; + height: 60px; + padding: 0 16px; + align-items: center; +} + +ul.scrobbles li:hover { + background-color: #E8EAF6; +} + +ul.scrobbles li img { + height: 40px; + margin-right: 16px; + border: 1px solid #C5CAE9; +} + +ul.scrobbles li div { + font-size: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 18px; +} + +ul.scrobbles li .album { + font-size: 12px; + color: #4f4f4f; +} + +#center { + height: 100%; + max-width: 1000px; + margin: 0 auto; + padding: 0 16px; + display: flex; + box-sizing: border-box; + align-items: center; +} + +.user { + color: rgba(255, 255, 255, .8); + margin-left: auto; +} + +.user:hover { + color: rgba(255, 255, 255, 1); +} + +#main { + max-width: 1000px; + margin: 0 auto; +} + +input[type=text], input[type=password] { + border: none; + padding-bottom: 8px; + font-size: 16px; + border-bottom: 1px solid grey; +} + +label { + font-size: 12px; + box-sizing: border-box; + margin-bottom: 8px; +} + +input[type=text]:focus, input[type=password]:focus { + outline: none; + padding-bottom: 7px; + border-bottom: 2px solid #3F51B5; +} + +input:focus+label { + color: #3F51B5; + +} + +.row { + display: flex; + margin-bottom: 1em; + flex-wrap: wrap; + margin-top: -1em; + padding: 0 16px; +} + +.form-element { + display: flex; + flex-direction: column-reverse; + margin-right:1em; + margin-top: 1em; + width: 300px; +} + +input[type=submit] { + border: none; + background: none; + padding: 0; + font-family: 'Roboto'; + color: #3F51B5; + font-size: 16px; + font-weight: 500; + cursor: pointer +} + +input[type=submit]:focus { + outline: none; +} + +form > section { + margin-bottom: 2em; +} diff --git a/templates/base.tmpl b/templates/base.tmpl new file mode 100644 index 0000000..2ee087a --- /dev/null +++ b/templates/base.tmpl @@ -0,0 +1,25 @@ +{{define "header"}} +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>ListenDiary</title> + <link href="https://fonts.googleapis.com/css?family=Roboto:400,500,700" rel="stylesheet"> + <link rel="stylesheet" href="static/style.css"> + </head> + <body> +<header> + <div id="center"> + <h1><a href="/">ListenDiary</a></h1> + <a href="/settings" class="user">{{ .Session.UserName }}</a> + </div> +</header> +<div id="main"> +{{end}} + +{{define "footer"}} +</div> + </body> +</html> +{{end}} diff --git a/templates/index.tmpl b/templates/index.tmpl new file mode 100644 index 0000000..7ee1801 --- /dev/null +++ b/templates/index.tmpl @@ -0,0 +1,15 @@ +{{template "header" . }} +<h2>Recent Listens</h2> +<ul class="scrobbles"> +{{range .Scrobbles}} + <li> + <img src="{{or .Image "https://lastfm-img2.akamaized.net/i/u/64s/4128a6eb29f94943c9d206c08e625904.png"}}" + height="40"/> + <div> + {{.Artist}} — {{.TrackName}}<br/> + <span class="album">in {{.Album}}</span> + </div> + </li> +{{end}} +</ul> +{{template "footer" }} diff --git a/templates/login.tmpl b/templates/login.tmpl new file mode 100644 index 0000000..1510242 --- /dev/null +++ b/templates/login.tmpl @@ -0,0 +1,3 @@ +{{template "header"}} +<a href="{{.}}"><img src="static/google.png"/></a> +{{template "footer"}} diff --git a/templates/settings.tmpl b/templates/settings.tmpl new file mode 100644 index 0000000..5c757ee --- /dev/null +++ b/templates/settings.tmpl @@ -0,0 +1,37 @@ +{{template "header" .}} +<form method=post action="/settings"> +<section> +<h2>Profile</h2> + <div class="row"> + <div class="form-element"> + <input name="name" id="name" type="text" value={{.UserName}}> + <label for="name">Name</label> + </div> + + <div class="form-element"> + <input name="email" id="email" type="text" value={{.Email}}> + <label for="email">Email</label> + </div> + </div> +</section> + +<section> +<h2>Last.fm</h2> + <div class="row"> + <div class="form-element"> + <input name="lfm_name" id="lfm_name" type="text" value={{.LfmName}}> + <label for="lfm_name">Username</label> + </div> + + <div class="form-element"> + <input name="lfm_password" id="lfm_password" type="password" value={{.LfmPassword}}> + <label for="lfm_password">Password</label> + </div> + </div> + +</section> +<div class="row"> +<input type="submit" value="SAVE"> +</div> +</form> +{{template "footer"}} @@ -4,6 +4,7 @@ import ( "crypto/md5" "crypto/rand" "encoding/hex" + "net/http" ) func randomToken(length int) string { @@ -12,7 +13,33 @@ func randomToken(length int) string { return hex.EncodeToString(b) } +func genKey(length int) []byte { + b := make([]byte, length) + rand.Read(b) + return b +} + func md5hex(s string) string { hash := md5.Sum([]byte(s)) return hex.EncodeToString(hash[:]) } + +func (app *App) GetCookie(r *http.Request, name string, dst interface{}) error { + cookie, err := r.Cookie(name) + if err != nil { + return err + } + return app.CookieHandler.Decode(name, cookie.Value, dst) +} + +func (app *App) SetCookie(w http.ResponseWriter, name string, v interface{}, exp int) { + encoded, _ := app.CookieHandler.Encode(name, v) + cookie := &http.Cookie{ + Name: name, + Value: encoded, + Path: "/", + HttpOnly: true, + MaxAge: exp, + } + http.SetCookie(w, cookie) +} @@ -0,0 +1,132 @@ +package main + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + + _ "github.com/lib/pq" +) + +type UserInfo struct { + Sub string `json:"sub"` + UserName string `json:"given_name"` + Email string `json:"email"` +} + +type UserSession struct { + Id string + UserId int64 + UserName string +} + +func (app *App) login(w http.ResponseWriter, r *http.Request) { + state := hex.EncodeToString(genKey(32)) + app.SetCookie(w, "state", state, 120) + url := app.Config.OAuth.AuthCodeURL(state) + app.Template.ExecuteTemplate(w, "login.tmpl", url) +} + +func (app *App) root(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + se := new(UserSession) + err := app.GetCookie(r, "session", se) + if err != nil { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + + var lfmName string + row := app.DB.QueryRow("SELECT lfm_name FROM users WHERE user_id=$1", + se.UserId) + row.Scan(&lfmName) + scrobbles := app.RecentScrobbles(lfmName) + + app.Template.ExecuteTemplate(w, "index.tmpl", struct { + Session *UserSession + Scrobbles []*Scrobble + }{se, scrobbles}) +} + +func (app *App) callback(w http.ResponseWriter, r *http.Request) { + redir := func(err error) { + if err != nil { + http.Redirect(w, r, "/login", http.StatusFound) + log.Panic(err) + } + } + + var state string + app.GetCookie(r, "state", &state) + if state == "" || state != r.FormValue("state") { + redir(fmt.Errorf("state")) + } + code := r.FormValue("code") + tok, err := app.Config.OAuth.Exchange(context.Background(), code) + redir(err) + client := app.Config.OAuth.Client(context.Background(), tok) + resp, _ := client.Get("https://www.googleapis.com/plus/v1/people/me/openIdConnect") + p, _ := ioutil.ReadAll(resp.Body) + userinfo := new(UserInfo) + err = json.Unmarshal(p, userinfo) + redir(err) + + se := new(UserSession) + se.Id = hex.EncodeToString(genKey(32)) + row := app.DB.QueryRow("SELECT user_id, name FROM users WHERE type='google' AND op_id=$1", + userinfo.Sub) + err = row.Scan(&se.UserId, &se.UserName) + if err != nil { + row := app.DB.QueryRow("INSERT into users (type, op_id, name, email)"+ + "values ('google', $1, $2, $3) RETURNING user_id", + userinfo.Sub, userinfo.UserName, userinfo.Email) + row.Scan(&se.UserId) + se.UserName = userinfo.UserName + } + app.DB.Exec("INSERT into user_sessions values ($1, $2)", se.Id, se.UserId) + app.SetCookie(w, "session", se, 86400*30) + if err != nil { + http.Redirect(w, r, "/settings", http.StatusTemporaryRedirect) + return + } + http.Redirect(w, r, "/", http.StatusFound) +} + +func (app *App) settings(w http.ResponseWriter, r *http.Request) { + se := new(UserSession) + err := app.GetCookie(r, "session", se) + if err != nil { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + + if r.Method == "POST" { + _, err = app.DB.Exec("UPDATE users SET name=$1, email=$2, lfm_name=$3, lfm_password=$4 WHERE user_id=$5", + r.FormValue("name"), r.FormValue("email"), r.FormValue("lfm_name"), + md5hex(r.FormValue("lfm_password")), se.UserId) + if err != nil { + log.Println(err) + } + se.UserName = r.FormValue("name") + app.SetCookie(w, "session", se, 86400*30) + } + + var userName, email, lfmName, lfmPassword string + row := app.DB.QueryRow("SELECT name, email, lfm_name, lfm_password FROM users WHERE user_id=$1", + se.UserId) + row.Scan(&userName, &email, &lfmName, &lfmPassword) + app.Template.ExecuteTemplate(w, "settings.tmpl", struct { + Session *UserSession + UserName string + Email string + LfmName string + LfmPassword string + }{se, userName, email, lfmName, lfmPassword}) +} |
