seperate routes, provide robust handler types, use mux

This commit is contained in:
sentriz
2019-07-14 19:32:36 +01:00
parent cbe709025e
commit 5444b328fd
77 changed files with 11880 additions and 1011 deletions

View File

@@ -9,11 +9,12 @@ if ! test -e "$embed_bin_path"; then
cmd/gonicembed/main.go cmd/gonicembed/main.go
fi fi
find server/assets/ \ find assets/ \
-type f \ -type f \
! -name '*.go' \
-exec "$embed_bin_path" \ -exec "$embed_bin_path" \
-out-path server/assets_bytes.go \ -out-path assets/assets_gen.go \
-package-name server \ -package-name assets \
-assets-var-name assetBytes \ -assets-var-name Bytes \
-asset-path-prefix server/assets/ \ -asset-path-prefix assets/ \
{} + {} +

View File

@@ -1,6 +1,6 @@
#!/bin/sh #!/bin/sh
test_data_path=server/handler/test_data test_data_path=server/ctrlsubsonic/testdata
test_listen_addr=localhost:9353 test_listen_addr=localhost:9353
test_music_path=~/music test_music_path=~/music
test_db_path=$test_data_path/db test_db_path=$test_data_path/db

13
assets/assets.go Normal file
View File

@@ -0,0 +1,13 @@
package assets
import "strings"
// PrefixDo runs a given callback for every path in our assets with
// the given prefix
func PrefixDo(pre string, cb func(path string, asset *EmbeddedAsset)) {
for path, asset := range Bytes {
if strings.HasPrefix(path, pre) {
cb(path, asset)
}
}
}

10809
assets/assets_gen.go Normal file

File diff suppressed because it is too large Load Diff

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 236 KiB

View File

