This commit is contained in:
sentriz
2019-05-19 23:28:05 +01:00
parent 5c657d9630
commit ad571ed7ab
45 changed files with 787 additions and 492 deletions

43
server/handler/handler.go Normal file
View File

@@ -0,0 +1,43 @@
package handler
import (
"html/template"
"github.com/jinzhu/gorm"
"github.com/wader/gormstore"
"github.com/sentriz/gonic/model"
)
type contextKey int
const (
contextUserKey contextKey = iota
contextSessionKey
)
type Controller struct {
DB *gorm.DB
SessDB *gormstore.Store
Templates map[string]*template.Template
MusicPath string
}
func (c *Controller) GetSetting(key string) string {
var setting model.Setting
c.DB.Where("key = ?", key).First(&setting)
return setting.Value
}
func (c *Controller) SetSetting(key, value string) {
c.DB.
Where(model.Setting{Key: key}).
Assign(model.Setting{Value: value}).
FirstOrCreate(&model.Setting{})
}
func (c *Controller) GetUserFromName(name string) *model.User {
var user model.User
c.DB.Where("name = ?", name).First(&user)
return &user
}

View File

@@ -0,0 +1,245 @@
package handler
import (
"fmt"
"net/http"
"github.com/gorilla/sessions"
"github.com/jinzhu/gorm"
"github.com/sentriz/gonic/model"
"github.com/sentriz/gonic/lastfm"
)
func (c *Controller) ServeLogin(w http.ResponseWriter, r *http.Request) {
renderTemplate(w, r, c.Templates["login"], nil)
}
func (c *Controller) ServeLoginDo(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value(contextSessionKey).(*sessions.Session)
username := r.FormValue("username")
password := r.FormValue("password")
if username == "" || password == "" {
session.AddFlash("please provide both a username and password")
session.Save(r, w)
http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther)
return
}
user := c.GetUserFromName(username)
if !(username == user.Name && password == user.Password) {
session.AddFlash("invalid username / password")
session.Save(r, w)
http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther)
return
}
// put the user name into the session. future endpoints after this one
// are wrapped with WithUserSession() which will get the name from the
// session and put the row into the request context.
session.Values["user"] = user.Name
session.Save(r, w)
http.Redirect(w, r, "/admin/home", http.StatusSeeOther)
}
func (c *Controller) ServeLogout(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value(contextSessionKey).(*sessions.Session)
session.Options.MaxAge = -1
session.Save(r, w)
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
}
func (c *Controller) ServeHome(w http.ResponseWriter, r *http.Request) {
var data templateData
c.DB.Table("album_artists").Count(&data.ArtistCount)
c.DB.Table("albums").Count(&data.AlbumCount)
c.DB.Table("tracks").Count(&data.TrackCount)
c.DB.Find(&data.AllUsers)
data.CurrentLastFMAPIKey = c.GetSetting("lastfm_api_key")
scheme := firstExisting(
"http", // fallback
r.Header.Get("X-Forwarded-Proto"),
r.Header.Get("X-Forwarded-Scheme"),
r.URL.Scheme,
)
host := firstExisting(
"localhost:7373", // fallback
r.Header.Get("X-Forwarded-Host"),
r.Host,
)
data.RequestRoot = fmt.Sprintf("%s://%s", scheme, host)
renderTemplate(w, r, c.Templates["home"], &data)
}
func (c *Controller) ServeChangeOwnPassword(w http.ResponseWriter, r *http.Request) {
renderTemplate(w, r, c.Templates["change_own_password"], nil)
}
func (c *Controller) ServeChangeOwnPasswordDo(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value(contextSessionKey).(*sessions.Session)
passwordOne := r.FormValue("password_one")
passwordTwo := r.FormValue("password_two")
err := validatePasswords(passwordOne, passwordTwo)
if err != nil {
session.AddFlash(err.Error())
session.Save(r, w)
http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther)
return
}
user := r.Context().Value(contextUserKey).(*model.User)
user.Password = passwordOne
c.DB.Save(user)
http.Redirect(w, r, "/admin/home", http.StatusSeeOther)
}
func (c *Controller) ServeLinkLastFMDo(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
http.Error(w, "please provide a token", 400)
return
}
sessionKey, err := lastfm.GetSession(
c.GetSetting("lastfm_api_key"),
c.GetSetting("lastfm_secret"),
token,
)
session := r.Context().Value(contextSessionKey).(*sessions.Session)
if err != nil {
session.AddFlash(err.Error())
session.Save(r, w)
http.Redirect(w, r, "/admin/home", http.StatusSeeOther)
return
}
user := r.Context().Value(contextUserKey).(*model.User)
user.LastFMSession = sessionKey
c.DB.Save(&user)
http.Redirect(w, r, "/admin/home", http.StatusSeeOther)
}
func (c *Controller) ServeUnlinkLastFMDo(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(contextUserKey).(*model.User)
user.LastFMSession = ""
c.DB.Save(&user)
http.Redirect(w, r, "/admin/home", http.StatusSeeOther)
}
func (c *Controller) ServeChangePassword(w http.ResponseWriter, r *http.Request) {
username := r.URL.Query().Get("user")
if username == "" {
http.Error(w, "please provide a username", 400)
return
}
var user model.User
err := c.DB.Where("name = ?", username).First(&user).Error
if gorm.IsRecordNotFoundError(err) {
http.Error(w, "couldn't find a user with that name", 400)
return
}
var data templateData
data.SelectedUser = &user
renderTemplate(w, r, c.Templates["change_password"], &data)
}
func (c *Controller) ServeChangePasswordDo(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value(contextSessionKey).(*sessions.Session)
username := r.URL.Query().Get("user")
var user model.User
c.DB.Where("name = ?", username).First(&user)
passwordOne := r.FormValue("password_one")
passwordTwo := r.FormValue("password_two")
err := validatePasswords(passwordOne, passwordTwo)
if err != nil {
session.AddFlash(err.Error())
session.Save(r, w)
http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther)
return
}
user.Password = passwordOne
c.DB.Save(&user)
http.Redirect(w, r, "/admin/home", http.StatusSeeOther)
}
func (c *Controller) ServeDeleteUser(w http.ResponseWriter, r *http.Request) {
username := r.URL.Query().Get("user")
if username == "" {
http.Error(w, "please provide a username", 400)
return
}
var user model.User
err := c.DB.Where("name = ?", username).First(&user).Error
if gorm.IsRecordNotFoundError(err) {
http.Error(w, "couldn't find a user with that name", 400)
return
}
var data templateData
data.SelectedUser = &user
renderTemplate(w, r, c.Templates["delete_user"], &data)
}
func (c *Controller) ServeDeleteUserDo(w http.ResponseWriter, r *http.Request) {
username := r.URL.Query().Get("user")
var user model.User
c.DB.Where("name = ?", username).First(&user)
c.DB.Delete(&user)
http.Redirect(w, r, "/admin/home", http.StatusSeeOther)
}
func (c *Controller) ServeCreateUser(w http.ResponseWriter, r *http.Request) {
renderTemplate(w, r, c.Templates["create_user"], nil)
}
func (c *Controller) ServeCreateUserDo(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value(contextSessionKey).(*sessions.Session)
username := r.FormValue("username")
err := validateUsername(username)
if err != nil {
session.AddFlash(err.Error())
session.Save(r, w)
http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther)
return
}
passwordOne := r.FormValue("password_one")
passwordTwo := r.FormValue("password_two")
err = validatePasswords(passwordOne, passwordTwo)
if err != nil {
session.AddFlash(err.Error())
session.Save(r, w)
http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther)
return
}
user := model.User{
Name: username,
Password: passwordOne,
}
err = c.DB.Create(&user).Error
if err != nil {
session.AddFlash(fmt.Sprintf(
"could not create user `%s`: %v", username, err,
))
session.Save(r, w)
http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/home", http.StatusSeeOther)
}
func (c *Controller) ServeUpdateLastFMAPIKey(w http.ResponseWriter, r *http.Request) {
var data templateData
data.CurrentLastFMAPIKey = c.GetSetting("lastfm_api_key")
data.CurrentLastFMAPISecret = c.GetSetting("lastfm_secret")
renderTemplate(w, r, c.Templates["update_lastfm_api_key"], &data)
}
func (c *Controller) ServeUpdateLastFMAPIKeyDo(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value(contextSessionKey).(*sessions.Session)
apiKey := r.FormValue("api_key")
secret := r.FormValue("secret")
err := validateAPIKey(apiKey, secret)
if err != nil {
session.AddFlash(err.Error())
session.Save(r, w)
http.Redirect(w, r, r.Header.Get("Referer"), http.StatusSeeOther)
return
}
c.SetSetting("lastfm_api_key", apiKey)
c.SetSetting("lastfm_secret", secret)
http.Redirect(w, r, "/admin/home", http.StatusSeeOther)
}

View File

@@ -0,0 +1,42 @@
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
}
func firstExisting(or string, strings ...string) string {
current := ""
for _, s := range strings {
if s == "" {
continue
}
current = s
break
}
if current == "" {
return or
}
return current
}

View File

@@ -0,0 +1,179 @@
package handler
import (
"fmt"
"net/http"
"github.com/jinzhu/gorm"
"github.com/sentriz/gonic/model"
"github.com/sentriz/gonic/server/subsonic"
)
func (c *Controller) GetIndexes(w http.ResponseWriter, r *http.Request) {
// we are browsing by folder, but the subsonic docs show sub <artist> elements
// for this, so we're going to return root directories as "artists"
var folders []*model.Folder
c.DB.Where("parent_id = ?", 1).Find(&folders)
var indexMap = make(map[rune]*subsonic.Index)
var indexes []*subsonic.Index
for _, folder := range folders {
i := indexOf(folder.Name)
index, ok := indexMap[i]
if !ok {
index = &subsonic.Index{
Name: string(i),
Artists: []*subsonic.Artist{},
}
indexMap[i] = index
indexes = append(indexes, index)
}
index.Artists = append(index.Artists, &subsonic.Artist{
ID: folder.ID,
Name: folder.Name,
})
}
sub := subsonic.NewResponse()
sub.Indexes = &subsonic.Indexes{
LastModified: 0,
Index: indexes,
}
respond(w, r, sub)
}
func (c *Controller) GetMusicDirectory(w http.ResponseWriter, r *http.Request) {
id, err := getIntParam(r, "id")
if err != nil {
respondError(w, r, 10, "please provide an `id` parameter")
return
}
childrenObj := []*subsonic.Child{}
var cFolder model.Folder
c.DB.First(&cFolder, id)
//
// start looking for child folders in the current dir
var folders []*model.Folder
c.DB.
Where("parent_id = ?", id).
Find(&folders)
for _, folder := range folders {
childrenObj = append(childrenObj, &subsonic.Child{
Parent: cFolder.ID,
ID: folder.ID,
Title: folder.Name,
IsDir: true,
CoverID: folder.CoverID,
})
}
//
// start looking for child tracks in the current dir
var tracks []*model.Track
c.DB.
Where("folder_id = ?", id).
Preload("Album").
Order("track_number").
Find(&tracks)
for _, track := range tracks {
if getStrParam(r, "c") == "Jamstash" {
// jamstash thinks it can't play flacs
track.ContentType = "audio/mpeg"
track.Suffix = "mp3"
}
childrenObj = append(childrenObj, &subsonic.Child{
ID: track.ID,
Album: track.Album.Title,
Artist: track.Artist,
ContentType: track.ContentType,
CoverID: cFolder.CoverID,
Duration: 0,
IsDir: false,
Parent: cFolder.ID,
Path: track.Path,
Size: track.Size,
Suffix: track.Suffix,
Title: track.Title,
Track: track.TrackNumber,
Type: "music",
})
}
//
// respond section
sub := subsonic.NewResponse()
sub.Directory = &subsonic.Directory{
ID: cFolder.ID,
Parent: cFolder.ParentID,
Name: cFolder.Name,
Children: childrenObj,
}
respond(w, r, sub)
}
// changes to this function should be reflected in in _by_tags.go's
// getAlbumListTwo() function
func (c *Controller) GetAlbumList(w http.ResponseWriter, r *http.Request) {
listType := getStrParam(r, "type")
if listType == "" {
respondError(w, r, 10, "please provide a `type` parameter")
return
}
q := c.DB
switch listType {
case "alphabeticalByArtist":
// not sure what it meant by "artist" since we're browsing by folder
// - so we'll consider the parent folder's name to be the "artist"
q = q.Joins(`
JOIN folders AS parent_folders
ON folders.parent_id = parent_folders.id`)
q = q.Order("parent_folders.name")
case "alphabeticalByName":
// not sure about "name" either, so lets use the folder's name
q = q.Order("name")
case "frequent":
user := r.Context().Value(contextUserKey).(*model.User)
q = q.Joins(`
JOIN plays
ON folders.id = plays.folder_id AND plays.user_id = ?`,
user.ID)
q = q.Order("plays.count DESC")
case "newest":
q = q.Order("updated_at DESC")
case "random":
q = q.Order(gorm.Expr("random()"))
case "recent":
user := r.Context().Value(contextUserKey).(*model.User)
q = q.Joins(`
JOIN plays
ON folders.id = plays.folder_id AND plays.user_id = ?`,
user.ID)
q = q.Order("plays.time DESC")
default:
respondError(w, r, 10, fmt.Sprintf(
"unknown value `%s` for parameter 'type'", listType,
))
return
}
var folders []*model.Folder
q.
Where("folders.has_tracks = 1").
Offset(getIntParamOr(r, "offset", 0)).
Limit(getIntParamOr(r, "size", 10)).
Preload("Parent").
Find(&folders)
listObj := []*subsonic.Album{}
for _, folder := range folders {
listObj = append(listObj, &subsonic.Album{
ID: folder.ID,
Title: folder.Name,
Album: folder.Name,
CoverID: folder.CoverID,
ParentID: folder.ParentID,
IsDir: true,
Artist: folder.Parent.Name,
})
}
sub := subsonic.NewResponse()
sub.Albums = &subsonic.Albums{
List: listObj,
}
respond(w, r, sub)
}