@@ -1,7 +1,6 @@
package main package main
import ( import (
"encoding/gob"
"flag" "flag"
"log" "log"
"os" "os"
@@ -11,7 +10,6 @@ import (
"senan.xyz/g/gonic/db" "senan.xyz/g/gonic/db"
"senan.xyz/g/gonic/server" "senan.xyz/g/gonic/server"
"senan.xyz/g/gonic/server/handler"
) )
const ( const (
@@ -49,7 +47,6 @@ func main() {
log.Fatalf("error opening database: %v\n", err) log.Fatalf("error opening database: %v\n", err)
} }
defer db.Close() defer db.Close()
gob.Register(&handler.Flash{})
s := server.New( s := server.New(
db, db,
*musicPath, *musicPath,

1
eggs Normal file

File diff suppressed because one or more lines are too long

4
go.mod
View File

@@ -7,19 +7,21 @@ require (
github.com/Masterminds/sprig v2.20.0+incompatible github.com/Masterminds/sprig v2.20.0+incompatible
github.com/dustin/go-humanize v1.0.0 github.com/dustin/go-humanize v1.0.0
github.com/google/uuid v1.1.1 // indirect github.com/google/uuid v1.1.1 // indirect
github.com/gorilla/mux v1.7.3
github.com/gorilla/securecookie v1.1.1 github.com/gorilla/securecookie v1.1.1
github.com/gorilla/sessions v1.1.3 github.com/gorilla/sessions v1.1.3
github.com/huandu/xstrings v1.2.0 // indirect github.com/huandu/xstrings v1.2.0 // indirect
github.com/imdario/mergo v0.3.7 // indirect github.com/imdario/mergo v0.3.7 // indirect
github.com/jinzhu/gorm v1.9.9 github.com/jinzhu/gorm v1.9.9
github.com/josephburnett/jd v0.0.0-20190531151850-1f9071c800e7 github.com/josephburnett/jd v0.0.0-20190531151850-1f9071c800e7
github.com/justinas/alice v0.0.0-20171023064455-03f45bd4b7da
github.com/karrick/godirwalk v1.10.12 github.com/karrick/godirwalk v1.10.12
github.com/kr/pretty v0.1.0 // indirect github.com/kr/pretty v0.1.0 // indirect
github.com/nicksellen/audiotags v0.0.0-20160226222119-94015fa599bd github.com/nicksellen/audiotags v0.0.0-20160226222119-94015fa599bd
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c
github.com/peterbourgon/ff v1.2.0 github.com/peterbourgon/ff v1.2.0
github.com/pkg/errors v0.8.1 github.com/pkg/errors v0.8.1
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be
github.com/stretchr/testify v1.3.0 // indirect
github.com/wader/gormstore v0.0.0-20190302154359-acb787ba3755 github.com/wader/gormstore v0.0.0-20190302154359-acb787ba3755
golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4 // indirect golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4 // indirect
google.golang.org/appengine v1.6.1 // indirect google.golang.org/appengine v1.6.1 // indirect

6
go.sum
View File

@@ -62,6 +62,8 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.1.3 h1:uXoZdcdA5XdXF3QzuSlheVRUvjl+1rKY7zBXL68L9RU= github.com/gorilla/sessions v1.1.3 h1:uXoZdcdA5XdXF3QzuSlheVRUvjl+1rKY7zBXL68L9RU=
@@ -83,6 +85,8 @@ github.com/josephburnett/jd v0.0.0-20190531151850-1f9071c800e7 h1:/sW5BavO4uIUSq
github.com/josephburnett/jd v0.0.0-20190531151850-1f9071c800e7/go.mod h1:aeV+6oc13ogwzcRNHBe4vbyLmoQxMfEDoqyqCU9oE30= github.com/josephburnett/jd v0.0.0-20190531151850-1f9071c800e7/go.mod h1:aeV+6oc13ogwzcRNHBe4vbyLmoQxMfEDoqyqCU9oE30=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/justinas/alice v0.0.0-20171023064455-03f45bd4b7da h1:5y58+OCjoHCYB8182mpf/dEsq0vwTKPOo4zGfH0xW9A=
github.com/justinas/alice v0.0.0-20171023064455-03f45bd4b7da/go.mod h1:oLH0CmIaxCGXD67VKGR5AacGXZSMznlmeqM8RzPrcY8=
github.com/karrick/godirwalk v1.10.12 h1:BqUm+LuJcXjGv1d2mj3gBiQyrQ57a0rYoAmhvJQ7RDU= github.com/karrick/godirwalk v1.10.12 h1:BqUm+LuJcXjGv1d2mj3gBiQyrQ57a0rYoAmhvJQ7RDU=
github.com/karrick/godirwalk v1.10.12/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA= github.com/karrick/godirwalk v1.10.12/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA=
github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg= github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg=
@@ -106,6 +110,8 @@ github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+W
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
github.com/peterbourgon/ff v1.2.0 h1:wGn2NwdHk8MTlRQpnXnO91UKegxt5DvlwR/bTK/L2hc= github.com/peterbourgon/ff v1.2.0 h1:wGn2NwdHk8MTlRQpnXnO91UKegxt5DvlwR/bTK/L2hc=
github.com/peterbourgon/ff v1.2.0/go.mod h1:ljiF7yxtUvZaxUDyUqQa0+uiEOgwVboj+Q2S2+0nq40= github.com/peterbourgon/ff v1.2.0/go.mod h1:ljiF7yxtUvZaxUDyUqQa0+uiEOgwVboj+Q2S2+0nq40=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=

View File

@@ -172,6 +172,8 @@ var coverFilenames = map[string]struct{}{
"front.jpeg": {}, "front.jpeg": {},
} }
// ## begin callbacks
// ## begin callbacks
// ## begin callbacks // ## begin callbacks
func (s *Scanner) callbackItem(fullPath string, info *godirwalk.Dirent) error { func (s *Scanner) callbackItem(fullPath string, info *godirwalk.Dirent) error {
@@ -237,6 +239,8 @@ func decoded(in string) string {
return result return result
} }
// ## begin handlers
// ## begin handlers
// ## begin handlers // ## begin handlers
func (s *Scanner) handleFolder(it *item) error { func (s *Scanner) handleFolder(it *item) error {

233
server/ctrladmin/ctrl.go Normal file
View File

@@ -0,0 +1,233 @@
package ctrladmin
import (
"encoding/gob"
"fmt"
"html/template"
"log"
"net/http"
"path/filepath"
"time"
"github.com/Masterminds/sprig"
"github.com/dustin/go-humanize"
"github.com/gorilla/securecookie"
"github.com/gorilla/sessions"
"github.com/oxtoacart/bpool"
"github.com/wader/gormstore"
"senan.xyz/g/gonic/assets"
"senan.xyz/g/gonic/model"
"senan.xyz/g/gonic/server/ctrlbase"
"senan.xyz/g/gonic/server/key"
)
func init() {
gob.Register(&Flash{})
}
// extendFromPaths /extends/ the given template for every asset
// with given prefix
func extendFromPaths(b *template.Template, p string) *template.Template {
assets.PrefixDo(p, func(_ string, asset *assets.EmbeddedAsset) {
tmplStr := string(asset.Bytes)
b = template.Must(b.Parse(tmplStr))
})
return b
}
// extendFromPaths /clones/ the given template for every asset
// with given prefix, extends it, and insert it into a new map
func pagesFromPaths(b *template.Template, p string) map[string]*template.Template {
ret := map[string]*template.Template{}
assets.PrefixDo(p, func(path string, asset *assets.EmbeddedAsset) {
tmplKey := filepath.Base(path)
clone := template.Must(b.Clone())
tmplStr := string(asset.Bytes)
ret[tmplKey] = template.Must(clone.Parse(tmplStr))
})
return ret
}
const (
prefixPartials = "partials"
prefixLayouts = "layouts"
prefixPages = "pages"
)
type Controller struct {
*ctrlbase.Controller
buffPool *bpool.BufferPool
templates map[string]*template.Template
sessDB *gormstore.Store
}
func New(base *ctrlbase.Controller) *Controller {
sessionKey := []byte(base.DB.GetSetting("session_key"))
if len(sessionKey) == 0 {
sessionKey = securecookie.GenerateRandomKey(32)
base.DB.SetSetting("session_key", string(sessionKey))
}
tmplBase := template.
New("layout").
Funcs(sprig.FuncMap()).
Funcs(template.FuncMap{
"humanDate": humanize.Time,
})
tmplBase = extendFromPaths(tmplBase, prefixPartials)
tmplBase = extendFromPaths(tmplBase, prefixLayouts)
return &Controller{
Controller: base,
buffPool: bpool.NewBufferPool(64),
templates: pagesFromPaths(tmplBase, prefixPages),
sessDB: gormstore.New(base.DB.DB, sessionKey),
}
}
type templateData struct {
// common
Flashes []interface{}
User *model.User
// home
AlbumCount int
ArtistCount int
TrackCount int
RequestRoot string
RecentFolders []*model.Album
AllUsers []*model.User
LastScanTime time.Time
IsScanning bool
//
CurrentLastFMAPIKey string
CurrentLastFMAPISecret string
SelectedUser *model.User
}
type adminHandler func(w http.ResponseWriter, r *http.Request) *response
type response struct {
// code is 200
template string
data *templateData
// code is 303
redirect string
// code is >= 400
code int
err string
}
func (c *Controller) H(h adminHandler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := h(w, r)
if resp.redirect != "" {
http.Redirect(w, r, resp.redirect, http.StatusSeeOther)
return
}
if resp.err != "" {
http.Error(w, resp.err, resp.code)
return
}
if resp.template == "" {
http.Error(w, "useless handler return", 500)
return
}
if resp.data == nil {
resp.data = &templateData{}
}
session := r.Context().Value(key.Session).(*sessions.Session)
resp.data.Flashes = session.Flashes()
if err := session.Save(r, w); err != nil {
http.Error(w, fmt.Sprintf("saving session: %v", err), 500)
return
}
resp.data.User, _ = r.Context().Value(key.User).(*model.User)
buff := c.buffPool.Get()
defer c.buffPool.Put(buff)
tmpl, ok := c.templates[resp.template]
if !ok {
http.Error(w, fmt.Sprintf("finding template %q", resp.template), 500)
return
}
if err := tmpl.Execute(buff, resp.data); err != nil {
http.Error(w, fmt.Sprintf("executing template: %v", err), 500)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
buff.WriteTo(w)
return
})
}
// ## begin utilities
// ## begin utilities
// ## begin utilities
func firstExisting(or string, strings ...string) string {
for _, s := range strings {
if s != "" {
return s
}
}
return or
}
func sessLogSave(w http.ResponseWriter, r *http.Request, s *sessions.Session) {
if err := s.Save(r, w); err != nil {
log.Printf("error saving session: %v\n", err)
}
}
type Flash struct {
Message string
Type string
}
func sessAddFlashW(message string, s *sessions.Session) {
s.AddFlash(Flash{
Message: message,
Type: "warning",
})
}
func sessAddFlashWf(message string, s *sessions.Session, a ...interface{}) {
sessAddFlashW(fmt.Sprintf(message, a...), s)
}
func sessAddFlashN(message string, s *sessions.Session) {
s.AddFlash(Flash{
Message: message,
Type: "normal",
})
}
func sessAddFlashNf(message string, s *sessions.Session, a ...interface{}) {
sessAddFlashN(fmt.Sprintf(message, a...), s)
}
// ## begin validation
// ## begin validation
// ## begin validation
func validateUsername(username string) error {
if username == "" {
return fmt.Errorf("please enter the username")
}
return nil
}
func validatePasswords(pOne, pTwo string) error {
if pOne == "" || pTwo == "" {
return fmt.Errorf("please enter the password twice")
}
if !(pOne == pTwo) {
return fmt.Errorf("the two passwords entered were not the same")
}
return nil
}
func validateAPIKey(apiKey, secret string) error {
if apiKey == "" || secret == "" {
return fmt.Errorf("please enter both the api key and secret")
}
return nil
}

View File

@@ -1,4 +1,4 @@
package handler package ctrladmin
import "testing" import "testing"

View File

@@ -1,4 +1,4 @@
package handler package ctrladmin
import ( import (
"fmt" "fmt"
@@ -8,50 +8,48 @@ import (
"time" "time"
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
"github.com/jinzhu/gorm"
"senan.xyz/g/gonic/model" "senan.xyz/g/gonic/model"
"senan.xyz/g/gonic/scanner" "senan.xyz/g/gonic/scanner"
"senan.xyz/g/gonic/server/key"
"senan.xyz/g/gonic/server/lastfm" "senan.xyz/g/gonic/server/lastfm"
) )
func (c *Controller) ServeLogin(w http.ResponseWriter, r *http.Request) { func (c *Controller) ServeLogin(w http.ResponseWriter, r *http.Request) *response {
renderTemplate(w, r, c.Templates["login.tmpl"], nil) return &response{template: "login.tmpl"}
} }
func (c *Controller) ServeLoginDo(w http.ResponseWriter, r *http.Request) { func (c *Controller) ServeLoginDo(w http.ResponseWriter, r *http.Request) *response {
session := r.Context().Value(contextSessionKey).(*sessions.Session) session := r.Context().Value(key.Session).(*sessions.Session)
username := r.FormValue("username") username := r.FormValue("username")
password := r.FormValue("password") password := r.FormValue("password")
if username == "" || password == "" { if username == "" || password == "" {
sessAddFlashW("please provide both a username and password", session) sessAddFlashW("please provide both a username and password", session)
sessLogSave(w, r, session) sessLogSave(w, r, session)
http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther) return &response{redirect: r.Referer()}
return
} }
user := c.DB.GetUserFromName(username) user := c.DB.GetUserFromName(username)
if user == nil || password != user.Password { if user == nil || password != user.Password {
sessAddFlashW("invalid username / password", session) sessAddFlashW("invalid username / password", session)
sessLogSave(w, r, session) sessLogSave(w, r, session)
http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther) return &response{redirect: r.Referer()}
return
} }
// put the user name into the session. future endpoints after this one // put the user name into the session. future endpoints after this one
// are wrapped with WithUserSession() which will get the name from the // are wrapped with WithUserSession() which will get the name from the
// session and put the row into the request context // session and put the row into the request context
session.Values["user"] = user.Name session.Values["user"] = user.Name
sessLogSave(w, r, session) sessLogSave(w, r, session)
http.Redirect(w, r, "/admin/home", http.StatusSeeOther) return &response{redirect: "/admin/home"}
} }
func (c *Controller) ServeLogout(w http.ResponseWriter, r *http.Request) { func (c *Controller) ServeLogout(w http.ResponseWriter, r *http.Request) *response {
session := r.Context().Value(contextSessionKey).(*sessions.Session) session := r.Context().Value(key.Session).(*sessions.Session)
session.Options.MaxAge = -1 session.Options.MaxAge = -1
sessLogSave(w, r, session) sessLogSave(w, r, session)
http.Redirect(w, r, "/admin/login", http.StatusSeeOther) return &response{redirect: "/admin/login"}
} }
func (c *Controller) ServeHome(w http.ResponseWriter, r *http.Request) { func (c *Controller) ServeHome(w http.ResponseWriter, r *http.Request) *response {
data := &templateData{} data := &templateData{}
// //
// stats box // stats box
@@ -89,79 +87,89 @@ func (c *Controller) ServeHome(w http.ResponseWriter, r *http.Request) {
data.LastScanTime = time.Unix(i, 0) data.LastScanTime = time.Unix(i, 0)
} }
// //
renderTemplate(w, r, c.Templates["home.tmpl"], data) return &response{
template: "home.tmpl",
data: data,
}
} }
func (c *Controller) ServeChangeOwnPassword(w http.ResponseWriter, r *http.Request) { func (c *Controller) ServeChangeOwnPassword(w http.ResponseWriter, r *http.Request) *response {
renderTemplate(w, r, c.Templates["change_own_password.tmpl"], nil) return &response{template: "change_own_password.tmpl"}
} }
func (c *Controller) ServeChangeOwnPasswordDo(w http.ResponseWriter, r *http.Request) { func (c *Controller) ServeChangeOwnPasswordDo(w http.ResponseWriter, r *http.Request) *response {
session := r.Context().Value(contextSessionKey).(*sessions.Session) session := r.Context().Value(key.Session).(*sessions.Session)
passwordOne := r.FormValue("password_one") passwordOne := r.FormValue("password_one")
passwordTwo := r.FormValue("password_two") passwordTwo := r.FormValue("password_two")
err := validatePasswords(passwordOne, passwordTwo) err := validatePasswords(passwordOne, passwordTwo)
if err != nil { if err != nil {
sessAddFlashW(err.Error(), session) sessAddFlashW(err.Error(), session)
sessLogSave(w, r, session) sessLogSave(w, r, session)
http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther) return &response{redirect: r.Referer()}
return
} }
user := r.Context().Value(contextUserKey).(*model.User) user := r.Context().Value(key.User).(*model.User)
user.Password = passwordOne user.Password = passwordOne
c.DB.Save(user) c.DB.Save(user)
http.Redirect(w, r, "/admin/home", http.StatusSeeOther) return &response{redirect: "/admin/home"}
} }
func (c *Controller) ServeLinkLastFMDo(w http.ResponseWriter, r *http.Request) { func (c *Controller) ServeLinkLastFMDo(w http.ResponseWriter, r *http.Request) *response {
token := r.URL.Query().Get("token") token := r.URL.Query().Get("token")
if token == "" { if token == "" {
http.Error(w, "please provide a token", 400) return &response{
return err: "please provide a token",
code: 400,
}
} }
sessionKey, err := lastfm.GetSession( sessionKey, err := lastfm.GetSession(
c.DB.GetSetting("lastfm_api_key"), c.DB.GetSetting("lastfm_api_key"),
c.DB.GetSetting("lastfm_secret"), c.DB.GetSetting("lastfm_secret"),
token, token,
) )
session := r.Context().Value(contextSessionKey).(*sessions.Session)
if err != nil { if err != nil {
session := r.Context().Value(key.Session).(*sessions.Session)
sessAddFlashW(err.Error(), session) sessAddFlashW(err.Error(), session)
sessLogSave(w, r, session) sessLogSave(w, r, session)
http.Redirect(w, r, "/admin/home", http.StatusSeeOther) return &response{redirect: "/admin/home"}
return
} }
user := r.Context().Value(contextUserKey).(*model.User) user := r.Context().Value(key.User).(*model.User)
user.LastFMSession = sessionKey user.LastFMSession = sessionKey
c.DB.Save(&user) c.DB.Save(&user)
http.Redirect(w, r, "/admin/home", http.StatusSeeOther) return &response{redirect: "/admin/home"}
} }
func (c *Controller) ServeUnlinkLastFMDo(w http.ResponseWriter, r *http.Request) { func (c *Controller) ServeUnlinkLastFMDo(w http.ResponseWriter, r *http.Request) *response {
user := r.Context().Value(contextUserKey).(*model.User) user := r.Context().Value(key.User).(*model.User)
user.LastFMSession = "" user.LastFMSession = ""
c.DB.Save(&user) c.DB.Save(&user)
http.Redirect(w, r, "/admin/home", http.StatusSeeOther) return &response{redirect: "/admin/home"}
} }
func (c *Controller) ServeChangePassword(w http.ResponseWriter, r *http.Request) { func (c *Controller) ServeChangePassword(w http.ResponseWriter, r *http.Request) *response {
username := r.URL.Query().Get("user") username := r.URL.Query().Get("user")
if username == "" { if username == "" {
http.Error(w, "please provide a username", 400) return &response{
return err: "please provide a username",
code: 400,
}
} }
user := c.DB.GetUserFromName(username) user := c.DB.GetUserFromName(username)
if user == nil { if user == nil {
http.Error(w, "couldn't find a user with that name", 400) return &response{
return err: "couldn't find a user with that name",
code: 400,
}
} }
data := &templateData{} data := &templateData{}
data.SelectedUser = user data.SelectedUser = user
renderTemplate(w, r, c.Templates["change_password.tmpl"], data) return &response{
template: "change_own_password.tmpl",
data: data,
}
} }
func (c *Controller) ServeChangePasswordDo(w http.ResponseWriter, r *http.Request) { func (c *Controller) ServeChangePasswordDo(w http.ResponseWriter, r *http.Request) *response {
session := r.Context().Value(contextSessionKey).(*sessions.Session) session := r.Context().Value(key.Session).(*sessions.Session)
username := r.URL.Query().Get("user") username := r.URL.Query().Get("user")
passwordOne := r.FormValue("password_one") passwordOne := r.FormValue("password_one")
passwordTwo := r.FormValue("password_two") passwordTwo := r.FormValue("password_two")
@@ -169,51 +177,56 @@ func (c *Controller) ServeChangePasswordDo(w http.ResponseWriter, r *http.Reques
if err != nil { if err != nil {
sessAddFlashW(err.Error(), session) sessAddFlashW(err.Error(), session)
sessLogSave(w, r, session) sessLogSave(w, r, session)
http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther) return &response{redirect: r.Referer()}
return
} }
user := c.DB.GetUserFromName(username) user := c.DB.GetUserFromName(username)
user.Password = passwordOne user.Password = passwordOne
c.DB.Save(user) c.DB.Save(user)
http.Redirect(w, r, "/admin/home", http.StatusSeeOther) return &response{redirect: "/admin/home"}
} }
func (c *Controller) ServeDeleteUser(w http.ResponseWriter, r *http.Request) { func (c *Controller) ServeDeleteUser(w http.ResponseWriter, r *http.Request) *response {
username := r.URL.Query().Get("user") username := r.URL.Query().Get("user")
if username == "" { if username == "" {
http.Error(w, "please provide a username", 400) return &response{
return err: "please provide a username",
code: 400,
}
} }
user := c.DB.GetUserFromName(username) user := c.DB.GetUserFromName(username)
if user == nil { if user == nil {
http.Error(w, "couldn't find a user with that name", 400) return &response{
return err: "couldn't find a user with that name",
code: 400,
}
} }
data := &templateData{} data := &templateData{}
data.SelectedUser = user data.SelectedUser = user
renderTemplate(w, r, c.Templates["delete_user.tmpl"], data) return &response{
template: "delete_user.tmpl",
data: data,
}
} }
func (c *Controller) ServeDeleteUserDo(w http.ResponseWriter, r *http.Request) { func (c *Controller) ServeDeleteUserDo(w http.ResponseWriter, r *http.Request) *response {
username := r.URL.Query().Get("user") username := r.URL.Query().Get("user")
user := c.DB.GetUserFromName(username) user := c.DB.GetUserFromName(username)
c.DB.Delete(user) c.DB.Delete(user)
http.Redirect(w, r, "/admin/home", http.StatusSeeOther) return &response{redirect: "/admin/home"}
} }
func (c *Controller) ServeCreateUser(w http.ResponseWriter, r *http.Request) { func (c *Controller) ServeCreateUser(w http.ResponseWriter, r *http.Request) *response {
renderTemplate(w, r, c.Templates["create_user.tmpl"], nil) return &response{template: "create_user.tmpl"}
} }
func (c *Controller) ServeCreateUserDo(w http.ResponseWriter, r *http.Request) { func (c *Controller) ServeCreateUserDo(w http.ResponseWriter, r *http.Request) *response {
session := r.Context().Value(contextSessionKey).(*sessions.Session) session := r.Context().Value(key.Session).(*sessions.Session)
username := r.FormValue("username") username := r.FormValue("username")
err := validateUsername(username) err := validateUsername(username)
if err != nil { if err != nil {
sessAddFlashW(err.Error(), session) sessAddFlashW(err.Error(), session)
sessLogSave(w, r, session) sessLogSave(w, r, session)
http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther) return &response{redirect: r.Referer()}
return
} }
passwordOne := r.FormValue("password_one") passwordOne := r.FormValue("password_one")
passwordTwo := r.FormValue("password_two") passwordTwo := r.FormValue("password_two")
@@ -221,8 +234,7 @@ func (c *Controller) ServeCreateUserDo(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
sessAddFlashW(err.Error(), session) sessAddFlashW(err.Error(), session)
sessLogSave(w, r, session) sessLogSave(w, r, session)
http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther) return &response{redirect: r.Referer()}
return
} }
user := model.User{ user := model.User{
Name: username, Name: username,
@@ -232,40 +244,38 @@ func (c *Controller) ServeCreateUserDo(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
sessAddFlashWf("could not create user `%s`: %v", session, username, err) sessAddFlashWf("could not create user `%s`: %v", session, username, err)
sessLogSave(w, r, session) sessLogSave(w, r, session)
http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther) return &response{redirect: r.Referer()}
return
} }
http.Redirect(w, r, "/admin/home", http.StatusSeeOther) return &response{redirect: "/admin/home"}
} }
func (c *Controller) ServeUpdateLastFMAPIKey(w http.ResponseWriter, r *http.Request) { func (c *Controller) ServeUpdateLastFMAPIKey(w http.ResponseWriter, r *http.Request) *response {
data := &templateData{} data := &templateData{}
data.CurrentLastFMAPIKey = c.DB.GetSetting("lastfm_api_key") data.CurrentLastFMAPIKey = c.DB.GetSetting("lastfm_api_key")
data.CurrentLastFMAPISecret = c.DB.GetSetting("lastfm_secret") data.CurrentLastFMAPISecret = c.DB.GetSetting("lastfm_secret")
renderTemplate(w, r, c.Templates["update_lastfm_api_key.tmpl"], data) return &response{
template: "create_user.tmpl",
data: data,
}
} }
func (c *Controller) ServeUpdateLastFMAPIKeyDo(w http.ResponseWriter, r *http.Request) { func (c *Controller) ServeUpdateLastFMAPIKeyDo(w http.ResponseWriter, r *http.Request) *response {
session := r.Context().Value(contextSessionKey).(*sessions.Session) session := r.Context().Value(key.Session).(*sessions.Session)
apiKey := r.FormValue("api_key") apiKey := r.FormValue("api_key")
secret := r.FormValue("secret") secret := r.FormValue("secret")
err := validateAPIKey(apiKey, secret) err := validateAPIKey(apiKey, secret)
if err != nil { if err != nil {
sessAddFlashW(err.Error(), session) sessAddFlashW(err.Error(), session)
sessLogSave(w, r, session) sessLogSave(w, r, session)
http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther) return &response{redirect: r.Referer()}
return
} }
c.DB.SetSetting("lastfm_api_key", apiKey) c.DB.SetSetting("lastfm_api_key", apiKey)
c.DB.SetSetting("lastfm_secret", secret) c.DB.SetSetting("lastfm_secret", secret)
http.Redirect(w, r, "/admin/home", http.StatusSeeOther) return &response{redirect: r.Referer()}
} }
func (c *Controller) ServeStartScanDo(w http.ResponseWriter, r *http.Request) { func (c *Controller) ServeStartScanDo(w http.ResponseWriter, r *http.Request) *response {
session := r.Context().Value(contextSessionKey).(*sessions.Session) defer func() {
sessAddFlashN("scan started. refresh for results", session)
sessLogSave(w, r, session)
http.Redirect(w, r, "/admin/home", http.StatusSeeOther)
go func() { go func() {
err := scanner. err := scanner.
New(c.DB, c.MusicPath). New(c.DB, c.MusicPath).
@@ -274,4 +284,9 @@ func (c *Controller) ServeStartScanDo(w http.ResponseWriter, r *http.Request) {
log.Printf("error while scanning: %v\n", err) log.Printf("error while scanning: %v\n", err)
} }
}() }()
}()
session := r.Context().Value(key.Session).(*sessions.Session)
sessAddFlashN("scan started. refresh for results", session)
sessLogSave(w, r, session)
return &response{redirect: "/admin/home"}
} }