View File

@@ -0,0 +1,180 @@
package handler
import (
"fmt"
"net/http"
"github.com/jinzhu/gorm"
"github.com/sentriz/gonic/model"
"github.com/sentriz/gonic/server/subsonic"
)
func (c *Controller) GetArtists(w http.ResponseWriter, r *http.Request) {
var artists []*model.AlbumArtist
c.DB.Find(&artists)
var indexMap = make(map[rune]*subsonic.Index)
var indexes subsonic.Artists
for _, artist := range artists {
i := indexOf(artist.Name)
index, ok := indexMap[i]
if !ok {
index = &subsonic.Index{
Name: string(i),
Artists: []*subsonic.Artist{},
}
indexMap[i] = index
indexes.List = append(indexes.List, index)
}
index.Artists = append(index.Artists, &subsonic.Artist{
ID: artist.ID,
Name: artist.Name,
})
}
sub := subsonic.NewResponse()
sub.Artists = &indexes
respond(w, r, sub)
}
func (c *Controller) GetArtist(w http.ResponseWriter, r *http.Request) {
id, err := getIntParam(r, "id")
if err != nil {
respondError(w, r, 10, "please provide an `id` parameter")
return
}
var artist model.AlbumArtist
c.DB.
Preload("Albums").
First(&artist, id)
albumsObj := []*subsonic.Album{}
for _, album := range artist.Albums {
albumsObj = append(albumsObj, &subsonic.Album{
ID: album.ID,
Name: album.Title,
Created: album.CreatedAt,
Artist: artist.Name,
ArtistID: artist.ID,
CoverID: album.CoverID,
})
}
sub := subsonic.NewResponse()
sub.Artist = &subsonic.Artist{
ID: artist.ID,
Name: artist.Name,
Albums: albumsObj,
}
respond(w, r, sub)
}
func (c *Controller) GetAlbum(w http.ResponseWriter, r *http.Request) {
id, err := getIntParam(r, "id")
if err != nil {
respondError(w, r, 10, "please provide an `id` parameter")
return
}
var album model.Album
c.DB.
Preload("AlbumArtist").
Preload("Tracks").
First(&album, id)
tracksObj := []*subsonic.Track{}
for _, track := range album.Tracks {
tracksObj = append(tracksObj, &subsonic.Track{
ID: track.ID,
Title: track.Title,
Artist: track.Artist, // track artist
TrackNo: track.TrackNumber,
ContentType: track.ContentType,
Path: track.Path,
Suffix: track.Suffix,
Created: track.CreatedAt,
Size: track.Size,
Album: album.Title,
AlbumID: album.ID,
ArtistID: album.AlbumArtist.ID, // album artist
CoverID: album.CoverID,
Type: "music",
})
}
sub := subsonic.NewResponse()
sub.Album = &subsonic.Album{
ID: album.ID,
Name: album.Title,
CoverID: album.CoverID,
Created: album.CreatedAt,
Artist: album.AlbumArtist.Name,
Tracks: tracksObj,
}
respond(w, r, sub)
}
// changes to this function should be reflected in in _by_folder.go's
// getAlbumList() function
func (c *Controller) GetAlbumListTwo(w http.ResponseWriter, r *http.Request) {
listType := getStrParam(r, "type")
if listType == "" {
respondError(w, r, 10, "please provide a `type` parameter")
return
}
q := c.DB
switch listType {
case "alphabeticalByArtist":
q = q.Joins(`
JOIN album_artists
ON albums.album_artist_id = album_artists.id`)
q = q.Order("album_artists.name")
case "alphabeticalByName":
q = q.Order("title")
case "byYear":
q = q.Where(
"year BETWEEN ? AND ?",
getIntParamOr(r, "fromYear", 1800),
getIntParamOr(r, "toYear", 2200))
q = q.Order("year")
case "frequent":
user := r.Context().Value(contextUserKey).(*model.User)
q = q.Joins(`
JOIN plays
ON albums.id = plays.album_id AND plays.user_id = ?`,
user.ID)
q = q.Order("plays.count DESC")
case "newest":
q = q.Order("updated_at DESC")
case "random":
q = q.Order(gorm.Expr("random()"))
case "recent":
user := r.Context().Value(contextUserKey).(*model.User)
q = q.Joins(`
JOIN plays
ON albums.id = plays.album_id AND plays.user_id = ?`,
user.ID)
q = q.Order("plays.time DESC")
default:
respondError(w, r, 10, fmt.Sprintf(
"unknown value `%s` for parameter 'type'", listType,
))
return
}
var albums []*model.Album
q.
Offset(getIntParamOr(r, "offset", 0)).
Limit(getIntParamOr(r, "size", 10)).
Preload("AlbumArtist").
Find(&albums)
listObj := []*subsonic.Album{}
for _, album := range albums {
listObj = append(listObj, &subsonic.Album{
ID: album.ID,
Name: album.Title,
Created: album.CreatedAt,
CoverID: album.CoverID,
Artist: album.AlbumArtist.Name,
ArtistID: album.AlbumArtist.ID,
})
}
sub := subsonic.NewResponse()
sub.AlbumsTwo = &subsonic.Albums{
List: listObj,
}
respond(w, r, sub)
}