View File

@@ -1,4 +1,4 @@
package handler package ctrladmin
import ( import (
"context" "context"
@@ -7,22 +7,21 @@ import (
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
"senan.xyz/g/gonic/model" "senan.xyz/g/gonic/model"
"senan.xyz/g/gonic/server/key"
) )
//nolint:interfacer func (c *Controller) WithSession(next http.Handler) http.Handler {
func (c *Controller) WithSession(next http.HandlerFunc) http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { session, _ := c.sessDB.Get(r, "gonic")
session, _ := c.SessDB.Get(r, "gonic") withSession := context.WithValue(r.Context(), key.Session, session)
withSession := context.WithValue(r.Context(), contextSessionKey, session)
next.ServeHTTP(w, r.WithContext(withSession)) next.ServeHTTP(w, r.WithContext(withSession))
} })
} }
//nolint:interfacer func (c *Controller) WithUserSession(next http.Handler) http.Handler {
func (c *Controller) WithUserSession(next http.HandlerFunc) http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
// session exists at this point // session exists at this point
session := r.Context().Value(contextSessionKey).(*sessions.Session) session := r.Context().Value(key.Session).(*sessions.Session)
username, ok := session.Values["user"].(string) username, ok := session.Values["user"].(string)
if !ok { if !ok {
sessAddFlashW("you are not authenticated", session) sessAddFlashW("you are not authenticated", session)
@@ -40,17 +39,16 @@ func (c *Controller) WithUserSession(next http.HandlerFunc) http.HandlerFunc {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther) http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return return
} }
withUser := context.WithValue(r.Context(), contextUserKey, user) withUser := context.WithValue(r.Context(), key.User, user)
next.ServeHTTP(w, r.WithContext(withUser)) next.ServeHTTP(w, r.WithContext(withUser))
} })
} }
//nolint:interfacer func (c *Controller) WithAdminSession(next http.Handler) http.Handler {
func (c *Controller) WithAdminSession(next http.HandlerFunc) http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
// session and user exist at this point // session and user exist at this point
session := r.Context().Value(contextSessionKey).(*sessions.Session) session := r.Context().Value(key.Session).(*sessions.Session)
user := r.Context().Value(contextUserKey).(*model.User) user := r.Context().Value(key.User).(*model.User)
if !user.IsAdmin { if !user.IsAdmin {
sessAddFlashW("you are not an admin", session) sessAddFlashW("you are not an admin", session)
sessLogSave(w, r, session) sessLogSave(w, r, session)
@@ -58,5 +56,5 @@ func (c *Controller) WithAdminSession(next http.HandlerFunc) http.HandlerFunc {
return return
} }
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
} })
} }

36
server/ctrlbase/ctrl.go Normal file
View File

@@ -0,0 +1,36 @@
package ctrlbase
import (
"log"
"net/http"
"senan.xyz/g/gonic/db"
)
type Controller struct {
DB *db.DB
MusicPath string
}
func (c *Controller) WithLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("connection from `%s` for `%s`", r.RemoteAddr, r.URL)
next.ServeHTTP(w, r)
})
}
func (c *Controller) WithCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods",
"POST, GET, OPTIONS, PUT, DELETE",
)
w.Header().Set("Access-Control-Allow-Headers",
"Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization",
)
if r.Method == "OPTIONS" {
return
}
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,98 @@
package ctrlsubsonic
import (
"encoding/json"
"encoding/xml"
"io"
"log"
"net/http"
"senan.xyz/g/gonic/server/ctrlbase"
"senan.xyz/g/gonic/server/ctrlsubsonic/spec"
"senan.xyz/g/gonic/server/parsing"
)
type Controller struct {
*ctrlbase.Controller
}
func New(base *ctrlbase.Controller) *Controller {
return &Controller{
Controller: base,
}
}
type metaResponse struct {
XMLName xml.Name `xml:"subsonic-response" json:"-"`
*spec.Response `json:"subsonic-response"`
}
type errWriter struct {
w io.Writer
err error
}
func (ew *errWriter) write(buf []byte) {
if ew.err != nil {
return
}
_, ew.err = ew.w.Write(buf)
}
func writeResp(w http.ResponseWriter, r *http.Request, resp *spec.Response) {
res := metaResponse{Response: resp}
ew := &errWriter{w: w}
switch parsing.GetStrParam(r, "f") {
case "json":
w.Header().Set("Content-Type", "application/json")
data, err := json.Marshal(res)
if err != nil {
log.Printf("could not marshall to json: %v\n", err)
return
}
ew.write(data)
case "jsonp":
w.Header().Set("Content-Type", "application/javascript")
data, err := json.Marshal(res)
if err != nil {
log.Printf("could not marshall to json: %v\n", err)
return
}
ew.write([]byte(parsing.GetStrParamOr(r, "callback", "cb")))
ew.write([]byte("("))
ew.write(data)
ew.write([]byte(");"))
default:
w.Header().Set("Content-Type", "application/xml")
data, err := xml.MarshalIndent(res, "", " ")
if err != nil {
log.Printf("could not marshall to xml: %v\n", err)
return
}
ew.write(data)
}
if ew.err != nil {
log.Printf("error writing to response: %v\n", ew.err)
}
}
type subsonicHandler func(r *http.Request) *spec.Response
func (c *Controller) H(h subsonicHandler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: write a non 200 if has err
response := h(r)
writeResp(w, r, response)
})
}
type subsonicHandlerRaw func(w http.ResponseWriter, r *http.Request) *spec.Response
func (c *Controller) HR(h subsonicHandlerRaw) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: write a non 200 if has err
// TODO: ensure no mixed return/writer
response := h(w, r)
writeResp(w, r, response)
})
}

View File

@@ -1,4 +1,4 @@
package handler package ctrlsubsonic
import ( import (
"log" "log"
@@ -13,10 +13,11 @@ import (
jd "github.com/josephburnett/jd/lib" jd "github.com/josephburnett/jd/lib"
"senan.xyz/g/gonic/db" "senan.xyz/g/gonic/db"
"senan.xyz/g/gonic/server/ctrlbase"
) )
var ( var (
testDataDir = "test_data" testDataDir = "testdata"
testCamelExpr = regexp.MustCompile("([a-z0-9])([A-Z])") testCamelExpr = regexp.MustCompile("([a-z0-9])([A-Z])")
testDBPath = path.Join(testDataDir, "db") testDBPath = path.Join(testDataDir, "db")
testController *Controller testController *Controller
@@ -27,9 +28,7 @@ func init() {
if err != nil { if err != nil {
log.Fatalf("error opening database: %v\n", err) log.Fatalf("error opening database: %v\n", err)
} }
testController = &Controller{ testController = New(&ctrlbase.Controller{DB: db})
DB: db,
}
} }
type queryCase struct { type queryCase struct {
@@ -38,7 +37,7 @@ type queryCase struct {
listSet bool listSet bool
} }
func runQueryCases(t *testing.T, handler http.HandlerFunc, cases []*queryCase) { func runQueryCases(t *testing.T, h subsonicHandler, cases []*queryCase) {
for _, qc := range cases { for _, qc := range cases {
qc := qc // pin qc := qc // pin
t.Run(qc.expectPath, func(t *testing.T) { t.Run(qc.expectPath, func(t *testing.T) {
@@ -50,7 +49,7 @@ func runQueryCases(t *testing.T, handler http.HandlerFunc, cases []*queryCase) {
// request from the handler in question // request from the handler in question
req, _ := http.NewRequest("", "?"+qc.params.Encode(), nil) req, _ := http.NewRequest("", "?"+qc.params.Encode(), nil)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req) testController.H(h).ServeHTTP(rr, req)
body := rr.Body.String() body := rr.Body.String()
if status := rr.Code; status != http.StatusOK { if status := rr.Code; status != http.StatusOK {
t.Fatalf("didn't give a 200\n%s", body) t.Fatalf("didn't give a 200\n%s", body)
@@ -81,8 +80,7 @@ func runQueryCases(t *testing.T, handler http.HandlerFunc, cases []*queryCase) {
if len(diff) == 0 { if len(diff) == 0 {
return return
} }
t.Errorf("\u001b[31;1mdiffering json\u001b[0m\n%s", t.Errorf("\u001b[31;1mdiffering json\u001b[0m\n%s", diff.Render())
diff.Render())
}) })
} }
} }

View File

@@ -1,4 +1,4 @@
package handler package ctrlsubsonic
import ( import (
"fmt" "fmt"
@@ -9,7 +9,9 @@ import (
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
"senan.xyz/g/gonic/model" "senan.xyz/g/gonic/model"
"senan.xyz/g/gonic/server/subsonic" "senan.xyz/g/gonic/server/ctrlsubsonic/spec"
"senan.xyz/g/gonic/server/key"
"senan.xyz/g/gonic/server/parsing"
) )
// the subsonic spec metions "artist" a lot when talking about the // the subsonic spec metions "artist" a lot when talking about the
@@ -18,7 +20,7 @@ import (
// an track to be the it's respective folder that comes directly // an track to be the it's respective folder that comes directly
// under the root directory // under the root directory
func (c *Controller) GetIndexes(w http.ResponseWriter, r *http.Request) { func (c *Controller) ServeGetIndexes(r *http.Request) *spec.Response {
var folders []*model.Album var folders []*model.Album
c.DB. c.DB.
Select("*, count(sub.id) as child_count"). Select("*, count(sub.id) as child_count").
@@ -30,40 +32,39 @@ func (c *Controller) GetIndexes(w http.ResponseWriter, r *http.Request) {
Group("albums.id"). Group("albums.id").
Find(&folders) Find(&folders)
// [a-z#] -> 27 // [a-z#] -> 27
indexMap := make(map[string]*subsonic.Index, 27) indexMap := make(map[string]*spec.Index, 27)
resp := make([]*subsonic.Index, 0, 27) resp := make([]*spec.Index, 0, 27)
for _, folder := range folders { for _, folder := range folders {
i := lowerUDecOrHash(folder.IndexRightPath()) i := lowerUDecOrHash(folder.IndexRightPath())
index, ok := indexMap[i] index, ok := indexMap[i]
if !ok { if !ok {
index = &subsonic.Index{ index = &spec.Index{
Name: i, Name: i,
Artists: []*subsonic.Artist{}, Artists: []*spec.Artist{},
} }
indexMap[i] = index indexMap[i] = index
resp = append(resp, index) resp = append(resp, index)
} }
index.Artists = append(index.Artists, index.Artists = append(index.Artists,
newArtistByFolder(folder)) spec.NewArtistByFolder(folder))
} }
sort.Slice(resp, func(i, j int) bool { sort.Slice(resp, func(i, j int) bool {
return resp[i].Name < resp[j].Name return resp[i].Name < resp[j].Name
}) })
sub := subsonic.NewResponse() sub := spec.NewResponse()
sub.Indexes = &subsonic.Indexes{ sub.Indexes = &spec.Indexes{
LastModified: 0, LastModified: 0,
Index: resp, Index: resp,
} }
respond(w, r, sub) return sub
} }
func (c *Controller) GetMusicDirectory(w http.ResponseWriter, r *http.Request) { func (c *Controller) ServeGetMusicDirectory(r *http.Request) *spec.Response {
id, err := getIntParam(r, "id") id, err := parsing.GetIntParam(r, "id")
if err != nil { if err != nil {
respondError(w, r, 10, "please provide an `id` parameter") return spec.NewError(10, "please provide an `id` parameter")
return
} }
childrenObj := []*subsonic.TrackChild{} childrenObj := []*spec.TrackChild{}
folder := &model.Album{} folder := &model.Album{}
c.DB.First(folder, id) c.DB.First(folder, id)
// //
@@ -73,7 +74,7 @@ func (c *Controller) GetMusicDirectory(w http.ResponseWriter, r *http.Request) {
Where("parent_id = ?", id). Where("parent_id = ?", id).
Find(&childFolders) Find(&childFolders)
for _, c := range childFolders { for _, c := range childFolders {
childrenObj = append(childrenObj, newTCAlbumByFolder(c)) childrenObj = append(childrenObj, spec.NewTCAlbumByFolder(c))
} }
// //
// start looking for child childTracks in the current dir // start looking for child childTracks in the current dir
@@ -84,8 +85,8 @@ func (c *Controller) GetMusicDirectory(w http.ResponseWriter, r *http.Request) {
Order("filename"). Order("filename").
Find(&childTracks) Find(&childTracks)
for _, c := range childTracks { for _, c := range childTracks {
toAppend := newTCTrackByFolder(c, folder) toAppend := spec.NewTCTrackByFolder(c, folder)
if getStrParam(r, "c") == "Jamstash" { if parsing.GetStrParam(r, "c") == "Jamstash" {
// jamstash thinks it can't play flacs // jamstash thinks it can't play flacs
toAppend.ContentType = "audio/mpeg" toAppend.ContentType = "audio/mpeg"
toAppend.Suffix = "mp3" toAppend.Suffix = "mp3"
@@ -94,18 +95,17 @@ func (c *Controller) GetMusicDirectory(w http.ResponseWriter, r *http.Request) {
} }
// //
// respond section // respond section
sub := subsonic.NewResponse() sub := spec.NewResponse()
sub.Directory = newDirectoryByFolder(folder, childrenObj) sub.Directory = spec.NewDirectoryByFolder(folder, childrenObj)
respond(w, r, sub) return sub
} }
// changes to this function should be reflected in in _by_tags.go's // changes to this function should be reflected in in _by_tags.go's
// getAlbumListTwo() function // getAlbumListTwo() function
func (c *Controller) GetAlbumList(w http.ResponseWriter, r *http.Request) { func (c *Controller) ServeGetAlbumList(r *http.Request) *spec.Response {
listType := getStrParam(r, "type") listType := parsing.GetStrParam(r, "type")
if listType == "" { if listType == "" {
respondError(w, r, 10, "please provide a `type` parameter") return spec.NewError(10, "please provide a `type` parameter")
return
} }
q := c.DB.DB q := c.DB.DB
switch listType { switch listType {
@@ -117,7 +117,7 @@ func (c *Controller) GetAlbumList(w http.ResponseWriter, r *http.Request) {
case "alphabeticalByName": case "alphabeticalByName":
q = q.Order("right_path") q = q.Order("right_path")
case "frequent": case "frequent":
user := r.Context().Value(contextUserKey).(*model.User) user := r.Context().Value(key.User).(*model.User)
q = q.Joins(` q = q.Joins(`
JOIN plays JOIN plays
ON albums.id = plays.album_id AND plays.user_id = ?`, ON albums.id = plays.album_id AND plays.user_id = ?`,
@@ -128,43 +128,40 @@ func (c *Controller) GetAlbumList(w http.ResponseWriter, r *http.Request) {
case "random": case "random":
q = q.Order(gorm.Expr("random()")) q = q.Order(gorm.Expr("random()"))
case "recent": case "recent":
user := r.Context().Value(contextUserKey).(*model.User) user := r.Context().Value(key.User).(*model.User)
q = q.Joins(` q = q.Joins(`
JOIN plays JOIN plays
ON albums.id = plays.album_id AND plays.user_id = ?`, ON albums.id = plays.album_id AND plays.user_id = ?`,
user.ID) user.ID)
q = q.Order("plays.time DESC") q = q.Order("plays.time DESC")
default: default:
respondError(w, r, 10, return spec.NewError(10, "unknown value `%s` for parameter 'type'", listType)
"unknown value `%s` for parameter 'type'", listType)
return
} }
var folders []*model.Album var folders []*model.Album
q. q.
Where("albums.tag_artist_id IS NOT NULL"). Where("albums.tag_artist_id IS NOT NULL").
Offset(getIntParamOr(r, "offset", 0)). Offset(parsing.GetIntParamOr(r, "offset", 0)).
Limit(getIntParamOr(r, "size", 10)). Limit(parsing.GetIntParamOr(r, "size", 10)).
Preload("Parent"). Preload("Parent").
Find(&folders) Find(&folders)
sub := subsonic.NewResponse() sub := spec.NewResponse()
sub.Albums = &subsonic.Albums{ sub.Albums = &spec.Albums{
List: make([]*subsonic.Album, len(folders)), List: make([]*spec.Album, len(folders)),
} }
for i, folder := range folders { for i, folder := range folders {
sub.Albums.List[i] = newAlbumByFolder(folder) sub.Albums.List[i] = spec.NewAlbumByFolder(folder)
} }
respond(w, r, sub) return sub
} }
func (c *Controller) SearchTwo(w http.ResponseWriter, r *http.Request) { func (c *Controller) ServeSearchTwo(r *http.Request) *spec.Response {
query := getStrParam(r, "query") query := parsing.GetStrParam(r, "query")
if query == "" { if query == "" {
respondError(w, r, 10, "please provide a `query` parameter") return spec.NewError(10, "please provide a `query` parameter")
return
} }
query = fmt.Sprintf("%%%s%%", query = fmt.Sprintf("%%%s%%",
strings.TrimSuffix(query, "*")) strings.TrimSuffix(query, "*"))
results := &subsonic.SearchResultTwo{} results := &spec.SearchResultTwo{}
// //
// search "artists" // search "artists"
var artists []*model.Album var artists []*model.Album
@@ -174,12 +171,12 @@ func (c *Controller) SearchTwo(w http.ResponseWriter, r *http.Request) {
AND (right_path LIKE ? OR AND (right_path LIKE ? OR
right_path_u_dec LIKE ?) right_path_u_dec LIKE ?)
`, query, query). `, query, query).
Offset(getIntParamOr(r, "artistOffset", 0)). Offset(parsing.GetIntParamOr(r, "artistOffset", 0)).
Limit(getIntParamOr(r, "artistCount", 20)). Limit(parsing.GetIntParamOr(r, "artistCount", 20)).
Find(&artists) Find(&artists)
for _, a := range artists { for _, a := range artists {
results.Artists = append(results.Artists, results.Artists = append(results.Artists,
newDirectoryByFolder(a, nil)) spec.NewDirectoryByFolder(a, nil))
} }
// //
// search "albums" // search "albums"
@@ -190,11 +187,11 @@ func (c *Controller) SearchTwo(w http.ResponseWriter, r *http.Request) {
AND (right_path LIKE ? OR AND (right_path LIKE ? OR
right_path_u_dec LIKE ?) right_path_u_dec LIKE ?)
`, query, query). `, query, query).
Offset(getIntParamOr(r, "albumOffset", 0)). Offset(parsing.GetIntParamOr(r, "albumOffset", 0)).
Limit(getIntParamOr(r, "albumCount", 20)). Limit(parsing.GetIntParamOr(r, "albumCount", 20)).
Find(&albums) Find(&albums)
for _, a := range albums { for _, a := range albums {
results.Albums = append(results.Albums, newTCAlbumByFolder(a)) results.Albums = append(results.Albums, spec.NewTCAlbumByFolder(a))
} }
// //
// search tracks // search tracks
@@ -205,15 +202,15 @@ func (c *Controller) SearchTwo(w http.ResponseWriter, r *http.Request) {
filename LIKE ? OR filename LIKE ? OR
filename_u_dec LIKE ? filename_u_dec LIKE ?
`, query, query). `, query, query).
Offset(getIntParamOr(r, "songOffset", 0)). Offset(parsing.GetIntParamOr(r, "songOffset", 0)).
Limit(getIntParamOr(r, "songCount", 20)). Limit(parsing.GetIntParamOr(r, "songCount", 20)).
Find(&tracks) Find(&tracks)
for _, t := range tracks { for _, t := range tracks {
results.Tracks = append(results.Tracks, results.Tracks = append(results.Tracks,
newTCTrackByFolder(t, t.Album)) spec.NewTCTrackByFolder(t, t.Album))
} }
// //
sub := subsonic.NewResponse() sub := spec.NewResponse()
sub.SearchResultTwo = results sub.SearchResultTwo = results
respond(w, r, sub) return sub
} }

View File

@@ -1,4 +1,4 @@
package handler package ctrlsubsonic
import ( import (
"net/url" "net/url"
@@ -8,20 +8,20 @@ import (
) )
func TestGetIndexes(t *testing.T) { func TestGetIndexes(t *testing.T) {
runQueryCases(t, testController.GetIndexes, []*queryCase{ runQueryCases(t, testController.ServeGetIndexes, []*queryCase{
{url.Values{}, "no_args", false}, {url.Values{}, "no_args", false},
}) })
} }
func TestGetMusicDirectory(t *testing.T) { func TestGetMusicDirectory(t *testing.T) {
runQueryCases(t, testController.GetMusicDirectory, []*queryCase{ runQueryCases(t, testController.ServeGetMusicDirectory, []*queryCase{
{url.Values{"id": []string{"2"}}, "without_tracks", false}, {url.Values{"id": []string{"2"}}, "without_tracks", false},
{url.Values{"id": []string{"3"}}, "with_tracks", false}, {url.Values{"id": []string{"3"}}, "with_tracks", false},
}) })
} }
func TestGetAlbumList(t *testing.T) { func TestGetAlbumList(t *testing.T) {
runQueryCases(t, testController.GetAlbumList, []*queryCase{ runQueryCases(t, testController.ServeGetAlbumList, []*queryCase{
{url.Values{"type": []string{"alphabeticalByArtist"}}, "alpha_artist", false}, {url.Values{"type": []string{"alphabeticalByArtist"}}, "alpha_artist", false},
{url.Values{"type": []string{"alphabeticalByName"}}, "alpha_name", false}, {url.Values{"type": []string{"alphabeticalByName"}}, "alpha_name", false},
{url.Values{"type": []string{"newest"}}, "newest", false}, {url.Values{"type": []string{"newest"}}, "newest", false},
@@ -30,7 +30,7 @@ func TestGetAlbumList(t *testing.T) {
} }
func TestSearchTwo(t *testing.T) { func TestSearchTwo(t *testing.T) {
runQueryCases(t, testController.SearchTwo, []*queryCase{ runQueryCases(t, testController.ServeSearchTwo, []*queryCase{
{url.Values{"query": []string{"13"}}, "q_13", false}, {url.Values{"query": []string{"13"}}, "q_13", false},
{url.Values{"query": []string{"ani"}}, "q_ani", false}, {url.Values{"query": []string{"ani"}}, "q_ani", false},
{url.Values{"query": []string{"cert"}}, "q_cert", false}, {url.Values{"query": []string{"cert"}}, "q_cert", false},

View File

@@ -1,4 +1,4 @@
package handler package ctrlsubsonic
import ( import (
"fmt" "fmt"
@@ -9,10 +9,12 @@ import (
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
"senan.xyz/g/gonic/model" "senan.xyz/g/gonic/model"
"senan.xyz/g/gonic/server/subsonic" "senan.xyz/g/gonic/server/ctrlsubsonic/spec"
"senan.xyz/g/gonic/server/key"
"senan.xyz/g/gonic/server/parsing"
) )
func (c *Controller) GetArtists(w http.ResponseWriter, r *http.Request) { func (c *Controller) ServeGetArtists(r *http.Request) *spec.Response {
var artists []*model.Artist var artists []*model.Artist
c.DB. c.DB.
Select("*, count(sub.id) as album_count"). Select("*, count(sub.id) as album_count").
@@ -23,56 +25,54 @@ func (c *Controller) GetArtists(w http.ResponseWriter, r *http.Request) {
Group("artists.id"). Group("artists.id").
Find(&artists) Find(&artists)
// [a-z#] -> 27 // [a-z#] -> 27
indexMap := make(map[string]*subsonic.Index, 27) indexMap := make(map[string]*spec.Index, 27)
resp := make([]*subsonic.Index, 0, 27) resp := make([]*spec.Index, 0, 27)
for _, artist := range artists { for _, artist := range artists {
i := lowerUDecOrHash(artist.IndexName()) i := lowerUDecOrHash(artist.IndexName())
index, ok := indexMap[i] index, ok := indexMap[i]
if !ok { if !ok {
index = &subsonic.Index{ index = &spec.Index{
Name: i, Name: i,
Artists: []*subsonic.Artist{}, Artists: []*spec.Artist{},
} }
indexMap[i] = index indexMap[i] = index
resp = append(resp, index) resp = append(resp, index)
} }
index.Artists = append(index.Artists, index.Artists = append(index.Artists,
newArtistByTags(artist)) spec.NewArtistByTags(artist))
} }
sort.Slice(resp, func(i, j int) bool { sort.Slice(resp, func(i, j int) bool {
return resp[i].Name < resp[j].Name return resp[i].Name < resp[j].Name
}) })
sub := subsonic.NewResponse() sub := spec.NewResponse()
sub.Artists = &subsonic.Artists{ sub.Artists = &spec.Artists{
List: resp, List: resp,
} }
respond(w, r, sub) return sub
} }
func (c *Controller) GetArtist(w http.ResponseWriter, r *http.Request) { func (c *Controller) ServeGetArtist(r *http.Request) *spec.Response {
id, err := getIntParam(r, "id") id, err := parsing.GetIntParam(r, "id")
if err != nil { if err != nil {
respondError(w, r, 10, "please provide an `id` parameter") return spec.NewError(10, "please provide an `id` parameter")
return
} }
artist := &model.Artist{} artist := &model.Artist{}
c.DB. c.DB.
Preload("Albums"). Preload("Albums").
First(artist, id) First(artist, id)
sub := subsonic.NewResponse() sub := spec.NewResponse()
sub.Artist = newArtistByTags(artist) sub.Artist = spec.NewArtistByTags(artist)
sub.Artist.Albums = make([]*subsonic.Album, len(artist.Albums)) sub.Artist.Albums = make([]*spec.Album, len(artist.Albums))
for i, album := range artist.Albums { for i, album := range artist.Albums {
sub.Artist.Albums[i] = newAlbumByTags(album, artist) sub.Artist.Albums[i] = spec.NewAlbumByTags(album, artist)
} }
respond(w, r, sub) return sub
} }
func (c *Controller) GetAlbum(w http.ResponseWriter, r *http.Request) { func (c *Controller) ServeGetAlbum(r *http.Request) *spec.Response {
id, err := getIntParam(r, "id") id, err := parsing.GetIntParam(r, "id")
if err != nil { if err != nil {
respondError(w, r, 10, "please provide an `id` parameter") return spec.NewError(10, "please provide an `id` parameter")
return
} }
album := &model.Album{} album := &model.Album{}
err = c.DB. err = c.DB.
@@ -83,25 +83,23 @@ func (c *Controller) GetAlbum(w http.ResponseWriter, r *http.Request) {
First(album, id). First(album, id).
Error Error
if gorm.IsRecordNotFoundError(err) { if gorm.IsRecordNotFoundError(err) {
respondError(w, r, 10, "couldn't find an album with that id") return spec.NewError(10, "couldn't find an album with that id")
return
} }
sub := subsonic.NewResponse() sub := spec.NewResponse()
sub.Album = newAlbumByTags(album, album.TagArtist) sub.Album = spec.NewAlbumByTags(album, album.TagArtist)
sub.Album.Tracks = make([]*subsonic.TrackChild, len(album.Tracks)) sub.Album.Tracks = make([]*spec.TrackChild, len(album.Tracks))
for i, track := range album.Tracks { for i, track := range album.Tracks {
sub.Album.Tracks[i] = newTrackByTags(track, album) sub.Album.Tracks[i] = spec.NewTrackByTags(track, album)
} }
respond(w, r, sub) return sub
} }
// changes to this function should be reflected in in _by_folder.go's // changes to this function should be reflected in in _by_folder.go's
// getAlbumList() function // getAlbumList() function
func (c *Controller) GetAlbumListTwo(w http.ResponseWriter, r *http.Request) { func (c *Controller) ServeGetAlbumListTwo(r *http.Request) *spec.Response {
listType := getStrParam(r, "type") listType := parsing.GetStrParam(r, "type")
if listType == "" { if listType == "" {
respondError(w, r, 10, "please provide a `type` parameter") return spec.NewError(10, "please provide a `type` parameter")
return
} }
q := c.DB.DB q := c.DB.DB
switch listType { switch listType {
@@ -115,11 +113,11 @@ func (c *Controller) GetAlbumListTwo(w http.ResponseWriter, r *http.Request) {
case "byYear": case "byYear":
q = q.Where( q = q.Where(
"tag_year BETWEEN ? AND ?", "tag_year BETWEEN ? AND ?",
getIntParamOr(r, "fromYear", 1800), parsing.GetIntParamOr(r, "fromYear", 1800),
getIntParamOr(r, "toYear", 2200)) parsing.GetIntParamOr(r, "toYear", 2200))
q = q.Order("tag_year") q = q.Order("tag_year")
case "frequent": case "frequent":
user := r.Context().Value(contextUserKey).(*model.User) user := r.Context().Value(key.User).(*model.User)
q = q.Joins(` q = q.Joins(`
JOIN plays JOIN plays
ON albums.id = plays.album_id AND plays.user_id = ?`, ON albums.id = plays.album_id AND plays.user_id = ?`,
@@ -130,43 +128,40 @@ func (c *Controller) GetAlbumListTwo(w http.ResponseWriter, r *http.Request) {
case "random": case "random":
q = q.Order(gorm.Expr("random()")) q = q.Order(gorm.Expr("random()"))
case "recent": case "recent":
user := r.Context().Value(contextUserKey).(*model.User) user := r.Context().Value(key.User).(*model.User)
q = q.Joins(` q = q.Joins(`
JOIN plays JOIN plays
ON albums.id = plays.album_id AND plays.user_id = ?`, ON albums.id = plays.album_id AND plays.user_id = ?`,
user.ID) user.ID)
q = q.Order("plays.time DESC") q = q.Order("plays.time DESC")
default: default:
respondError(w, r, 10, return spec.NewError(10, "unknown value `%s` for parameter 'type'", listType)
"unknown value `%s` for parameter 'type'", listType)
return
} }
var albums []*model.Album var albums []*model.Album
q. q.
Where("albums.tag_artist_id IS NOT NULL"). Where("albums.tag_artist_id IS NOT NULL").
Offset(getIntParamOr(r, "offset", 0)). Offset(parsing.GetIntParamOr(r, "offset", 0)).
Limit(getIntParamOr(r, "size", 10)). Limit(parsing.GetIntParamOr(r, "size", 10)).
Preload("TagArtist"). Preload("TagArtist").
Find(&albums) Find(&albums)
sub := subsonic.NewResponse() sub := spec.NewResponse()
sub.AlbumsTwo = &subsonic.Albums{ sub.AlbumsTwo = &spec.Albums{
List: make([]*subsonic.Album, len(albums)), List: make([]*spec.Album, len(albums)),
} }
for i, album := range albums { for i, album := range albums {
sub.AlbumsTwo.List[i] = newAlbumByTags(album, album.TagArtist) sub.AlbumsTwo.List[i] = spec.NewAlbumByTags(album, album.TagArtist)
} }
respond(w, r, sub) return sub
} }
func (c *Controller) SearchThree(w http.ResponseWriter, r *http.Request) { func (c *Controller) ServeSearchThree(r *http.Request) *spec.Response {
query := getStrParam(r, "query") query := parsing.GetStrParam(r, "query")
if query == "" { if query == "" {
respondError(w, r, 10, "please provide a `query` parameter") return spec.NewError(10, "please provide a `query` parameter")
return
} }
query = fmt.Sprintf("%%%s%%", query = fmt.Sprintf("%%%s%%",
strings.TrimSuffix(query, "*")) strings.TrimSuffix(query, "*"))
results := &subsonic.SearchResultThree{} results := &spec.SearchResultThree{}
// //
// search "artists" // search "artists"
var artists []*model.Artist var artists []*model.Artist
@@ -175,12 +170,12 @@ func (c *Controller) SearchThree(w http.ResponseWriter, r *http.Request) {
name LIKE ? OR name LIKE ? OR
name_u_dec LIKE ? name_u_dec LIKE ?
`, query, query). `, query, query).
Offset(getIntParamOr(r, "artistOffset", 0)). Offset(parsing.GetIntParamOr(r, "artistOffset", 0)).
Limit(getIntParamOr(r, "artistCount", 20)). Limit(parsing.GetIntParamOr(r, "artistCount", 20)).
Find(&artists) Find(&artists)
for _, a := range artists { for _, a := range artists {
results.Artists = append(results.Artists, results.Artists = append(results.Artists,
newArtistByTags(a)) spec.NewArtistByTags(a))
} }
// //
// search "albums" // search "albums"
@@ -191,12 +186,12 @@ func (c *Controller) SearchThree(w http.ResponseWriter, r *http.Request) {
tag_title LIKE ? OR tag_title LIKE ? OR
tag_title_u_dec LIKE ? tag_title_u_dec LIKE ?
`, query, query). `, query, query).
Offset(getIntParamOr(r, "albumOffset", 0)). Offset(parsing.GetIntParamOr(r, "albumOffset", 0)).
Limit(getIntParamOr(r, "albumCount", 20)). Limit(parsing.GetIntParamOr(r, "albumCount", 20)).
Find(&albums) Find(&albums)
for _, a := range albums { for _, a := range albums {
results.Albums = append(results.Albums, results.Albums = append(results.Albums,
newAlbumByTags(a, a.TagArtist)) spec.NewAlbumByTags(a, a.TagArtist))
} }
// //
// search tracks // search tracks
@@ -207,14 +202,14 @@ func (c *Controller) SearchThree(w http.ResponseWriter, r *http.Request) {
tag_title LIKE ? OR tag_title LIKE ? OR
tag_title_u_dec LIKE ? tag_title_u_dec LIKE ?
`, query, query). `, query, query).
Offset(getIntParamOr(r, "songOffset", 0)). Offset(parsing.GetIntParamOr(r, "songOffset", 0)).
Limit(getIntParamOr(r, "songCount", 20)). Limit(parsing.GetIntParamOr(r, "songCount", 20)).
Find(&tracks) Find(&tracks)
for _, t := range tracks { for _, t := range tracks {
results.Tracks = append(results.Tracks, results.Tracks = append(results.Tracks,
newTrackByTags(t, t.Album)) spec.NewTrackByTags(t, t.Album))
} }
sub := subsonic.NewResponse() sub := spec.NewResponse()
sub.SearchResultThree = results sub.SearchResultThree = results
respond(w, r, sub) return sub
} }

View File

@@ -1,4 +1,4 @@
package handler package ctrlsubsonic
import ( import (
"net/url" "net/url"
@@ -6,13 +6,13 @@ import (
) )
func TestGetArtists(t *testing.T) { func TestGetArtists(t *testing.T) {
runQueryCases(t, testController.GetArtists, []*queryCase{ runQueryCases(t, testController.ServeGetArtists, []*queryCase{
{url.Values{}, "no_args", false}, {url.Values{}, "no_args", false},
}) })
} }
func TestGetArtist(t *testing.T) { func TestGetArtist(t *testing.T) {
runQueryCases(t, testController.GetArtist, []*queryCase{ runQueryCases(t, testController.ServeGetArtist, []*queryCase{
{url.Values{"id": []string{"1"}}, "id_one", false}, {url.Values{"id": []string{"1"}}, "id_one", false},
{url.Values{"id": []string{"2"}}, "id_two", false}, {url.Values{"id": []string{"2"}}, "id_two", false},
{url.Values{"id": []string{"3"}}, "id_three", false}, {url.Values{"id": []string{"3"}}, "id_three", false},
@@ -20,14 +20,14 @@ func TestGetArtist(t *testing.T) {
} }
func TestGetAlbum(t *testing.T) { func TestGetAlbum(t *testing.T) {
runQueryCases(t, testController.GetAlbum, []*queryCase{ runQueryCases(t, testController.ServeGetAlbum, []*queryCase{
{url.Values{"id": []string{"2"}}, "without_cover", false}, {url.Values{"id": []string{"2"}}, "without_cover", false},
{url.Values{"id": []string{"3"}}, "with_cover", false}, {url.Values{"id": []string{"3"}}, "with_cover", false},
}) })
} }
func TestGetAlbumListTwo(t *testing.T) { func TestGetAlbumListTwo(t *testing.T) {
runQueryCases(t, testController.GetAlbumListTwo, []*queryCase{ runQueryCases(t, testController.ServeGetAlbumListTwo, []*queryCase{
{url.Values{"type": []string{"alphabeticalByArtist"}}, "alpha_artist", false}, {url.Values{"type": []string{"alphabeticalByArtist"}}, "alpha_artist", false},
{url.Values{"type": []string{"alphabeticalByName"}}, "alpha_name", false}, {url.Values{"type": []string{"alphabeticalByName"}}, "alpha_name", false},
{url.Values{"type": []string{"newest"}}, "newest", false}, {url.Values{"type": []string{"newest"}}, "newest", false},
@@ -36,7 +36,7 @@ func TestGetAlbumListTwo(t *testing.T) {
} }
func TestSearchThree(t *testing.T) { func TestSearchThree(t *testing.T) {
runQueryCases(t, testController.SearchThree, []*queryCase{ runQueryCases(t, testController.ServeSearchThree, []*queryCase{
{url.Values{"query": []string{"13"}}, "q_13", false}, {url.Values{"query": []string{"13"}}, "q_13", false},
{url.Values{"query": []string{"ani"}}, "q_ani", false}, {url.Values{"query": []string{"ani"}}, "q_ani", false},
{url.Values{"query": []string{"cert"}}, "q_cert", false}, {url.Values{"query": []string{"cert"}}, "q_cert", false},

View File

@@ -0,0 +1,107 @@
package ctrlsubsonic
import (
"log"
"net/http"
"time"
"unicode"
"senan.xyz/g/gonic/model"
"senan.xyz/g/gonic/scanner"
"senan.xyz/g/gonic/server/ctrlsubsonic/spec"
"senan.xyz/g/gonic/server/key"
"senan.xyz/g/gonic/server/lastfm"
"senan.xyz/g/gonic/server/parsing"
)
func lowerUDecOrHash(in string) string {
lower := unicode.ToLower(rune(in[0]))
if !unicode.IsLetter(lower) {
return "#"
}
return string(lower)
}
func (c *Controller) ServeGetLicence(r *http.Request) *spec.Response {
sub := spec.NewResponse()
sub.Licence = &spec.Licence{
Valid: true,
}
return sub
}
func (c *Controller) ServePing(r *http.Request) *spec.Response {
return spec.NewResponse()
}
func (c *Controller) ServeScrobble(r *http.Request) *spec.Response {
id, err := parsing.GetIntParam(r, "id")
if err != nil {
return spec.NewError(10, "please provide an `id` parameter")
}
// fetch user to get lastfm session
user := r.Context().Value(key.User).(*model.User)
if user.LastFMSession == "" {
return spec.NewError(0, "you don't have a last.fm session")
}
// fetch track for getting info to send to last.fm function
track := &model.Track{}
c.DB.
Preload("Album").
Preload("Artist").
First(track, id)
// scrobble with above info
err = lastfm.Scrobble(
c.DB.GetSetting("lastfm_api_key"),
c.DB.GetSetting("lastfm_secret"),
user.LastFMSession,
track,
// clients will provide time in miliseconds, so use that or
// instead convert UnixNano to miliseconds
parsing.GetIntParamOr(r, "time", int(time.Now().UnixNano()/1e6)),
parsing.GetStrParamOr(r, "submission", "true") != "false",
)
if err != nil {
return spec.NewError(0, "error when submitting: %v", err)
}
return spec.NewResponse()
}
func (c *Controller) ServeGetMusicFolders(r *http.Request) *spec.Response {
folders := &spec.MusicFolders{}
folders.List = []*spec.MusicFolder{
{ID: 1, Name: "music"},
}
sub := spec.NewResponse()
sub.MusicFolders = folders
return sub
}
func (c *Controller) ServeStartScan(r *http.Request) *spec.Response {
go func() {
err := scanner.
New(c.DB, c.MusicPath).
Start()
if err != nil {
log.Printf("error while scanning: %v\n", err)
}
}()
return c.ServeGetScanStatus(r)
}
func (c *Controller) ServeGetScanStatus(r *http.Request) *spec.Response {
var trackCount int
c.DB.
Model(model.Track{}).
Count(&trackCount)
sub := spec.NewResponse()
sub.ScanStatus = &spec.ScanStatus{
Scanning: scanner.IsScanning(),
Count: trackCount,
}
return sub
}
func (c *Controller) ServeNotFound(r *http.Request) *spec.Response {
return spec.NewError(70, "view not found")
}

View File

@@ -0,0 +1,88 @@
package ctrlsubsonic
import (
"net/http"
"os"
"path"
"time"
"github.com/jinzhu/gorm"
"senan.xyz/g/gonic/model"
"senan.xyz/g/gonic/server/ctrlsubsonic/spec"
"senan.xyz/g/gonic/server/key"
"senan.xyz/g/gonic/server/parsing"
)
// "raw" handlers are ones that don't always return a spec response.
// it could be a file, stream, etc. so you must either
// a) write to response writer
// b) return a non-nil spec.Response
// _but not both_
func (c *Controller) ServeGetCoverArt(w http.ResponseWriter, r *http.Request) *spec.Response {
id, err := parsing.GetIntParam(r, "id")
if err != nil {
return spec.NewError(10, "please provide an `id` parameter")
}
folder := &model.Album{}
err = c.DB.
Select("id, left_path, right_path, cover").
First(folder, id).
Error
if gorm.IsRecordNotFoundError(err) {
return spec.NewError(10, "could not find a cover with that id")
}
if folder.Cover == "" {
return spec.NewError(10, "no cover found for that folder")
}
absPath := path.Join(
c.MusicPath,
folder.LeftPath,
folder.RightPath,
folder.Cover,
)
http.ServeFile(w, r, absPath)
return nil
}
func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.Response {
id, err := parsing.GetIntParam(r, "id")
if err != nil {
return spec.NewError(10, "please provide an `id` parameter")
}
track := &model.Track{}
err = c.DB.
Preload("Album").
First(track, id).
Error
if gorm.IsRecordNotFoundError(err) {
return spec.NewError(70, "media with id `%d` was not found", id)
}
absPath := path.Join(
c.MusicPath,
track.Album.LeftPath,
track.Album.RightPath,
track.Filename,
)
file, err := os.Open(absPath)
if err != nil {
return spec.NewError(0, "error while streaming media: %v", err)
}
stat, _ := file.Stat()
http.ServeContent(w, r, absPath, stat.ModTime(), file)
//
// after we've served the file, mark the album as played
user := r.Context().Value(key.User).(*model.User)
play := model.Play{
AlbumID: track.Album.ID,
UserID: user.ID,
}
c.DB.
Where(play).
First(&play)
play.Time = time.Now() // for getAlbumList?type=recent
play.Count++ // for getAlbumList?type=frequent
c.DB.Save(&play)
return nil
}

View File

@@ -0,0 +1,80 @@
package ctrlsubsonic
import (
"context"
"crypto/md5"
"encoding/hex"
"fmt"
"net/http"
"net/url"
"senan.xyz/g/gonic/server/ctrlsubsonic/spec"
"senan.xyz/g/gonic/server/key"
"senan.xyz/g/gonic/server/parsing"
)
var requiredParameters = []string{
"u", "v", "c",
}
func checkHasAllParams(params url.Values) error {
for _, req := range requiredParameters {
param := params.Get(req)
if param != "" {
continue
}
return fmt.Errorf("please provide a `%s` parameter", req)
}
return nil
}
func checkCredsToken(password, token, salt string) bool {
toHash := fmt.Sprintf("%s%s", password, salt)
hash := md5.Sum([]byte(toHash))
expToken := hex.EncodeToString(hash[:])
return token == expToken
}
func checkCredsBasic(password, given string) bool {
if len(given) >= 4 && given[:4] == "enc:" {
bytes, _ := hex.DecodeString(given[4:])
given = string(bytes)
}
return password == given
}
func (c *Controller) WithValidSubsonicArgs(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := checkHasAllParams(r.URL.Query()); err != nil {
writeResp(w, r, spec.NewError(10, err.Error()))
return
}
username := parsing.GetStrParam(r, "u")
password := parsing.GetStrParam(r, "p")
token := parsing.GetStrParam(r, "t")
salt := parsing.GetStrParam(r, "s")
passwordAuth := token == "" && salt == ""
tokenAuth := password == ""
if tokenAuth == passwordAuth {
writeResp(w, r, spec.NewError(10, "please provide `t` and `s`, or just `p`"))
return
}
user := c.DB.GetUserFromName(username)
if user == nil {
writeResp(w, r, spec.NewError(40, "invalid username `%s`", username))
return
}
var credsOk bool
if tokenAuth {
credsOk = checkCredsToken(user.Password, token, salt)
} else {
credsOk = checkCredsBasic(user.Password, password)
}
if !credsOk {
writeResp(w, r, spec.NewError(40, "invalid password"))
return
}
withUser := context.WithValue(r.Context(), key.User, user)
next.ServeHTTP(w, r.WithContext(withUser))
})
}

View File

@@ -1,14 +1,13 @@
package handler package spec
import ( import (
"path" "path"
"senan.xyz/g/gonic/model" "senan.xyz/g/gonic/model"
"senan.xyz/g/gonic/server/subsonic"
) )
func newAlbumByFolder(f *model.Album) *subsonic.Album { func NewAlbumByFolder(f *model.Album) *Album {
return &subsonic.Album{ return &Album{
Artist: f.Parent.RightPath, Artist: f.Parent.RightPath,
CoverID: f.ID, CoverID: f.ID,
ID: f.ID, ID: f.ID,
@@ -18,8 +17,8 @@ func newAlbumByFolder(f *model.Album) *subsonic.Album {
} }
} }
func newTCAlbumByFolder(f *model.Album) *subsonic.TrackChild { func NewTCAlbumByFolder(f *model.Album) *TrackChild {
trCh := &subsonic.TrackChild{ trCh := &TrackChild{
ID: f.ID, ID: f.ID,
IsDir: true, IsDir: true,
Title: f.RightPath, Title: f.RightPath,
@@ -31,8 +30,8 @@ func newTCAlbumByFolder(f *model.Album) *subsonic.TrackChild {
return trCh return trCh
} }
func newTCTrackByFolder(t *model.Track, parent *model.Album) *subsonic.TrackChild { func NewTCTrackByFolder(t *model.Track, parent *model.Album) *TrackChild {
trCh := &subsonic.TrackChild{ trCh := &TrackChild{
ID: t.ID, ID: t.ID,
Album: t.Album.RightPath, Album: t.Album.RightPath,
ContentType: t.MIME(), ContentType: t.MIME(),
@@ -59,16 +58,16 @@ func newTCTrackByFolder(t *model.Track, parent *model.Album) *subsonic.TrackChil
return trCh return trCh
} }
func newArtistByFolder(f *model.Album) *subsonic.Artist { func NewArtistByFolder(f *model.Album) *Artist {
return &subsonic.Artist{ return &Artist{
ID: f.ID, ID: f.ID,
Name: f.RightPath, Name: f.RightPath,
AlbumCount: f.ChildCount, AlbumCount: f.ChildCount,
} }
} }
func newDirectoryByFolder(f *model.Album, children []*subsonic.TrackChild) *subsonic.Directory { func NewDirectoryByFolder(f *model.Album, children []*TrackChild) *Directory {
return &subsonic.Directory{ return &Directory{
ID: f.ID, ID: f.ID,
Parent: f.ParentID, Parent: f.ParentID,
Name: f.RightPath, Name: f.RightPath,

View File

@@ -1,14 +1,13 @@
package handler package spec
import ( import (
"path" "path"
"senan.xyz/g/gonic/model" "senan.xyz/g/gonic/model"
"senan.xyz/g/gonic/server/subsonic"
) )
func newAlbumByTags(a *model.Album, artist *model.Artist) *subsonic.Album { func NewAlbumByTags(a *model.Album, artist *model.Artist) *Album {
ret := &subsonic.Album{ ret := &Album{
Created: a.ModifiedAt, Created: a.ModifiedAt,
ID: a.ID, ID: a.ID,
Name: a.TagTitle, Name: a.TagTitle,
@@ -23,8 +22,8 @@ func newAlbumByTags(a *model.Album, artist *model.Artist) *subsonic.Album {
return ret return ret
} }
func newTrackByTags(t *model.Track, album *model.Album) *subsonic.TrackChild { func NewTrackByTags(t *model.Track, album *model.Album) *TrackChild {
ret := &subsonic.TrackChild{ ret := &TrackChild{
ID: t.ID, ID: t.ID,
ContentType: t.MIME(), ContentType: t.MIME(),
Suffix: t.Ext(), Suffix: t.Ext(),
@@ -53,8 +52,8 @@ func newTrackByTags(t *model.Track, album *model.Album) *subsonic.TrackChild {
return ret return ret
} }
func newArtistByTags(a *model.Artist) *subsonic.Artist { func NewArtistByTags(a *model.Artist) *Artist {
return &subsonic.Artist{ return &Artist{
ID: a.ID, ID: a.ID,
Name: a.Name, Name: a.Name,
AlbumCount: a.AlbumCount, AlbumCount: a.AlbumCount,

View File

@@ -1,6 +1,9 @@
package subsonic package spec
import "time" import (
"fmt"
"time"
)
var ( var (
apiVersion = "1.9.0" apiVersion = "1.9.0"
@@ -36,19 +39,30 @@ func NewResponse() *Response {
} }
} }
// spec errors:
// 0 a generic error
// 10 required parameter is missing
// 20 incompatible subsonic rest protocol version. client must upgrade
// 30 incompatible subsonic rest protocol version. server must upgrade
// 40 wrong username or password
// 41 token authentication not supported for ldap users
// 50 user is not authorized for the given operation
// 60 the trial period for the subsonic server is over
// 70 the requested data was not found
type Error struct { type Error struct {
Code int `xml:"code,attr" json:"code"` Code int `xml:"code,attr" json:"code"`
Message string `xml:"message,attr" json:"message"` Message string `xml:"message,attr" json:"message"`
} }
func NewError(code int, message string) *Response { func NewError(code int, message string, a ...interface{}) *Response {
return &Response{ return &Response{
Status: "failed", Status: "failed",
XMLNS: xmlns, XMLNS: xmlns,
Version: apiVersion, Version: apiVersion,
Error: &Error{ Error: &Error{
Code: code, Code: code,
Message: message, Message: fmt.Sprintf(message, a...),
}, },
} }
} }

View File

@@ -1,23 +0,0 @@
package handler
import (
"html/template"
"github.com/wader/gormstore"
"senan.xyz/g/gonic/db"
)
type contextKey int
const (
contextUserKey contextKey = iota
contextSessionKey
)
type Controller struct {
DB *db.DB
SessDB *gormstore.Store
Templates map[string]*template.Template
MusicPath string
}

View File

@@ -1,51 +0,0 @@
package handler
import (
"fmt"
"log"
"net/http"
"github.com/gorilla/sessions"
)
func firstExisting(or string, strings ...string) string {
for _, s := range strings {
if s != "" {
return s
}
}
return or
}
func sessLogSave(w http.ResponseWriter, r *http.Request, s *sessions.Session) {
if err := s.Save(r, w); err != nil {
log.Printf("error saving session: %v\n", err)
}
}
type Flash struct {
Message string
Type string
}
func sessAddFlashW(message string, s *sessions.Session) {
s.AddFlash(Flash{
Message: message,
Type: "warning",
})
}
func sessAddFlashWf(message string, s *sessions.Session, a ...interface{}) {
sessAddFlashW(fmt.Sprintf(message, a...), s)
}
func sessAddFlashN(message string, s *sessions.Session) {
s.AddFlash(Flash{
Message: message,
Type: "normal",
})
}
func sessAddFlashNf(message string, s *sessions.Session, a ...interface{}) {
sessAddFlashN(fmt.Sprintf(message, a...), s)
}

View File

@@ -1,27 +0,0 @@
package handler
import "fmt"
func validateUsername(username string) error {
if username == "" {
return fmt.Errorf("please enter the username")
}
return nil
}
func validatePasswords(pOne, pTwo string) error {
if pOne == "" || pTwo == "" {
return fmt.Errorf("please enter the password twice")
}
if !(pOne == pTwo) {
return fmt.Errorf("the two passwords entered were not the same")
}
return nil
}
func validateAPIKey(apiKey, secret string) error {
if apiKey == "" || secret == "" {
return fmt.Errorf("please enter both the api key and secret")
}
return nil
}

View File

@@ -1,185 +0,0 @@
package handler
import (
"log"
"net/http"
"os"
"path"
"time"
"unicode"
"github.com/jinzhu/gorm"
"senan.xyz/g/gonic/model"
"senan.xyz/g/gonic/scanner"
"senan.xyz/g/gonic/server/lastfm"
"senan.xyz/g/gonic/server/subsonic"
)
func lowerUDecOrHash(in string) string {
lower := unicode.ToLower(rune(in[0]))
if !unicode.IsLetter(lower) {
return "#"
}
return string(lower)
}
func (c *Controller) Stream(w http.ResponseWriter, r *http.Request) {
id, err := getIntParam(r, "id")
if err != nil {
respondError(w, r, 10, "please provide an `id` parameter")
return
}
track := &model.Track{}
err = c.DB.
Preload("Album").
First(track, id).
Error
if gorm.IsRecordNotFoundError(err) {
respondError(w, r, 70, "media with id `%d` was not found", id)
return
}
absPath := path.Join(
c.MusicPath,
track.Album.LeftPath,
track.Album.RightPath,
track.Filename,
)
file, err := os.Open(absPath)
if err != nil {
respondError(w, r, 0, "error while streaming media: %v", err)
return
}
stat, _ := file.Stat()
http.ServeContent(w, r, absPath, stat.ModTime(), file)
//
// after we've served the file, mark the album as played
user := r.Context().Value(contextUserKey).(*model.User)
play := model.Play{
AlbumID: track.Album.ID,
UserID: user.ID,
}
c.DB.
Where(play).
First(&play)
play.Time = time.Now() // for getAlbumList?type=recent
play.Count++ // for getAlbumList?type=frequent
c.DB.Save(&play)
}
func (c *Controller) GetCoverArt(w http.ResponseWriter, r *http.Request) {
id, err := getIntParam(r, "id")
if err != nil {
respondError(w, r, 10, "please provide an `id` parameter")
return
}
folder := &model.Album{}
err = c.DB.
Select("id, left_path, right_path, cover").
First(folder, id).
Error
if gorm.IsRecordNotFoundError(err) {
respondError(w, r, 10, "could not find a cover with that id")
return
}
if folder.Cover == "" {
respondError(w, r, 10, "no cover found for that folder")
return
}
absPath := path.Join(
c.MusicPath,
folder.LeftPath,
folder.RightPath,
folder.Cover,
)
http.ServeFile(w, r, absPath)
}
func (c *Controller) GetLicence(w http.ResponseWriter, r *http.Request) {
sub := subsonic.NewResponse()
sub.Licence = &subsonic.Licence{
Valid: true,
}
respond(w, r, sub)
}
func (c *Controller) Ping(w http.ResponseWriter, r *http.Request) {
sub := subsonic.NewResponse()
respond(w, r, sub)
}
func (c *Controller) Scrobble(w http.ResponseWriter, r *http.Request) {
id, err := getIntParam(r, "id")
if err != nil {
respondError(w, r, 10, "please provide an `id` parameter")
return
}
// fetch user to get lastfm session
user := r.Context().Value(contextUserKey).(*model.User)
if user.LastFMSession == "" {
respondError(w, r, 0, "you don't have a last.fm session")
return
}
// fetch track for getting info to send to last.fm function
track := &model.Track{}
c.DB.
Preload("Album").
Preload("Artist").
First(track, id)
// scrobble with above info
err = lastfm.Scrobble(
c.DB.GetSetting("lastfm_api_key"),
c.DB.GetSetting("lastfm_secret"),
user.LastFMSession,
track,
// clients will provide time in miliseconds, so use that or
// instead convert UnixNano to miliseconds
getIntParamOr(r, "time", int(time.Now().UnixNano()/1e6)),
getStrParamOr(r, "submission", "true") != "false",
)
if err != nil {
respondError(w, r, 0, "error when submitting: %v", err)
return
}
sub := subsonic.NewResponse()
respond(w, r, sub)
}
func (c *Controller) GetMusicFolders(w http.ResponseWriter, r *http.Request) {
folders := &subsonic.MusicFolders{}
folders.List = []*subsonic.MusicFolder{
{ID: 1, Name: "music"},
}
sub := subsonic.NewResponse()
sub.MusicFolders = folders
respond(w, r, sub)
}
func (c *Controller) StartScan(w http.ResponseWriter, r *http.Request) {
go func() {
err := scanner.
New(c.DB, c.MusicPath).
Start()
if err != nil {
log.Printf("error while scanning: %v\n", err)
}
}()
c.GetScanStatus(w, r)
}
func (c *Controller) GetScanStatus(w http.ResponseWriter, r *http.Request) {
var trackCount int
c.DB.
Model(model.Track{}).
Count(&trackCount)
sub := subsonic.NewResponse()
sub.ScanStatus = &subsonic.ScanStatus{
Scanning: scanner.IsScanning(),
Count: trackCount,
}
respond(w, r, sub)
}
func (c *Controller) NotFound(w http.ResponseWriter, r *http.Request) {
respondError(w, r, 0, "unknown route")
}

View File

@@ -1,14 +0,0 @@
package handler
import (
"log"
"net/http"
)
//nolint:interfacer
func (c *Controller) WithLogging(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log.Printf("connection from `%s` for `%s`", r.RemoteAddr, r.URL)
next.ServeHTTP(w, r)
}
}

View File

@@ -1,96 +0,0 @@
package handler
import (
"context"
"crypto/md5"
"encoding/hex"
"fmt"
"net/http"
"net/url"
)
var requiredParameters = []string{
"u", "v", "c",
}
func checkHasAllParams(params url.Values) error {
for _, req := range requiredParameters {
param := params.Get(req)
if param != "" {
continue
}
return fmt.Errorf("please provide a `%s` parameter", req)
}
return nil
}
func checkCredentialsToken(password, token, salt string) bool {
toHash := fmt.Sprintf("%s%s", password, salt)
hash := md5.Sum([]byte(toHash))
expToken := hex.EncodeToString(hash[:])
return token == expToken
}
func checkCredentialsBasic(password, given string) bool {
if given[:4] == "enc:" {
bytes, _ := hex.DecodeString(given[4:])
given = string(bytes)
}
return password == given
}
//nolint:interfacer
func (c *Controller) WithValidSubsonicArgs(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
err := checkHasAllParams(r.URL.Query())
if err != nil {
respondError(w, r, 10, err.Error())
return
}
username, password := r.URL.Query().Get("u"),
r.URL.Query().Get("p")
token, salt := r.URL.Query().Get("t"),
r.URL.Query().Get("s")
passwordAuth, tokenAuth := token == "" && salt == "",
password == ""
if tokenAuth == passwordAuth {
respondError(w, r, 10,
"please provide parameters `t` and `s`, or just `p`")
return
}
user := c.DB.GetUserFromName(username)
if user == nil {
respondError(w, r, 40, "invalid username `%s`", username)
return
}
var credsOk bool
if tokenAuth {
credsOk = checkCredentialsToken(user.Password, token, salt)
} else {
credsOk = checkCredentialsBasic(user.Password, password)
}
if !credsOk {
respondError(w, r, 40, "invalid password")
return
}
withUser := context.WithValue(r.Context(), contextUserKey, user)
next.ServeHTTP(w, r.WithContext(withUser))
}
}
//nolint:interfacer
func (c *Controller) WithCORS(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods",
"POST, GET, OPTIONS, PUT, DELETE",
)
w.Header().Set("Access-Control-Allow-Headers",
"Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization",
)
if r.Method == "OPTIONS" {
return
}
next.ServeHTTP(w, r)
}
}

View File

@@ -1,52 +0,0 @@
package handler
import (
"html/template"
"log"
"net/http"
"time"
"github.com/gorilla/sessions"
"senan.xyz/g/gonic/model"
)
type templateData struct {
// common
Flashes []interface{}
User *model.User
// home
AlbumCount int
ArtistCount int
TrackCount int
RequestRoot string
RecentFolders []*model.Album
AllUsers []*model.User
LastScanTime time.Time
IsScanning bool
//
CurrentLastFMAPIKey string
CurrentLastFMAPISecret string
SelectedUser *model.User
}
func renderTemplate(
w http.ResponseWriter,
r *http.Request,
tmpl *template.Template,
data *templateData,
) {
if data == nil {
data = &templateData{}
}
session := r.Context().Value(contextSessionKey).(*sessions.Session)
data.Flashes = session.Flashes()
sessLogSave(w, r, session)
data.User, _ = r.Context().Value(contextUserKey).(*model.User)
err := tmpl.Execute(w, data)
if err != nil {
log.Printf("error executing template: %v\n", err)
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
}

View File

@@ -1,82 +0,0 @@
package handler
import (
"encoding/json"
"encoding/xml"
"fmt"
"io"
"log"
"net/http"
"senan.xyz/g/gonic/server/subsonic"
)
type metaResponse struct {
XMLName xml.Name `xml:"subsonic-response" json:"-"`
*subsonic.Response `json:"subsonic-response"`
}
type errWriter struct {
w io.Writer
err error
}
func (ew *errWriter) write(buf []byte) {
if ew.err != nil {
return
}
_, ew.err = ew.w.Write(buf)
}
func respondRaw(w http.ResponseWriter, r *http.Request,
code int, sub *subsonic.Response) {
w.WriteHeader(code)
res := metaResponse{
Response: sub,
}
ew := &errWriter{w: w}
switch getStrParam(r, "f") {
case "json":
w.Header().Set("Content-Type", "application/json")
data, err := json.Marshal(res)
if err != nil {
log.Printf("could not marshall to json: %v\n", err)
return
}
ew.write(data)
case "jsonp":
w.Header().Set("Content-Type", "application/javascript")
data, err := json.Marshal(res)
if err != nil {
log.Printf("could not marshall to json: %v\n", err)
return
}
ew.write([]byte(getStrParamOr(r, "callback", "cb")))
ew.write([]byte("("))
ew.write(data)
ew.write([]byte(");"))
default:
w.Header().Set("Content-Type", "application/xml")
data, err := xml.MarshalIndent(res, "", " ")
if err != nil {
log.Printf("could not marshall to xml: %v\n", err)
return
}
ew.write(data)
}
if ew.err != nil {
log.Printf("error writing to response: %v\n", ew.err)
}
}
func respond(w http.ResponseWriter, r *http.Request,
sub *subsonic.Response) {
respondRaw(w, r, http.StatusOK, sub)
}
func respondError(w http.ResponseWriter, r *http.Request,
code int, message string, a ...interface{}) {
respondRaw(w, r, http.StatusBadRequest, subsonic.NewError(
code, fmt.Sprintf(message, a...),
))
}

8
server/key/key.go Normal file
View File

@@ -0,0 +1,8 @@
package key
type Key int
const (
User Key = iota
Session
)

View File

@@ -1,4 +1,4 @@
package handler package parsing
import ( import (
"fmt" "fmt"
@@ -6,19 +6,19 @@ import (
"strconv" "strconv"
) )
func getStrParam(r *http.Request, key string) string { func GetStrParam(r *http.Request, key string) string {
return r.URL.Query().Get(key) return r.URL.Query().Get(key)
} }
func getStrParamOr(r *http.Request, key, or string) string { func GetStrParamOr(r *http.Request, key, or string) string {
val := getStrParam(r, key) val := GetStrParam(r, key)
if val == "" { if val == "" {
return or return or
} }
return val return val
} }
func getIntParam(r *http.Request, key string) (int, error) { func GetIntParam(r *http.Request, key string) (int, error) {
strVal := r.URL.Query().Get(key) strVal := r.URL.Query().Get(key)
if strVal == "" { if strVal == "" {
return 0, fmt.Errorf("no param with key `%s`", key) return 0, fmt.Errorf("no param with key `%s`", key)
@@ -30,8 +30,8 @@ func getIntParam(r *http.Request, key string) (int, error) {
return val, nil return val, nil
} }
func getIntParamOr(r *http.Request, key string, or int) int { func GetIntParamOr(r *http.Request, key string, or int) int {
val, err := getIntParam(r, key) val, err := GetIntParam(r, key)
if err != nil { if err != nil {
return or return or
} }

View File

@@ -1,53 +1,123 @@
package server package server
import ( import (
"bytes"
"net/http" "net/http"
"path/filepath"
"time" "time"
"github.com/gorilla/mux"
"senan.xyz/g/gonic/assets"
"senan.xyz/g/gonic/db" "senan.xyz/g/gonic/db"
"senan.xyz/g/gonic/server/handler" "senan.xyz/g/gonic/server/ctrladmin"
"senan.xyz/g/gonic/server/ctrlbase"
"senan.xyz/g/gonic/server/ctrlsubsonic"
) )
type Server struct { type Server struct {
mux *http.ServeMux
*handler.Controller
*http.Server *http.Server
router *mux.Router
ctrlBase *ctrlbase.Controller
} }
func New( func New(db *db.DB, musicPath string, listenAddr string) *Server {
db *db.DB, ctrlBase := &ctrlbase.Controller{
musicPath string, DB: db,
listenAddr string, MusicPath: musicPath,
) *Server { }
mux := http.NewServeMux() router := mux.NewRouter()
// jamstash seems to call "musicFolderSettings.view" to start a scan. notice
// that there is no "/rest/" prefix, so i doesn't fit in with the nice router,
// custom handler, middleware. etc setup that we've got in `SetupSubsonic()`.
// instead lets redirect to down there and use the scan endpoint
router.HandleFunc("/musicFolderSettings.view", func(w http.ResponseWriter, r *http.Request) {
oldParams := r.URL.Query().Encode()
redirectTo := "/rest/startScan.view?" + oldParams
http.Redirect(w, r, redirectTo, http.StatusMovedPermanently)
})
router.Use(ctrlBase.WithLogging)
router.Use(ctrlBase.WithCORS)
server := &http.Server{ server := &http.Server{
Addr: listenAddr, Addr: listenAddr,
Handler: mux, Handler: router,
ReadTimeout: 5 * time.Second, ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second,
IdleTimeout: 15 * time.Second, IdleTimeout: 15 * time.Second,
} }
controller := &handler.Controller{
DB: db,
MusicPath: musicPath,
}
return &Server{ return &Server{
mux: mux,
Server: server, Server: server,
Controller: controller, router: router,
ctrlBase: ctrlBase,
} }
} }
type middleware func(next http.HandlerFunc) http.HandlerFunc func (s *Server) SetupAdmin() error {
ctrl := ctrladmin.New(s.ctrlBase)
// TODO: remove all the H()s
routPublic := s.router.PathPrefix("/admin").Subrouter()
routPublic.Use(ctrl.WithSession)
routPublic.Handle("/login", ctrl.H(ctrl.ServeLogin))
routPublic.Handle("/login_do", ctrl.H(ctrl.ServeLoginDo))
assets.PrefixDo("static", func(path string, asset *assets.EmbeddedAsset) {
_, name := filepath.Split(path)
route := filepath.Join("/static", name)
reader := bytes.NewReader(asset.Bytes)
routPublic.HandleFunc(route, func(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, name, asset.ModTime, reader)
})
})
//
// begin user routes (if session is valid)
routUser := routPublic.NewRoute().Subrouter()
routUser.Use(ctrl.WithUserSession)
routUser.Handle("/logout", ctrl.H(ctrl.ServeLogout))
routUser.Handle("/home", ctrl.H(ctrl.ServeHome))
routUser.Handle("/change_own_password", ctrl.H(ctrl.ServeChangeOwnPassword))
routUser.Handle("/change_own_password_do", ctrl.H(ctrl.ServeChangeOwnPasswordDo))
routUser.Handle("/link_lastfm_do", ctrl.H(ctrl.ServeLinkLastFMDo))
routUser.Handle("/unlink_lastfm_do", ctrl.H(ctrl.ServeUnlinkLastFMDo))
// begin admin routes (if session is valid, and is admin)
routAdmin := routUser.NewRoute().Subrouter()
routAdmin.Use(ctrl.WithAdminSession)
routAdmin.Handle("/change_password", ctrl.H(ctrl.ServeChangePassword))
routAdmin.Handle("/change_password_do", ctrl.H(ctrl.ServeChangePasswordDo))
routAdmin.Handle("/delete_user", ctrl.H(ctrl.ServeDeleteUser))
routAdmin.Handle("/delete_user_do", ctrl.H(ctrl.ServeDeleteUserDo))
routAdmin.Handle("/create_user", ctrl.H(ctrl.ServeCreateUser))
routAdmin.Handle("/create_user_do", ctrl.H(ctrl.ServeCreateUserDo))
routAdmin.Handle("/update_lastfm_api_key", ctrl.H(ctrl.ServeUpdateLastFMAPIKey))
routAdmin.Handle("/update_lastfm_api_key_do", ctrl.H(ctrl.ServeUpdateLastFMAPIKeyDo))
routAdmin.Handle("/start_scan_do", ctrl.H(ctrl.ServeStartScanDo))
return nil
}
func newChain(wares ...middleware) middleware { func (s *Server) SetupSubsonic() error {
return func(final http.HandlerFunc) http.HandlerFunc { ctrl := ctrlsubsonic.New(s.ctrlBase)
return func(w http.ResponseWriter, r *http.Request) { rout := s.router.PathPrefix("/rest").Subrouter()
last := final rout.Use(ctrl.WithValidSubsonicArgs)
for i := len(wares) - 1; i >= 0; i-- { rout.NotFoundHandler = ctrl.H(ctrl.ServeNotFound)
last = wares[i](last) // common
} rout.Handle("/getLicense{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetLicence))
last(w, r) rout.Handle("/getMusicFolders{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetMusicFolders))
} rout.Handle("/getScanStatus{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetScanStatus))
} rout.Handle("/ping{_:(?:\\.view)?}", ctrl.H(ctrl.ServePing))
rout.Handle("/scrobble{_:(?:\\.view)?}", ctrl.H(ctrl.ServeScrobble))
rout.Handle("/startScan{_:(?:\\.view)?}", ctrl.H(ctrl.ServeStartScan))
// raw
rout.Handle("/download{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeStream))
rout.Handle("/getCoverArt{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeGetCoverArt))
rout.Handle("/stream{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeStream))
// browse by tag
rout.Handle("/getAlbum{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetAlbum))
rout.Handle("/getAlbumList2{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetAlbumListTwo))
rout.Handle("/getArtist{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetArtist))
rout.Handle("/getArtists{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetArtists))
rout.Handle("/search3{_:(?:\\.view)?}", ctrl.H(ctrl.ServeSearchThree))
// browse by folder
rout.Handle("/getIndexes{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetIndexes))
rout.Handle("/getMusicDirectory{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetMusicDirectory))
rout.Handle("/getAlbumList{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetAlbumList))
rout.Handle("/search2{_:(?:\\.view)?}", ctrl.H(ctrl.ServeSearchTwo))
return nil
} }

View File

@@ -1,120 +0,0 @@
package server
import (
"bytes"
"html/template"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/Masterminds/sprig" //nolint:typecheck
"github.com/dustin/go-humanize"
"github.com/gorilla/securecookie"
"github.com/wader/gormstore"
)
const (
prefixLayouts = "layouts"
prefixPages = "pages"
prefixPartials = "partials"
prefixStatic = "static"
)
type tmplMap map[string]*template.Template
// prefixDo runs a given callback for every path in our assets with
// the given prefix
func prefixDo(pre string, cb func(path string, asset *EmbeddedAsset)) {
for path, asset := range assetBytes {
if strings.HasPrefix(path, pre) {
cb(path, asset)
}
}
}
// extendFromPaths /extends/ the given template for every asset
// with given prefix
func extendFromPaths(b *template.Template, p string) *template.Template {
prefixDo(p, func(_ string, asset *EmbeddedAsset) {
tmplStr := string(asset.Bytes)
b = template.Must(b.Parse(tmplStr))
})
return b
}
// extendFromPaths /clones/ the given template for every asset
// with given prefix, extends it, and insert it into a new tmplMap
func pagesFromPaths(b *template.Template, p string) tmplMap {
ret := tmplMap{}
prefixDo(p, func(path string, asset *EmbeddedAsset) {
tmplKey := filepath.Base(path)
clone := template.Must(b.Clone())
tmplStr := string(asset.Bytes)
ret[tmplKey] = template.Must(clone.Parse(tmplStr))
})
return ret
}
func (s *Server) SetupAdmin() error {
sessionKey := []byte(s.DB.GetSetting("session_key"))
if len(sessionKey) == 0 {
sessionKey = securecookie.GenerateRandomKey(32)
s.DB.SetSetting("session_key", string(sessionKey))
}
s.SessDB = gormstore.New(s.DB.DB, sessionKey)
go s.SessDB.PeriodicCleanup(time.Hour, nil)
//
tmplBase := template.
New("layout").
Funcs(sprig.FuncMap()).
Funcs(template.FuncMap{
"humanDate": humanize.Time,
})
tmplBase = extendFromPaths(tmplBase, prefixPartials)
tmplBase = extendFromPaths(tmplBase, prefixLayouts)
s.Templates = pagesFromPaths(tmplBase, prefixPages)
// setup static server
prefixDo(prefixStatic, func(path string, asset *EmbeddedAsset) {
_, name := filepath.Split(path)
route := filepath.Join("/admin/static", name)
reader := bytes.NewReader(asset.Bytes)
s.mux.HandleFunc(route, func(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, name, asset.ModTime, reader)
})
})
//
withPublicWare := newChain(
s.WithLogging,
s.WithSession,
)
withUserWare := newChain(
withPublicWare,
s.WithUserSession,
)
withAdminWare := newChain(
withUserWare,
s.WithAdminSession,
)
// begin public routes (creates new session)
s.mux.HandleFunc("/admin/login", withPublicWare(s.ServeLogin))
s.mux.HandleFunc("/admin/login_do", withPublicWare(s.ServeLoginDo))
// begin user routes (if session is valid)
s.mux.HandleFunc("/admin/logout", withUserWare(s.ServeLogout))
s.mux.HandleFunc("/admin/home", withUserWare(s.ServeHome))
s.mux.HandleFunc("/admin/change_own_password", withUserWare(s.ServeChangeOwnPassword))
s.mux.HandleFunc("/admin/change_own_password_do", withUserWare(s.ServeChangeOwnPasswordDo))
s.mux.HandleFunc("/admin/link_lastfm_do", withUserWare(s.ServeLinkLastFMDo))
s.mux.HandleFunc("/admin/unlink_lastfm_do", withUserWare(s.ServeUnlinkLastFMDo))
// begin admin routes (if session is valid, and is admin)
s.mux.HandleFunc("/admin/change_password", withAdminWare(s.ServeChangePassword))
s.mux.HandleFunc("/admin/change_password_do", withAdminWare(s.ServeChangePasswordDo))
s.mux.HandleFunc("/admin/delete_user", withAdminWare(s.ServeDeleteUser))
s.mux.HandleFunc("/admin/delete_user_do", withAdminWare(s.ServeDeleteUserDo))
s.mux.HandleFunc("/admin/create_user", withAdminWare(s.ServeCreateUser))
s.mux.HandleFunc("/admin/create_user_do", withAdminWare(s.ServeCreateUserDo))
s.mux.HandleFunc("/admin/update_lastfm_api_key", withAdminWare(s.ServeUpdateLastFMAPIKey))
s.mux.HandleFunc("/admin/update_lastfm_api_key_do", withAdminWare(s.ServeUpdateLastFMAPIKeyDo))
s.mux.HandleFunc("/admin/start_scan_do", withAdminWare(s.ServeStartScanDo))
return nil
}

View File

@@ -1,49 +0,0 @@
package server
func (s *Server) SetupSubsonic() error {
withWare := newChain(
s.WithLogging,
s.WithCORS,
s.WithValidSubsonicArgs,
)
// common
s.mux.HandleFunc("/rest/download", withWare(s.Stream))
s.mux.HandleFunc("/rest/download.view", withWare(s.Stream))
s.mux.HandleFunc("/rest/stream", withWare(s.Stream))
s.mux.HandleFunc("/rest/stream.view", withWare(s.Stream))
s.mux.HandleFunc("/rest/getCoverArt", withWare(s.GetCoverArt))
s.mux.HandleFunc("/rest/getCoverArt.view", withWare(s.GetCoverArt))
s.mux.HandleFunc("/rest/getLicense", withWare(s.GetLicence))
s.mux.HandleFunc("/rest/getLicense.view", withWare(s.GetLicence))
s.mux.HandleFunc("/rest/ping", withWare(s.Ping))
s.mux.HandleFunc("/rest/ping.view", withWare(s.Ping))
s.mux.HandleFunc("/rest/scrobble", withWare(s.Scrobble))
s.mux.HandleFunc("/rest/scrobble.view", withWare(s.Scrobble))
s.mux.HandleFunc("/rest/getMusicFolders", withWare(s.GetMusicFolders))
s.mux.HandleFunc("/rest/getMusicFolders.view", withWare(s.GetMusicFolders))
s.mux.HandleFunc("/rest/startScan", withWare(s.StartScan))
s.mux.HandleFunc("/rest/startScan.view", withWare(s.StartScan))
s.mux.HandleFunc("/rest/getScanStatus", withWare(s.GetScanStatus))
s.mux.HandleFunc("/rest/getScanStatus.view", withWare(s.GetScanStatus))
// browse by tag
s.mux.HandleFunc("/rest/getAlbum", withWare(s.GetAlbum))
s.mux.HandleFunc("/rest/getAlbum.view", withWare(s.GetAlbum))
s.mux.HandleFunc("/rest/getAlbumList2", withWare(s.GetAlbumListTwo))
s.mux.HandleFunc("/rest/getAlbumList2.view", withWare(s.GetAlbumListTwo))
s.mux.HandleFunc("/rest/getArtist", withWare(s.GetArtist))
s.mux.HandleFunc("/rest/getArtist.view", withWare(s.GetArtist))
s.mux.HandleFunc("/rest/getArtists", withWare(s.GetArtists))
s.mux.HandleFunc("/rest/getArtists.view", withWare(s.GetArtists))
s.mux.HandleFunc("/rest/search3", withWare(s.SearchThree))
s.mux.HandleFunc("/rest/search3.view", withWare(s.SearchThree))
// browse by folder
s.mux.HandleFunc("/rest/getIndexes", withWare(s.GetIndexes))
s.mux.HandleFunc("/rest/getIndexes.view", withWare(s.GetIndexes))
s.mux.HandleFunc("/rest/getMusicDirectory", withWare(s.GetMusicDirectory))
s.mux.HandleFunc("/rest/getMusicDirectory.view", withWare(s.GetMusicDirectory))
s.mux.HandleFunc("/rest/getAlbumList", withWare(s.GetAlbumList))
s.mux.HandleFunc("/rest/getAlbumList.view", withWare(s.GetAlbumList))
s.mux.HandleFunc("/rest/search2", withWare(s.SearchTwo))
s.mux.HandleFunc("/rest/search2.view", withWare(s.SearchTwo))
return nil
}