View File

@@ -0,0 +1,157 @@
package handler
import (
"fmt"
"net/http"
"os"
"sync/atomic"
"time"
"unicode"
"github.com/rainycape/unidecode"
"github.com/sentriz/gonic/lastfm"
"github.com/sentriz/gonic/model"
"github.com/sentriz/gonic/scanner"
"github.com/sentriz/gonic/server/subsonic"
)
func indexOf(s string) rune {
first := string(s[0])
c := rune(unidecode.Unidecode(first)[0])
if !unicode.IsLetter(c) {
return '#'
}
return c
}
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
}
var track model.Track
c.DB.
Preload("Album").
Preload("Folder").
First(&track, id)
if track.Path == "" {
respondError(w, r, 70, fmt.Sprintf("media with id `%d` was not found", id))
return
}
file, err := os.Open(track.Path)
if err != nil {
respondError(w, r, 0, fmt.Sprintf("error while streaming media: %v", err))
return
}
stat, _ := file.Stat()
http.ServeContent(w, r, track.Path, 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,
FolderID: track.Folder.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
}
var cover model.Cover
c.DB.First(&cover, id)
w.Write(cover.Image)
}
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, fmt.Sprintf("no last.fm session for this user: %v", err))
return
}
// fetch track for getting info to send to last.fm function
var track model.Track
c.DB.
Preload("Album").
Preload("AlbumArtist").
First(&track, id)
// get time from args or use now
time := getIntParamOr(r, "time", int(time.Now().Unix()))
// get submission, where the default is true. we will
// check if it's false later
submission := getStrParamOr(r, "submission", "true")
// scrobble with above info
err = lastfm.Scrobble(
c.GetSetting("lastfm_api_key"),
c.GetSetting("lastfm_secret"),
user.LastFMSession,
&track,
time,
submission != "false",
)
if err != nil {
respondError(w, r, 0, fmt.Sprintf("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) {
scanC := scanner.New(c.DB, c.MusicPath)
go scanC.Start()
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: atomic.LoadInt32(&scanner.IsScanning) == 1,
Count: trackCount,
}
respond(w, r, sub)
}
func (c *Controller) NotFound(w http.ResponseWriter, r *http.Request) {
respondError(w, r, 0, "unknown route")
}

View File

@@ -0,0 +1,59 @@
package handler
import (
"context"
"net/http"
"github.com/gorilla/sessions"
"github.com/sentriz/gonic/model"
)
func (c *Controller) WithSession(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session, _ := c.SessDB.Get(r, "gonic")
withSession := context.WithValue(r.Context(), contextSessionKey, session)
next.ServeHTTP(w, r.WithContext(withSession))
}
}
func (c *Controller) WithUserSession(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// session exists at this point
session := r.Context().Value(contextSessionKey).(*sessions.Session)
username, ok := session.Values["user"].(string)
if !ok {
session.AddFlash("you are not authenticated")
session.Save(r, w)
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// take username from sesion and add the user row to the context
user := c.GetUserFromName(username)
if user.ID == 0 {
// the username in the client's session no longer relates to a
// user in the database (maybe the user was deleted)
session.Options.MaxAge = -1
session.Save(r, w)
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
withUser := context.WithValue(r.Context(), contextUserKey, user)
next.ServeHTTP(w, r.WithContext(withUser))
}
}
func (c *Controller) WithAdminSession(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// session and user exist at this point
session := r.Context().Value(contextSessionKey).(*sessions.Session)
user := r.Context().Value(contextUserKey).(*model.User)
if !user.IsAdmin {
session.AddFlash("you are not an admin")
session.Save(r, w)
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
}
}

View File

@@ -0,0 +1,13 @@
package handler
import (
"log"
"net/http"
)
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

@@ -0,0 +1,98 @@
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, givenPassword string) bool {
if givenPassword[:4] == "enc:" {
bytes, _ := hex.DecodeString(givenPassword[4:])
givenPassword = string(bytes)
}
return password == givenPassword
}
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.GetUserFromName(username)
if user.ID == 0 {
// the user does not exist
respondError(w, r, 40, "invalid 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))
}
}
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)
}
}

39
server/handler/parse.go Normal file
View File

@@ -0,0 +1,39 @@
package handler
import (
"fmt"
"net/http"
"strconv"
)
func getStrParam(r *http.Request, key string) string {
return r.URL.Query().Get(key)
}
func getStrParamOr(r *http.Request, key, or string) string {
val := getStrParam(r, key)
if val == "" {
return or
}
return val
}
func getIntParam(r *http.Request, key string) (int, error) {
strVal := r.URL.Query().Get(key)
if strVal == "" {
return 0, fmt.Errorf("no param with key `%s`", key)
}
val, err := strconv.Atoi(strVal)
if err != nil {
return 0, fmt.Errorf("not an int `%s`", strVal)
}
return val, nil
}
func getIntParamOr(r *http.Request, key string, or int) int {
val, err := getIntParam(r, key)
if err != nil {
return or
}
return val
}

View File

@@ -0,0 +1,43 @@
package handler
import (
"fmt"
"html/template"
"net/http"
"github.com/gorilla/sessions"
"github.com/sentriz/gonic/model"
)
type templateData struct {
Flashes []interface{}
User *model.User
SelectedUser *model.User
AllUsers []*model.User
ArtistCount int
AlbumCount int
TrackCount int
CurrentLastFMAPIKey string
CurrentLastFMAPISecret string
RequestRoot string
}
func renderTemplate(w http.ResponseWriter, r *http.Request,
tmpl *template.Template, data *templateData) {
session := r.Context().Value(contextSessionKey).(*sessions.Session)
if data == nil {
data = &templateData{}
}
data.Flashes = session.Flashes()
session.Save(r, w)
user, ok := r.Context().Value(contextUserKey).(*model.User)
if ok {
data.User = user
}
err := tmpl.Execute(w, data)
if err != nil {
http.Error(w, fmt.Sprintf("500 when executing: %v", err), 500)
return
}
}

View File

@@ -0,0 +1,56 @@
package handler
import (
"encoding/json"
"encoding/xml"
"log"
"net/http"
"github.com/sentriz/gonic/server/subsonic"
)
func respondRaw(w http.ResponseWriter, r *http.Request,
code int, sub *subsonic.Response) {
res := subsonic.MetaResponse{
Response: sub,
}
switch r.URL.Query().Get("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)
}
w.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)
}
callback := r.URL.Query().Get("callback")
w.Write([]byte(callback))
w.Write([]byte("("))
w.Write(data)
w.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)
}
w.Write(data)
}
}
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) {
respondRaw(w, r, http.StatusBadRequest, subsonic.NewError(
code, message,
))
}

54
server/server.go Normal file
View File

@@ -0,0 +1,54 @@
package server
import (
"net/http"
"time"
"github.com/jinzhu/gorm"
_ "github.com/jinzhu/gorm/dialects/sqlite"
"github.com/sentriz/gonic/server/handler"
)
type middleware func(next http.HandlerFunc) http.HandlerFunc
func newChain(wares ...middleware) middleware {
return func(final http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
last := final
for i := len(wares) - 1; i >= 0; i-- {
last = wares[i](last)
}
last(w, r)
}
}
}
type Server struct {
mux *http.ServeMux
*handler.Controller
*http.Server
}
func New(db *gorm.DB, musicPath string, listenAddr string) *Server {
mux := http.NewServeMux()
server := &http.Server{
Addr: listenAddr,
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 15 * time.Second,
}
controller := &handler.Controller{
DB: db,
MusicPath: musicPath,
}
ret := &Server{
mux: mux,
Server: server,
Controller: controller,
}
ret.setupAdmin()
ret.setupSubsonic()
return ret
}

83
server/setup_admin.go Normal file
View File

@@ -0,0 +1,83 @@
package server
import (
"html/template"
"log"
"net/http"
"time"
"github.com/gobuffalo/packr/v2"
"github.com/gorilla/securecookie"
"github.com/wader/gormstore"
)
func extendFromBox(tmpl *template.Template, box *packr.Box, key string) *template.Template {
strT, err := box.FindString(key)
if err != nil {
log.Fatalf("error when reading template from box: %v", err)
}
if tmpl == nil {
tmpl = template.New("layout")
} else {
tmpl = template.Must(tmpl.Clone())
}
newT, err := tmpl.Parse(strT)
if err != nil {
log.Fatalf("error when parsing template template: %v", err)
}
return newT
}
func (s *Server) setupAdmin() {
sessionKey := []byte(s.GetSetting("session_key"))
if len(sessionKey) == 0 {
sessionKey = securecookie.GenerateRandomKey(32)
s.SetSetting("session_key", string(sessionKey))
}
// create gormstore (and cleanup) for backend sessions
s.SessDB = gormstore.New(s.DB, []byte(sessionKey))
go s.SessDB.PeriodicCleanup(1*time.Hour, nil)
// using packr to bundle templates and static files
box := packr.New("templates", "templates")
layoutT := extendFromBox(nil, box, "layout.tmpl")
userT := extendFromBox(layoutT, box, "user.tmpl")
s.Templates = map[string]*template.Template{
"login": extendFromBox(layoutT, box, "pages/login.tmpl"),
"home": extendFromBox(userT, box, "pages/home.tmpl"),
"change_own_password": extendFromBox(userT, box, "pages/change_own_password.tmpl"),
"change_password": extendFromBox(userT, box, "pages/change_password.tmpl"),
"delete_user": extendFromBox(userT, box, "pages/delete_user.tmpl"),
"create_user": extendFromBox(userT, box, "pages/create_user.tmpl"),
"update_lastfm_api_key": extendFromBox(userT, box, "pages/update_lastfm_api_key.tmpl"),
}
withPublicWare := newChain(
s.WithLogging,
s.WithSession,
)
withUserWare := newChain(
withPublicWare,
s.WithUserSession,
)
withAdminWare := newChain(
withUserWare,
s.WithAdminSession,
)
server := http.FileServer(packr.New("static", "static"))
s.mux.Handle("/admin/static/", http.StripPrefix("/admin/static/", server))
s.mux.HandleFunc("/admin/login", withPublicWare(s.ServeLogin))
s.mux.HandleFunc("/admin/login_do", withPublicWare(s.ServeLoginDo))
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))
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))
}

44
server/setup_subsonic.go Normal file
View File

@@ -0,0 +1,44 @@
package server
func (s *Server) setupSubsonic() {
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))
// 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))
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,114 @@
form input[type],
form select,
form textarea {
margin-bottom: 0;
}
form {
max-width: 400px;
margin-left: auto;
margin-right: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
}
form input[type=password],
form input[type=text] {
margin-bottom: 0.25rem;
}
form input[type=submit] {
width: 8rem;
}
div {
margin: 0;
padding: 0;
}
body {
max-width: 800px;
margin: 0 auto;
height: 100vh;
display: flex;
flex-direction: column;
justify-content: space-between;
}
a,
a:visited {
color: #0064c1;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
#content>* {
margin: 2rem 0;
}
#header {
border-bottom: 2px solid #0000001a;
padding-top: 3rem;
}
#header img {
max-width: 580px;
position: relative;
}
#footer {
text-align: right;
}
#flashes {
background-color: #fd1b1b1c;
border-right: 2px solid #fd1b1b1c;
border-bottom: 2px solid #fd1b1b1c;
}
.right {
text-align: right;
}
.light {
color: #00000082;
}
.mono {
font-family: monospace;
}
.pre {
white-space: pre;
}
.box {
background-color: #00000005;
border-right: 2px solid #0000000c;
border-bottom: 2px solid #0000000c;
}
.box-title {
margin-bottom: 0.5rem;
}
.padded {
padding: 1rem;
}
.side-padded {
padding: 0 1rem;
}
.angry {
background-color: #f4433669;
}
i.mdi {
font-size: 14px;
}

File diff suppressed because one or more lines are too long

123
server/subsonic/media.go Normal file
View File

@@ -0,0 +1,123 @@
package subsonic
import "time"
type Albums struct {
List []*Album `xml:"album" json:"album,omitempty"`
}
type Album struct {
// common
ID int `xml:"id,attr,omitempty" json:"id"`
CoverID int `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
ArtistID int `xml:"artistId,attr,omitempty" json:"artistId,omitempty"`
Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"`
// browsing by folder (getAlbumList)
Title string `xml:"title,attr,omitempty" json:"title,omitempty"`
Album string `xml:"album,attr,omitempty" json:"album,omitempty"`
ParentID int `xml:"parent,attr,omitempty" json:"parent,omitempty"`
IsDir bool `xml:"isDir,attr,omitempty" json:"isDir,omitempty"`
// browsing by tags (getAlbumList2)
Name string `xml:"name,attr,omitempty" json:"name,omitempty"`
TrackCount int `xml:"songCount,attr,omitempty" json:"songCount,omitempty"`
Duration int `xml:"duration,attr,omitempty" json:"duration,omitempty"`
Created time.Time `xml:"created,attr,omitempty" json:"created,omitempty"`
Tracks []*Track `xml:"song,omitempty" json:"song,omitempty"`
}
type RandomTracks struct {
Tracks []*Track `xml:"song" json:"song"`
}
type Track struct {
ID int `xml:"id,attr,omitempty" json:"id"`
Parent int `xml:"parent,attr,omitempty" json:"parent"`
Title string `xml:"title,attr,omitempty" json:"title"`
Album string `xml:"album,attr,omitempty" json:"album"`
Artist string `xml:"artist,attr,omitempty" json:"artist"`
IsDir bool `xml:"isDir,attr,omitempty" json:"isDir"`
CoverID int `xml:"coverArt,attr,omitempty" json:"coverArt"`
Created time.Time `xml:"created,attr,omitempty" json:"created"`
Duration int `xml:"duration,attr,omitempty" json:"duration"`
Genre string `xml:"genre,attr,omitempty" json:"genre"`
Bitrate int `xml:"bitRate,attr,omitempty" json:"bitRate"`
Size int `xml:"size,attr,omitempty" json:"size"`
Suffix string `xml:"suffix,attr,omitempty" json:"suffix"`
ContentType string `xml:"contentType,attr,omitempty" json:"contentType"`
IsVideo bool `xml:"isVideo,attr,omitempty" json:"isVideo"`
Path string `xml:"path,attr,omitempty" json:"path"`
AlbumID int `xml:"albumId,attr,omitempty" json:"albumId"`
ArtistID int `xml:"artistId,attr,omitempty" json:"artistId"`
TrackNo int `xml:"track,attr,omitempty" json:"track"`
Type string `xml:"type,attr,omitempty" json:"type"`
}
type Artists struct {
List []*Index `xml:"index,omitempty" json:"index,omitempty"`
}
type Artist struct {
ID int `xml:"id,attr,omitempty" json:"id"`
Name string `xml:"name,attr,omitempty" json:"name"`
CoverID int `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
AlbumCount int `xml:"albumCount,attr,omitempty" json:"albumCount,omitempty"`
Albums []*Album `xml:"album,omitempty" json:"album,omitempty"`
}
type Indexes struct {
LastModified int `xml:"lastModified,attr,omitempty" json:"lastModified"`
Index []*Index `xml:"index,omitempty" json:"index"`
}
type Index struct {
Name string `xml:"name,attr,omitempty" json:"name"`
Artists []*Artist `xml:"artist,omitempty" json:"artist"`
}
type Directory struct {
ID int `xml:"id,attr,omitempty" json:"id"`
Parent int `xml:"parent,attr,omitempty" json:"parent"`
Name string `xml:"name,attr,omitempty" json:"name"`
Starred string `xml:"starred,attr,omitempty" json:"starred,omitempty"`
Children []*Child `xml:"child,omitempty" json:"child"`
}
type Child struct {
ID int `xml:"id,attr,omitempty" json:"id,omitempty"`
Parent int `xml:"parent,attr,omitempty" json:"parent,omitempty"`
Title string `xml:"title,attr,omitempty" json:"title,omitempty"`
IsDir bool `xml:"isDir,attr,omitempty" json:"isDir,omitempty"`
Album string `xml:"album,attr,omitempty" json:"album,omitempty"`
AlbumID int `xml:"albumId,attr,omitempty" json:"albumId,omitempty"`
Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"`
ArtistID int `xml:"artistId,attr,omitempty" json:"artistId,omitempty"`
Track int `xml:"track,attr,omitempty" json:"track,omitempty"`
Year int `xml:"year,attr,omitempty" json:"year,omitempty"`
Genre string `xml:"genre,attr,omitempty" json:"genre,omitempty"`
CoverID int `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
Size int `xml:"size,attr,omitempty" json:"size,omitempty"`
ContentType string `xml:"contentType,attr,omitempty" json:"contentType,omitempty"`
Suffix string `xml:"suffix,attr,omitempty" json:"suffix,omitempty"`
Duration int `xml:"duration,attr,omitempty" json:"duration,omitempty"`
Bitrate int `xml:"bitRate,attr,omitempty" json:"bitrate,omitempty"`
Path string `xml:"path,attr,omitempty" json:"path,omitempty"`
Type string `xml:"type,attr,omitempty" json:"type,omitempty"`
}
type MusicFolders struct {
List []*MusicFolder `xml:"musicFolder,omitempty" json:"musicFolder,omitempty"`
}
type MusicFolder struct {
ID int `xml:"id,attr,omitempty" json:"id,omitempty"`
Name string `xml:"name,attr,omitempty" json:"name,omitempty"`
}
type Licence struct {
Valid bool `xml:"valid,attr,omitempty" json:"valid,omitempty"`
}
type ScanStatus struct {
Scanning bool `xml:"scanning,attr" json:"scanning"`
Count int `xml:"count,attr,omitempty" json:"count,omitempty"`
}

View File

@@ -0,0 +1,59 @@
// from "sonicmonkey" by https://github.com/jeena/sonicmonkey/
package subsonic
import "encoding/xml"
var (
apiVersion = "1.9.0"
xmlns = "http://subsonic.org/restapi"
)
type MetaResponse struct {
XMLName xml.Name `xml:"subsonic-response" json:"-"`
*Response `json:"subsonic-response"`
}
type Response struct {
Status string `xml:"status,attr" json:"status"`
Version string `xml:"version,attr" json:"version"`
XMLNS string `xml:"xmlns,attr" json:"-"`
Error *Error `xml:"error" json:"error,omitempty"`
AlbumsTwo *Albums `xml:"albumList2" json:"albumList2,omitempty"`
Albums *Albums `xml:"albumList" json:"albumList,omitempty"`
Album *Album `xml:"album" json:"album,omitempty"`
Track *Track `xml:"song" json:"song,omitempty"`
Indexes *Indexes `xml:"indexes" json:"indexes,omitempty"`
Artists *Artists `xml:"artists" json:"artists,omitempty"`
Artist *Artist `xml:"artist" json:"artist,omitempty"`
Directory *Directory `xml:"directory" json:"directory,omitempty"`
RandomTracks *RandomTracks `xml:"randomSongs" json:"randomSongs,omitempty"`
MusicFolders *MusicFolders `xml:"musicFolders" json:"musicFolders,omitempty"`
ScanStatus *ScanStatus `xml:"scanStatus" json:"scanStatus,omitempty"`
Licence *Licence `xml:"license" json:"license,omitempty"`
}
type Error struct {
Code int `xml:"code,attr" json:"code"`
Message string `xml:"message,attr" json:"message"`
}
func NewResponse() *Response {
return &Response{
Status: "ok",
XMLNS: xmlns,
Version: apiVersion,
}
}
func NewError(code int, message string) *Response {
return &Response{
Status: "failed",
XMLNS: xmlns,
Version: apiVersion,
Error: &Error{
Code: code,
Message: message,
},
}
}

View File

@@ -0,0 +1,35 @@
{{ define "layout" }}
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>{{ template "title" }}</title>
<link rel="stylesheet" href="https://cdn.materialdesignicons.com/3.6.95/css/materialdesignicons.min.css">
<link rel="stylesheet" href="/admin/static/stylesheets/awsm.css">
<link rel="stylesheet" href="/admin/static/stylesheets/main.css">
<link rel="shortcut icon" href="/admin/static/images/favicon.ico" type="image/x-icon">
<link rel="icon" href="/admin/static/images/favicon.ico" type="image/x-icon">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
</head>
<body>
<div>
<div id="header">
<img src="/admin/static/images/gonic.png">
</div>
<div id="content">
{{ if .Flashes }}
<div id="flashes" class="padded mono">
<i class="mdi mdi-alert-circle"></i> {{ index .Flashes 0 }}
</div>
{{ end }}
{{ template "content" . }}
</div>
</div>
<div id="footer" class="padded mono">
senan kelly, 2019
<span class="light">&#124;</span>
<a href="https://github.com/sentriz/gonic">github</a>
</div>
</body>
</html>
{{ end }}

View File

@@ -0,0 +1,14 @@
{{ define "title" }}home{{ end }}
{{ define "user" }}
<div class="padded box mono">
<div class="box-title">
<i class="mdi mdi-account-key"></i> changing account password
</div>
<form action="/admin/change_own_password_do" method="post">
<input type="password" id="password_one" name="password_one" placeholder="new password">
<input type="password" id="password_two" name="password_two" placeholder="verify new password">
<input type="submit" value="change">
</form>
</div>
{{ end }}

View File

@@ -0,0 +1,14 @@
{{ define "title" }}home{{ end }}
{{ define "user" }}
<div class="padded box mono">
<div class="box-title">
<i class="mdi mdi-account-key"></i> changing {{ .SelectedUser.Name }}'s password
</div>
<form action="/admin/change_password_do?user={{ .SelectedUser.Name }}" method="post">
<input type="password" id="password_one" name="password_one" placeholder="new password">
<input type="password" id="password_two" name="password_two" placeholder="verify new password">
<input type="submit" value="change">
</form>
</div>
{{ end }}

View File

@@ -0,0 +1,15 @@
{{ define "title" }}home{{ end }}
{{ define "user" }}
<div class="padded box mono">
<div class="box-title">
<i class="mdi mdi-account-plus"></i> creating new user
</div>
<form action="/admin/create_user_do" method="post">
<input type="text" id="username" name="username" placeholder="username">
<input type="password" id="password_one" name="password_one" placeholder="password">
<input type="password" id="password_two" name="password_two" placeholder="verify password">
<input type="submit" value="create">
</form>
</div>
{{ end }}

View File

@@ -0,0 +1,15 @@
{{ define "title" }}home{{ end }}
{{ define "user" }}
<div class="padded box mono">
<div class="box-title">
<i class="mdi mdi-account-remove"></i> deleting user {{ .SelectedUser.Name }}
</div>
<div class="right">
are you sure?
</div>
<form action="/admin/delete_user_do?user={{ .SelectedUser.Name }}" method="post">
<input type="submit" value="yes">
</form>
</div>
{{ end }}

View File

@@ -0,0 +1,64 @@
{{ define "title" }}home{{ end }}
{{ define "user" }}
<div class="padded box mono">
<div class="box-title">
<i class="mdi mdi-chart-arc"></i> stats
</div>
<div class="right">
<span class="pre">artists: {{ printf "%7v" .ArtistCount }}</span><br/>
<span class="pre">albums: {{ printf "%7v" .AlbumCount }}</span><br/>
<span class="pre">tracks: {{ printf "%7v" .TrackCount }}</span>
</div>
</div>
<div class="padded box mono">
<div class="box-title">
<i class="mdi mdi-lastfm"></i> last.fm
</div>
<div class="right">
{{ if .User.IsAdmin }}
<a href="/admin/update_lastfm_api_key">update api key</a><br/>
{{ end }}
{{ if .CurrentLastFMAPIKey }}
<span class="light">current status</span>
{{ if .User.LastFMSession }}
linked
<span class="light">&#124;</span>
<a href="/admin/unlink_lastfm_do">unlink</a><br/>
{{ else }}
<span class="angry">unlinked</span>
<a href="https://www.last.fm/api/auth/?api_key={{ .CurrentLastFMAPIKey }}&cb={{ .RequestRoot }}/admin/link_lastfm_do">link</a><br/>
{{ end }}
{{ else if not .User.IsAdmin }}
<span class="light">api key not set. please ask your admin to set it</span>
{{ end }}
</div>
</div>
<div class="padded box mono">
{{ if .User.IsAdmin }}
{{/* admin panel to manage all users */}}
<div class="box-title">
<i class="mdi mdi-account-multiple"></i> users
</div>
<div class="right">
{{ range $user := .AllUsers }}
<i>{{ $user.Name }}</i>
<span class="light">{{ $user.CreatedAt.Format "jan 02, 2006" }}</span>
<span class="light">&#124;</span>
<a href="/admin/change_password?user={{ $user.Name }}">change password</a>
<span class="light">&#124;</span>
<a href="/admin/delete_user?user={{ $user.Name }}">delete</a><br/>
{{ end }}
<a href="/admin/create_user" class="button">create new</a>
</div>
{{ else }}
{{/* user panel to manage themselves */}}
<div class="box-title">
<i class="mdi mdi-account"></i> your account
</div>
<div class="right">
<a href="/admin/change_own_password" class="button">change password</a>
</div>
{{ end }}
</div>
{{ end }}

View File

@@ -0,0 +1,14 @@
{{ define "title" }}gonic{{ end }}
{{ define "content" }}
<div class="padded box mono">
<div class="box-title">
<i class="mdi mdi-login-variant"></i> login
</div>
<form action="/admin/login_do" method="post">
<input type="text" id="username" name="username" placeholder="username">
<input type="password" id="password" name="password" placeholder="password">
<input type="submit" value="login">
</form>
</div>
{{ end }}

View File

@@ -0,0 +1,18 @@
{{ define "title" }}home{{ end }}
{{ define "user" }}
<div class="padded box mono">
<div class="box-title">
<i class="mdi mdi-key-change"></i> updating last.fm keys
</div>
<div class="right">
<span class="light">current key</span> <i>{{ if .CurrentLastFMAPIKey }}{{ .CurrentLastFMAPIKey }}{{ else }}not set{{ end }}</i><br/>
<span class="light">current secret</span> <i>{{ if .CurrentLastFMAPISecret }}{{ .CurrentLastFMAPISecret }}{{ else }}not set{{ end }}</i>
</div>
<form action="/admin/update_lastfm_api_key_do" method="post">
<input type="text" id="api_key" name="api_key" placeholder="new key">
<input type="text" id="secret" name="secret" placeholder="new secret">
<input type="submit" value="update">
</form>
</div>
{{ end }}

View File

@@ -0,0 +1,12 @@
{{ define "title" }}home{{ end }}
{{ define "content" }}
<div class="side-padded light right mono">
welcome {{ .User.Name }}
&#124;
<a href="/admin/home">home</a>
&#124;
<a href="/admin/logout">logout <i class="mdi mdi-logout-variant"></i></a>
</div>
{{ template "user" . }}
{{ end }}