From 222256cccbeb791168070ba5fe04a3bc1632cb94 Mon Sep 17 00:00:00 2001 From: sentriz Date: Tue, 21 Feb 2023 23:11:54 +0000 Subject: [PATCH] feat(admin): update stylesheet --- go.mod | 6 +- go.sum | 12 +- server/assets/assets.go | 18 -- server/assets/layouts/base.tmpl | 31 -- server/assets/layouts/user.tmpl | 10 - server/assets/pages/change_avatar.tmpl | 26 -- server/assets/pages/change_own_avatar.tmpl | 26 -- server/assets/pages/change_own_password.tmpl | 12 - server/assets/pages/change_own_username.tmpl | 11 - server/assets/pages/change_password.tmpl | 12 - server/assets/pages/change_username.tmpl | 11 - server/assets/pages/delete_user.tmpl | 14 - server/assets/pages/home.tmpl | 280 ------------------ server/assets/pages/login.tmpl | 12 - server/assets/pages/not_found.tmpl | 7 - .../assets/pages/update_lastfm_api_key.tmpl | 19 -- server/assets/partials/head.tmpl | 9 - server/assets/static/main.css | 186 ------------ server/assets/static/main.js | 5 - server/assets/static/reset.css | 135 --------- server/ctrladmin/adminui/adminui.go | 10 + server/ctrladmin/adminui/components.tmpl | 107 +++++++ .../adminui/pages/change_avatar.tmpl | 25 ++ .../adminui/pages/change_password.tmpl | 16 + .../adminui/pages/change_username.tmpl | 15 + .../adminui}/pages/create_user.tmpl | 18 +- .../ctrladmin/adminui/pages/delete_user.tmpl | 15 + server/ctrladmin/adminui/pages/home.tmpl | 226 ++++++++++++++ server/ctrladmin/adminui/pages/login.tmpl | 13 + server/ctrladmin/adminui/pages/not_found.tmpl | 3 + .../adminui/pages/update_lastfm_api_key.tmpl | 21 ++ .../adminui}/static/favicon.ico | Bin .../adminui}/static/gonic.png | Bin .../static/inconsolata-v31-latin-500.woff | Bin 0 -> 20000 bytes .../static/inconsolata-v31-latin-500.woff2 | Bin 0 -> 16488 bytes .../static/inconsolata-v31-latin-600.woff | Bin 0 -> 20004 bytes .../static/inconsolata-v31-latin-600.woff2 | Bin 0 -> 16396 bytes server/ctrladmin/adminui/static/main.js | 3 + server/ctrladmin/adminui/static/style.css | 1 + server/ctrladmin/adminui/style.css | 52 ++++ server/ctrladmin/adminui/tailwind.config.js | 13 + server/ctrladmin/ctrl.go | 78 +++-- server/ctrladmin/handlers.go | 230 +++++++------- server/server.go | 29 +- 44 files changed, 691 insertions(+), 1026 deletions(-) delete mode 100644 server/assets/assets.go delete mode 100644 server/assets/layouts/base.tmpl delete mode 100644 server/assets/layouts/user.tmpl delete mode 100644 server/assets/pages/change_avatar.tmpl delete mode 100644 server/assets/pages/change_own_avatar.tmpl delete mode 100644 server/assets/pages/change_own_password.tmpl delete mode 100644 server/assets/pages/change_own_username.tmpl delete mode 100644 server/assets/pages/change_password.tmpl delete mode 100644 server/assets/pages/change_username.tmpl delete mode 100644 server/assets/pages/delete_user.tmpl delete mode 100644 server/assets/pages/home.tmpl delete mode 100644 server/assets/pages/login.tmpl delete mode 100644 server/assets/pages/not_found.tmpl delete mode 100644 server/assets/pages/update_lastfm_api_key.tmpl delete mode 100644 server/assets/partials/head.tmpl delete mode 100644 server/assets/static/main.css delete mode 100644 server/assets/static/main.js delete mode 100644 server/assets/static/reset.css create mode 100644 server/ctrladmin/adminui/adminui.go create mode 100644 server/ctrladmin/adminui/components.tmpl create mode 100644 server/ctrladmin/adminui/pages/change_avatar.tmpl create mode 100644 server/ctrladmin/adminui/pages/change_password.tmpl create mode 100644 server/ctrladmin/adminui/pages/change_username.tmpl rename server/{assets => ctrladmin/adminui}/pages/create_user.tmpl (55%) create mode 100644 server/ctrladmin/adminui/pages/delete_user.tmpl create mode 100644 server/ctrladmin/adminui/pages/home.tmpl create mode 100644 server/ctrladmin/adminui/pages/login.tmpl create mode 100644 server/ctrladmin/adminui/pages/not_found.tmpl create mode 100644 server/ctrladmin/adminui/pages/update_lastfm_api_key.tmpl rename server/{assets => ctrladmin/adminui}/static/favicon.ico (100%) rename server/{assets => ctrladmin/adminui}/static/gonic.png (100%) create mode 100644 server/ctrladmin/adminui/static/inconsolata-v31-latin-500.woff create mode 100644 server/ctrladmin/adminui/static/inconsolata-v31-latin-500.woff2 create mode 100644 server/ctrladmin/adminui/static/inconsolata-v31-latin-600.woff create mode 100644 server/ctrladmin/adminui/static/inconsolata-v31-latin-600.woff2 create mode 100644 server/ctrladmin/adminui/static/main.js create mode 100644 server/ctrladmin/adminui/static/style.css create mode 100644 server/ctrladmin/adminui/style.css create mode 100644 server/ctrladmin/adminui/tailwind.config.js diff --git a/go.mod b/go.mod index 95bea10..f1d9402 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/dexterlb/mpvipc v0.0.0-20221227161445-38b9935eae9d github.com/disintegration/imaging v1.6.2 github.com/dustin/go-humanize v1.0.1 + github.com/fatih/structs v1.1.0 github.com/fsnotify/fsnotify v1.6.0 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/google/uuid v1.3.0 @@ -26,9 +27,10 @@ require ( github.com/oklog/run v1.1.0 github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c github.com/peterbourgon/ff v1.7.1 + github.com/philippta/go-template v0.0.0-20220911145045-4556aca435e4 github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be github.com/sentriz/gormstore v0.0.0-20220105134332-64e31f7f6981 - golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb + golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2 golang.org/x/net v0.7.0 gopkg.in/gormigrate.v1 v1.6.0 ) @@ -51,7 +53,7 @@ require ( github.com/mailru/easyjson v0.7.7 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect - github.com/mmcdole/goxpp v1.0.0 // indirect + github.com/mmcdole/goxpp v1.1.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect golang.org/x/crypto v0.6.0 // indirect diff --git a/go.sum b/go.sum index 87172e5..7e8a670 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y= github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= +github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= @@ -111,8 +113,8 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mmcdole/gofeed v1.2.0 h1:kuq7tJnDf0pnsDzF820ukuySHxFimAcizpG15gYHIns= github.com/mmcdole/gofeed v1.2.0/go.mod h1:TEyTG4gw4Q5Co+Hgahx/Oi3E0JHLM8BXtWC+mkJtRsw= -github.com/mmcdole/goxpp v1.0.0 h1:/eu75G4jwH/LaugmPVB0FFC8LdKw00UMrpo6N7ym45o= -github.com/mmcdole/goxpp v1.0.0/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8= +github.com/mmcdole/goxpp v1.1.0 h1:WwslZNF7KNAXTFuzRtn/OKZxFLJAAyOA9w82mDz2ZGI= +github.com/mmcdole/goxpp v1.1.0/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -131,6 +133,8 @@ github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwU github.com/pelletier/go-toml v1.6.0/go.mod h1:5N711Q9dKgbdkxHL+MEfF31hpT7l0S0s/t2kKREewys= github.com/peterbourgon/ff v1.7.1 h1:xt1lxTG+Nr2+tFtysY7abFgPoH3Lug8CwYJMOmJRXhk= github.com/peterbourgon/ff v1.7.1/go.mod h1:fYI5YA+3RDqQRExmFbHnBjEeWzh9TrS8rnRpEq7XIg0= +github.com/philippta/go-template v0.0.0-20220911145045-4556aca435e4 h1:SvVjZyjXBOjqjCdMiC9ndyWb709aP5qU4Qbun40GCxA= +github.com/philippta/go-template v0.0.0-20220911145045-4556aca435e4/go.mod h1:Mpa6Hci7lO3vybfdYlWXmH5gEq2vyOmYYjhrlwCTW3w= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ= @@ -149,8 +153,8 @@ golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= -golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb h1:PaBZQdo+iSDyHT053FjUCgZQ/9uqVwPOcl7KSWhKn6w= -golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2 h1:Jvc7gsqn21cJHCmAWx0LiimpP18LZmUxkT5Mp7EZ1mI= +golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.5.0 h1:5JMiNunQeQw++mMOz48/ISeNu3Iweh/JaZU8ZLqHRrI= golang.org/x/image v0.5.0/go.mod h1:FVC7BI/5Ym8R25iw5OLsgshdUBbT1h5jZTpA+mvAdZ4= diff --git a/server/assets/assets.go b/server/assets/assets.go deleted file mode 100644 index fd665c5..0000000 --- a/server/assets/assets.go +++ /dev/null @@ -1,18 +0,0 @@ -//nolint:gochecknoglobals,golint,stylecheck -package assets - -import ( - "embed" -) - -//go:embed layouts -var Layouts embed.FS - -//go:embed pages -var Pages embed.FS - -//go:embed partials -var Partials embed.FS - -//go:embed static -var Static embed.FS diff --git a/server/assets/layouts/base.tmpl b/server/assets/layouts/base.tmpl deleted file mode 100644 index f7478e8..0000000 --- a/server/assets/layouts/base.tmpl +++ /dev/null @@ -1,31 +0,0 @@ -{{ define "layout" }} - - - - - gonic - {{ template "head" }} - - -
- - {{ range $flash := .Flashes }} -
- {{ $flash.Message }} -
- {{ end }} - {{ template "content" . }} -
- {{ .Version }} - senan kelly, 2020 - | - github -
-
- - -{{ end }} diff --git a/server/assets/layouts/user.tmpl b/server/assets/layouts/user.tmpl deleted file mode 100644 index 4ccfc1f..0000000 --- a/server/assets/layouts/user.tmpl +++ /dev/null @@ -1,10 +0,0 @@ -{{ define "content" }} -
- welcome {{ .User.Name }} - | - home - | - logout -
-{{ template "user" . }} -{{ end }} diff --git a/server/assets/pages/change_avatar.tmpl b/server/assets/pages/change_avatar.tmpl deleted file mode 100644 index 2f42a2c..0000000 --- a/server/assets/pages/change_avatar.tmpl +++ /dev/null @@ -1,26 +0,0 @@ -{{ define "user" }} -
-
- changing {{ .SelectedUser.Name }}'s avatar -
- {{ if ne (len .SelectedUser.Avatar) 0 }} -
- -
- {{ end }} -
-
- - -
- {{ if ne (len .SelectedUser.Avatar) 0 }} -

- {{ end }} -
-
-{{ end }} diff --git a/server/assets/pages/change_own_avatar.tmpl b/server/assets/pages/change_own_avatar.tmpl deleted file mode 100644 index 0780334..0000000 --- a/server/assets/pages/change_own_avatar.tmpl +++ /dev/null @@ -1,26 +0,0 @@ -{{ define "user" }} -
-
- changing account avatar -
- {{ if ne (len .SelectedUser.Avatar) 0 }} -
- -
- {{ end }} -
-
- - -
- {{ if ne (len .SelectedUser.Avatar) 0 }} -

- {{ end }} -
-
-{{ end }} diff --git a/server/assets/pages/change_own_password.tmpl b/server/assets/pages/change_own_password.tmpl deleted file mode 100644 index a2fd526..0000000 --- a/server/assets/pages/change_own_password.tmpl +++ /dev/null @@ -1,12 +0,0 @@ -{{ define "user" }} -
-
- changing account password -
-
- - - -
-
-{{ end }} diff --git a/server/assets/pages/change_own_username.tmpl b/server/assets/pages/change_own_username.tmpl deleted file mode 100644 index d505920..0000000 --- a/server/assets/pages/change_own_username.tmpl +++ /dev/null @@ -1,11 +0,0 @@ -{{ define "user" }} -
-
- changing account username -
-
- - -
-
-{{ end }} diff --git a/server/assets/pages/change_password.tmpl b/server/assets/pages/change_password.tmpl deleted file mode 100644 index 122f860..0000000 --- a/server/assets/pages/change_password.tmpl +++ /dev/null @@ -1,12 +0,0 @@ -{{ define "user" }} -
-
- changing {{ .SelectedUser.Name }}'s password -
-
- - - -
-
-{{ end }} diff --git a/server/assets/pages/change_username.tmpl b/server/assets/pages/change_username.tmpl deleted file mode 100644 index 2fd0b54..0000000 --- a/server/assets/pages/change_username.tmpl +++ /dev/null @@ -1,11 +0,0 @@ -{{ define "user" }} -
-
- changing {{ .SelectedUser.Name }}'s username -
-
- - -
-
-{{ end }} diff --git a/server/assets/pages/delete_user.tmpl b/server/assets/pages/delete_user.tmpl deleted file mode 100644 index acf5253..0000000 --- a/server/assets/pages/delete_user.tmpl +++ /dev/null @@ -1,14 +0,0 @@ -{{ define "user" }} -
-
- deleting user {{ .SelectedUser.Name }} -
-
- are you sure?
- their plays, starred, etc. will also be deleted -
-
- -
-
-{{ end }} diff --git a/server/assets/pages/home.tmpl b/server/assets/pages/home.tmpl deleted file mode 100644 index 179190f..0000000 --- a/server/assets/pages/home.tmpl +++ /dev/null @@ -1,280 +0,0 @@ -{{ define "user" }} -
-
- stats -
-
- - - - -
artists: {{ .ArtistCount }}
albums: {{ .AlbumCount }}
tracks: {{ .TrackCount }}
-
-
-
-
- last.fm -
-
-

gonic can scrobble to last.fm for any user (but the admin must set a global api key)

-
-
- {{ if .CurrentLastFMAPIKey }} - current status - {{ if .User.LastFMSession }} - linked
-
- -
- {{ else }} - unlinked
- {{ $cbPath := path "/admin/link_lastfm_do" }} - {{ $cbURL := printf "%s%s" .RequestRoot $cbPath }} - link… - {{ end }} - {{ else }} -

api key not set

- {{ if not .User.IsAdmin }} -

please ask your admin to set it

- {{ end }} - {{ end }} - {{ if .User.IsAdmin }} -

update api key…

- {{ end }} -
-
-
-
- listenbrainz -
-
-

gonic can scrobble to listenbrainz and compatible sites

-
-
- current status - {{ if .User.ListenBrainzToken }} - linked
-
- -
- {{ else }} - unlinked -
- - - -
- {{ end }} -
-
-
- {{ if .User.IsAdmin }} - {{/* admin panel to manage all users */}} -
- users -
-
- {{ range $user := .AllUsers }} - {{ $user.Name }} - {{ $user.CreatedAt | date }} - | - username… - | - password… - | - change avatar… - | - {{ if $user.IsAdmin }} - delete… - {{ else }} - delete… - {{ end }} -
- {{ end }} - create new… -
- {{ else }} - {{/* user panel to manage themselves */}} -
- your account -
- - {{ end }} -
-
-
- recent folders -
-
- {{ if eq (len .RecentFolders) 0 }} - no folders yet - {{ end }} - - - - - - {{ range $folder := .RecentFolders }} - - - - - {{ end }} -
{{ $folder.RightPath }}{{ $folder.ModifiedAt | dateHuman }}
- {{- if and (not .IsScanning) (.User.IsAdmin) -}} - {{- if not .LastScanTime.IsZero -}} -

scanned {{ .LastScanTime | dateHuman }}

- {{ end }} -
- -
-
- -
- {{ end }} - {{- if .IsScanning }}

scan in progress...

{{ end }} -
-
-
-
- transcoding device profiles -
-
-

you can find your device's client name in the gonic logs.

-

some common client names are DSub, Jamstash, Soundwaves, or use * as fallback rule for any client.

-

for more info, see transcode profiles

-
-
- - {{ range $pref := .TranscodePreferences }} - - {{ $formSuffix := kebabcase $pref.Client }} - - - - - - {{ end }} - - - - - - -
{{ $pref.Client }}{{ $pref.Profile }}
-
-
-{{ if .User.IsAdmin }} -
-
- podcasts -
-
-

you can add podcasts rss feeds here

-
-
- - {{ range $pref := .Podcasts }} - - - - - - - - - - - {{ end }} - - - - - -
{{ $pref.Title }}
-
-
-{{ end }} -{{ if .User.IsAdmin }} -
-
- internet_radio_stations -
-
-

you can add and update internet radio stations here

-
-
- - {{ range $pref := .InternetRadioStations }} - - - - - - - - - - {{ end }} - - - - - - - -
-
-
-{{ end }} -
-
- playlists -
-
- {{ if eq (len .Playlists) 0 }} - no playlists yet - {{ end }} - - {{ range $i, $playlist := .Playlists }} - - - - - - - - {{ end }} -
{{ $playlist.Name }}({{ $playlist.TrackCount }} tracks){{ $playlist.CreatedAt | dateHuman }}
-
-
- - -
-
-
-
-{{ end }} diff --git a/server/assets/pages/login.tmpl b/server/assets/pages/login.tmpl deleted file mode 100644 index 3c8b838..0000000 --- a/server/assets/pages/login.tmpl +++ /dev/null @@ -1,12 +0,0 @@ -{{ define "content" }} -
-
- login -
-
- - - -
-
-{{ end }} diff --git a/server/assets/pages/not_found.tmpl b/server/assets/pages/not_found.tmpl deleted file mode 100644 index 2e46801..0000000 --- a/server/assets/pages/not_found.tmpl +++ /dev/null @@ -1,7 +0,0 @@ -{{ define "content" }} -
-
- page not found -
-
-{{ end }} diff --git a/server/assets/pages/update_lastfm_api_key.tmpl b/server/assets/pages/update_lastfm_api_key.tmpl deleted file mode 100644 index b7a16c7..0000000 --- a/server/assets/pages/update_lastfm_api_key.tmpl +++ /dev/null @@ -1,19 +0,0 @@ -{{ define "user" }} -
-
- updating last.fm keys -
-
-

you can get an api key here (note: only the "application name" field is required)

-
-
-

current key {{ default "not set" .CurrentLastFMAPIKey }}

-

current secret {{ default "not set" .CurrentLastFMAPISecret }}

-
-
- - - -
-
-{{ end }} diff --git a/server/assets/partials/head.tmpl b/server/assets/partials/head.tmpl deleted file mode 100644 index e6efdb2..0000000 --- a/server/assets/partials/head.tmpl +++ /dev/null @@ -1,9 +0,0 @@ -{{ define "head" }} - - - - - - - -{{ end }} diff --git a/server/assets/static/main.css b/server/assets/static/main.css deleted file mode 100644 index 072b29f..0000000 --- a/server/assets/static/main.css +++ /dev/null @@ -1,186 +0,0 @@ -:root { - --size: 13px; - --width-body: 750px; - --width-box-description: 510px; - --width-form: 400px; -} - -@media only screen and (max-width: 780px) { - :root { - --size: 11px; - } -} - -*, -body, -button, -div, -i, -input[type], -select, -span, -html { - font-family: monospace; - font-size: var(--size); - color: black; - line-height: 1; -} - -body { - max-width: var(--width-body); - margin: 0 auto; - overflow-y: scroll; -} - -input[type], -select, -button, -textarea { - border-radius: 0; - box-sizing: border-box; - margin: 0; - padding: 0; - border: none; - outline: 1px solid #ccc; - height: var(--size); - vertical-align: bottom; -} - -input[type] { - width: calc(var(--size) * 9); - background-color: white; - cursor: pointer; -} - -input[type="text"], -input[type="password"] { - width: 100%; -} - -input[type="submit"] { - width: fit-content; -} - -form.block { - max-width: var(--width-form); - margin-left: auto; - margin-right: 0; - display: flex; - flex-direction: column; - align-items: flex-end; - text-align: right; -} - -form > * { - width: 100%; -} - -table { - white-space: nowrap; -} - -table td { - padding-left: calc(var(--size) * 0.5); -} - -table td.text-trunc { - text-overflow: ellipsis; - overflow: hidden; - max-width: 0; -} - -p { - color: inherit; -} - -a, -a:visited { - color: #0064c1; - text-decoration: none; -} - -a:hover { - text-decoration: underline; -} - -#content > * { - margin: calc(var(--size) * 1.5); -} - -#header { - border-bottom: 2px solid #0000001a; -} - -#header img { - width: 60%; - display: block; - margin: 0 auto; - height: auto; -} - -.flash-warning { - background-color: #fd1b1b1c; - border-right: 2px solid #fd1b1b1c; - border-bottom: 2px solid #fd1b1b1c; -} - -.flash-normal { - background-color: #15ff5424; - border-right: 2px solid #15ff5424; - border-bottom: 2px solid #15ff5424; -} - -.box { - background-color: #00000005; - border-right: 2px solid #0000000c; - border-bottom: 2px solid #0000000c; -} - -.box-description { - max-width: var(--width-box-description); -} - -@media only screen and (max-width: 780px) { - .no-small { - display: none; - } -} - -.text-right { - text-align: right; -} - -.text-light { - color: #00000082; -} - -.text-emp { - font-style: italic; - color: #444; -} - -.text-full { - width: 100%; -} - -.block-right > * { - margin-left: auto; -} - -.padded { - padding: var(--size); -} - -.padded-side { - padding: 0 var(--size); -} - -.angry { - background-color: #f4433669; -} - -.avatar-preview { - width: 64px; - height: 64px; - object-fit: cover; -} diff --git a/server/assets/static/main.js b/server/assets/static/main.js deleted file mode 100644 index cd49ca3..0000000 --- a/server/assets/static/main.js +++ /dev/null @@ -1,5 +0,0 @@ -for (const form of document.querySelectorAll("form.file-upload") || []) { - const input = form.querySelector("input[type=file]"); - if (!input) continue; - input.onchange = (e) => form.submit(); -} diff --git a/server/assets/static/reset.css b/server/assets/static/reset.css deleted file mode 100644 index 216194d..0000000 --- a/server/assets/static/reset.css +++ /dev/null @@ -1,135 +0,0 @@ -/* http://meyerweb.com/eric/tools/css/reset/ - v2.0 | 20110126 - License: none (public domain) -*/ - -html, -body, -div, -span, -applet, -object, -iframe, -h1, -h2, -h3, -h4, -h5, -h6, -p, -blockquote, -pre, -a, -abbr, -acronym, -address, -big, -cite, -code, -del, -dfn, -em, -img, -ins, -kbd, -q, -s, -samp, -small, -strike, -strong, -sub, -sup, -tt, -var, -b, -u, -i, -center, -dl, -dt, -dd, -ol, -ul, -li, -fieldset, -form, -label, -legend, -table, -caption, -tbody, -tfoot, -thead, -tr, -th, -td, -article, -aside, -canvas, -details, -embed, -figure, -figcaption, -footer, -header, -hgroup, -menu, -nav, -output, -ruby, -section, -summary, -time, -mark, -audio, -video { - margin: 0; - padding: 0; - border: 0; - font-size: 100%; - font: inherit; - vertical-align: baseline; -} - -/* html5 display-role reset for older browsers */ -article, -aside, -details, -figcaption, -figure, -footer, -header, -hgroup, -menu, -nav, -section { - display: block; -} - -body { - line-height: 1; -} - -ol, -ul { - list-style: none; -} - -blockquote, -q { - quotes: none; -} - -blockquote:before, -blockquote:after, -q:before, -q:after { - content: ""; - content: none; -} - -table { - border-collapse: collapse; - border-spacing: 0; -} diff --git a/server/ctrladmin/adminui/adminui.go b/server/ctrladmin/adminui/adminui.go new file mode 100644 index 0000000..cb1f0a0 --- /dev/null +++ b/server/ctrladmin/adminui/adminui.go @@ -0,0 +1,10 @@ +package adminui + +import "embed" + +//go:embed components.tmpl pages/*.tmpl +var TemplatesFS embed.FS + +//go:generate npx tailwindcss@v3.2.4 --config tailwind.config.js --input style.css --output static/style.css --minify +//go:embed static/* +var StaticFS embed.FS diff --git a/server/ctrladmin/adminui/components.tmpl b/server/ctrladmin/adminui/components.tmpl new file mode 100644 index 0000000..7bdaae3 --- /dev/null +++ b/server/ctrladmin/adminui/components.tmpl @@ -0,0 +1,107 @@ +{{ define "block" }} +
+
+ {{ component "icon" .Props.Icon }}{{ end }} + {{ .Props.Name }} +
+
+ {{ if .Props.Desc }} +
{{ .Props.Desc }}
+ {{ end }} +
+ {{ slot }} +
+
+{{ end }} + +{{ define "link" }} + {{ slot }} +{{ end }} + +{{ define "ext_link" }} + {{ slot }} +{{ end }} + +{{ define "layout_user" }} +
+ welcome {{ .User.Name }} + | + {{ component "link" (props . "To" (path "/admin/home")) }}home{{ end }} + | + {{ component "link" (props . "To" (path "/admin/logout")) }}logout{{ end }} +
+ {{ slot }} +{{ end }} + +{{ define "layout" }} + + + + + gonic + + + + + + + +
+
+ + + +
+ {{ range $flash := .Flashes }} + {{ $colour := "bg-green-200" }} + {{ if eq $flash.Type "warning" }}{{ $colour = "bg-red-200" }}{{ end }} +
+ {{ component "icon" "circle-info" }}{{ end }} + {{ $flash.Message }} +
+ {{ end }} + {{ slot }} +
+ {{ .Version }} + senan kelly, 2020 + | + {{ component "ext_link" (props . "To" "https://github.com/sentriz/gonic") }}github{{ end }} +
+
+ + +{{ end }} + + +{{/* from https://github.com/FortAwesome/Font-Awesome/tree/6.x/svgs/brand */}} +{{/* TODO: see if we can dynamically render templates based on a variable instead of this */}} + +{{ define "icon" }} + {{ if (eq . "lastfm") }} + + {{ else if (eq . "brain" ) }} + + {{ else if (eq . "chart-pie" ) }} + + {{ else if (eq . "brain" ) }} + + {{ else if (eq . "users" ) }} + + {{ else if (eq . "user" ) }} + + {{ else if (eq . "folder-tree" ) }} + + {{ else if (eq . "music" ) }} + + {{ else if (eq . "rss" ) }} + + {{ else if (eq . "radio" ) }} + + {{ else if (eq . "list" ) }} + + {{ else if (eq . "circle-info" ) }} + + {{ else if (eq . "key" ) }} + + {{ end }} +{{ end }} diff --git a/server/ctrladmin/adminui/pages/change_avatar.tmpl b/server/ctrladmin/adminui/pages/change_avatar.tmpl new file mode 100644 index 0000000..d661b33 --- /dev/null +++ b/server/ctrladmin/adminui/pages/change_avatar.tmpl @@ -0,0 +1,25 @@ +{{ component "layout" . }} +{{ component "layout_user" . }} + +{{ component "block" (props . + "Icon" "user" + "Name" (printf "changing %s's avatar" .SelectedUser.Name) +) }} +
+ {{ if ne (len .SelectedUser.Avatar) 0 }} + +
+ +
+ {{ end }} +
+
+ + +
+
+
+{{ end }} + +{{ end }} +{{ end }} diff --git a/server/ctrladmin/adminui/pages/change_password.tmpl b/server/ctrladmin/adminui/pages/change_password.tmpl new file mode 100644 index 0000000..91d035a --- /dev/null +++ b/server/ctrladmin/adminui/pages/change_password.tmpl @@ -0,0 +1,16 @@ +{{ component "layout" . }} +{{ component "layout_user" . }} + +{{ component "block" (props . + "Icon" "user" + "Name" (printf "changing %s's password" .SelectedUser.Name) +) }} +
+ + + +
+{{ end }} + +{{ end }} +{{ end }} diff --git a/server/ctrladmin/adminui/pages/change_username.tmpl b/server/ctrladmin/adminui/pages/change_username.tmpl new file mode 100644 index 0000000..1970a9f --- /dev/null +++ b/server/ctrladmin/adminui/pages/change_username.tmpl @@ -0,0 +1,15 @@ +{{ component "layout" . }} +{{ component "layout_user" . }} + +{{ component "block" (props . + "Icon" "user" + "Name" (printf "changing %s's username" .SelectedUser.Name) +) }} +
+ + +
+{{ end }} + +{{ end }} +{{ end }} diff --git a/server/assets/pages/create_user.tmpl b/server/ctrladmin/adminui/pages/create_user.tmpl similarity index 55% rename from server/assets/pages/create_user.tmpl rename to server/ctrladmin/adminui/pages/create_user.tmpl index 50232a1..a97b674 100644 --- a/server/assets/pages/create_user.tmpl +++ b/server/ctrladmin/adminui/pages/create_user.tmpl @@ -1,13 +1,17 @@ -{{ define "user" }} -
-
- creating new user -
-
+{{ component "layout" . }} +{{ component "layout_user" . }} + +{{ component "block" (props . + "Icon" "user" + "Name" "creating new user" +) }} +
-
+{{ end }} + +{{ end }} {{ end }} diff --git a/server/ctrladmin/adminui/pages/delete_user.tmpl b/server/ctrladmin/adminui/pages/delete_user.tmpl new file mode 100644 index 0000000..df670e4 --- /dev/null +++ b/server/ctrladmin/adminui/pages/delete_user.tmpl @@ -0,0 +1,15 @@ +{{ component "layout" . }} +{{ component "layout_user" . }} + +{{ component "block" (props . + "Icon" "user" + "Name" (printf "deleting user %s" .SelectedUser.Name) + "Desc" "are you sure? this will also delete their plays, playlists, starred, rated, etc." +) }} +
+ +
+{{ end }} + +{{ end }} +{{ end }} diff --git a/server/ctrladmin/adminui/pages/home.tmpl b/server/ctrladmin/adminui/pages/home.tmpl new file mode 100644 index 0000000..4c4125d --- /dev/null +++ b/server/ctrladmin/adminui/pages/home.tmpl @@ -0,0 +1,226 @@ +{{ component "layout" . }} +{{ component "layout_user" . }} + +{{ component "block" (props . + "Icon" "chart-pie" + "Name" "stats" + "Desc" "total items found in all watched folders" +) }} +
+
artists
+
{{ .ArtistCount }}
+
albums
+
{{ .AlbumCount }}
+
tracks
+
{{ .TrackCount }}
+
+{{ end }} + +{{ component "block" (props . + "Icon" "users" + "Name" "user management" + "Desc" "manage user accounts for subsonic api and web interface access" +) }} +
+ {{ range $user := .AllUsers }} +
{{ $user.Name }}
+
{{ $user.CreatedAt | date }}
+ {{ component "link" (props . "To" (printf "/admin/change_username?user=%s" $user.Name | path)) }}username{{ end }} + {{ component "link" (props . "To" (printf "/admin/change_password?user=%s" $user.Name | path)) }}password{{ end }} + {{ component "link" (props . "To" (printf "/admin/change_avatar?user=%s" $user.Name | path)) }}avatar{{ end }} + {{ if $user.IsAdmin }} +
delete
+ {{ else }} + {{ component "link" (props . "To" (printf "/admin/delete_user?user=%s" $user.Name | path)) }}delete{{ end }} + {{ end }} + {{ end }} + {{ if .User.IsAdmin }} +
{{ component "link" (props . "To" (path "/admin/create_user")) }}create new{{ end }}
+ {{ end }} +
+{{ end }} + +{{ component "block" (props . + "Icon" "folder-tree" + "Name" "recent folders" +) }} +
+ {{ if eq (len .RecentFolders) 0 }} +
no folders yet
+ {{ end }} + {{ range $folder := .RecentFolders }} +
{{ $folder.RightPath }}
+
{{ $folder.ModifiedAt | dateHuman }}
+ {{ end }} + {{ if and (not .IsScanning) (.User.IsAdmin) }} + {{ if not .LastScanTime.IsZero }} +

scanned {{ .LastScanTime | dateHuman }}

+ {{ end }} +
+ +
+ {{ end }} + {{ if .IsScanning }}

scan in progress...

{{ end }} +
+{{ end }} + +{{ component "block" (props . + "Icon" "music" + "Name" "transcoding device profiles" + "Desc" "you can find your device's client name in the gonic logs. some common client names are DSub, Jamstash, Soundwaves, or use * as fallback rule for any client. see the \"transcoding profiles\" page on the wiki for more info" +) }} +
+ {{ range $pref := .TranscodePreferences }} + {{ $formSuffix := kebabcase $pref.Client }} +
{{ $pref.Client }}
+
{{ $pref.Profile }}
+
+ +
+ {{ end }} +
+ + + +
+
+{{ end }} + +{{ component "block" (props . + "Icon" "lastfm" + "Name" "last.fm" + "Desc" "scrobble to last.fm on a per user basis (the admin must set a global api key first)" +) }} +
+ {{ if .CurrentLastFMAPIKey }} + {{ if .User.LastFMSession }} +

current status linked

+
+ +
+ {{ else }} +

current status unlinked

+ {{ $cbPath := path "/admin/link_lastfm_do" }} + {{ $cbURL := printf "%s%s" .RequestRoot $cbPath }} +
{{ component "link" (props . "To" (printf "https://www.last.fm/api/auth/?api_key=%s&cb=%s" .CurrentLastFMAPIKey $cbURL)) }}link{{ end }}
+ {{ end }} + {{ else }} +

api key not set

+ {{ if not .User.IsAdmin }} +

please ask your admin to set it

+ {{ end }} + {{ end }} + {{ if .User.IsAdmin }} +

{{ component "link" (props . "To" (path "/admin/update_lastfm_api_key" )) }}update api key{{ end }}

+ {{ end }} +
+{{ end }} + +{{ component "block" (props . + "Icon" "brain" + "Name" "listenbrainz" + "Desc" "scrobble to listenbrainz and compatible sites on a per user basis" +) }} +
+ {{ if .User.ListenBrainzToken }} +

current status linked

+
+ +
+ {{ else }} +

current status unlinked

+
+ + + +
+ {{ end }} +
+{{ end }} + +{{ if .User.IsAdmin }} +{{ component "block" (props . + "Icon" "rss" + "Name" "podcasts" + "Desc" "you can add podcasts rss feeds here" +) }} +
+ {{ range $pref := .Podcasts }} +
{{ $pref.Title }}
+
+ +
+ +
+ +
+ {{ end }} +
+ + +
+
+{{ end }} +{{ end }} + +{{ if .User.IsAdmin }} +{{ component "block" (props . + "Icon" "rss" + "Name" "internet radio stations" + "Desc" "you can add and update internet radio stations here" +) }} +
+ {{ range $pref := .InternetRadioStations }} +
+ + + + +
+
+ +
+ {{ end }} +
+ + + + +
+
+{{ end }} +{{ end }} + +{{ component "block" (props . + "Icon" "list" + "Name" "playlists" + "Desc" "choose a local .m3u8 file containing paths to music files that gonic has scanned. paths should be absolute, prefixed by the music-path option that you started gonic with. a playlist will be created from the file and available to subsonic clients" +) }} + {{ if eq (len .Playlists) 0 }} + no playlists yet + {{ end }} +
+ {{ range $i, $playlist := .Playlists }} +
{{ $playlist.Name }}
+
({{ $playlist.TrackCount }} tracks)
+ +
+ +
+ {{ end }} +
+ + +
+
+{{ end }} + +{{ end }} +{{ end }} diff --git a/server/ctrladmin/adminui/pages/login.tmpl b/server/ctrladmin/adminui/pages/login.tmpl new file mode 100644 index 0000000..1ad9253 --- /dev/null +++ b/server/ctrladmin/adminui/pages/login.tmpl @@ -0,0 +1,13 @@ +{{ component "layout" . }} +{{ component "block" (props . + "Icon" "user" + "Name" "login" + "Desc" "if you are logging in as an admin, the default credentials can be found in the readme" +) }} +
+ + + +
+{{ end }} +{{ end }} diff --git a/server/ctrladmin/adminui/pages/not_found.tmpl b/server/ctrladmin/adminui/pages/not_found.tmpl new file mode 100644 index 0000000..fff88dd --- /dev/null +++ b/server/ctrladmin/adminui/pages/not_found.tmpl @@ -0,0 +1,3 @@ +{{ component "layout" . }} +

page not found

+{{ end }} diff --git a/server/ctrladmin/adminui/pages/update_lastfm_api_key.tmpl b/server/ctrladmin/adminui/pages/update_lastfm_api_key.tmpl new file mode 100644 index 0000000..cdd7d17 --- /dev/null +++ b/server/ctrladmin/adminui/pages/update_lastfm_api_key.tmpl @@ -0,0 +1,21 @@ +{{ component "layout" . }} +{{ component "layout_user" . }} + +{{ component "block" (props . + "Icon" "user" + "Name" "update last.fm api keys" + "Desc" "you can get an api key from last.fm here here. note, only the application name field is required" +) }} +
+

current key {{ default "not set" .CurrentLastFMAPIKey }}

+

current secret {{ default "not set" .CurrentLastFMAPISecret }}

+
+ + + +
+
+{{ end }} + +{{ end }} +{{ end }} diff --git a/server/assets/static/favicon.ico b/server/ctrladmin/adminui/static/favicon.ico similarity index 100% rename from server/assets/static/favicon.ico rename to server/ctrladmin/adminui/static/favicon.ico diff --git a/server/assets/static/gonic.png b/server/ctrladmin/adminui/static/gonic.png similarity index 100% rename from server/assets/static/gonic.png rename to server/ctrladmin/adminui/static/gonic.png diff --git a/server/ctrladmin/adminui/static/inconsolata-v31-latin-500.woff b/server/ctrladmin/adminui/static/inconsolata-v31-latin-500.woff new file mode 100644 index 0000000000000000000000000000000000000000..3221f5fcdd0d1c7b6a810b38416f84411bf9a815 GIT binary patch literal 20000 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 S{dGcGLxiLw**7wfvu6NIiTH2; literal 0 HcmV?d00001 diff --git a/server/ctrladmin/adminui/static/inconsolata-v31-latin-500.woff2 b/server/ctrladmin/adminui/static/inconsolata-v31-latin-500.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..26b61e9d5d8c7241aa509e2ec7a6851f6708aa99 GIT binary patch literal 16488 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 OLxiLw**7wfvu6N^tJ!`4 literal 0 HcmV?d00001 diff --git a/server/ctrladmin/adminui/static/inconsolata-v31-latin-600.woff2 b/server/ctrladmin/adminui/static/inconsolata-v31-latin-600.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..7e7f92719deedd0f8e286704ae1a8b46399a8141 GIT binary patch literal 16396 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 e.target.form.submit(); +} diff --git a/server/ctrladmin/adminui/static/style.css b/server/ctrladmin/adminui/static/style.css new file mode 100644 index 0000000..46d6667 --- /dev/null +++ b/server/ctrladmin/adminui/static/style.css @@ -0,0 +1 @@ +/*! tailwindcss v3.2.4 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:Inconsolata,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}*,::backdrop,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }form,input,select{all:unset;-webkit-appearance:none;-moz-appearance:none;appearance:none;display:block}a{text-decoration:none}@font-face{font-family:Inconsolata;font-style:normal;font-weight:500;src:local(""),url(/admin/static/inconsolata-v31-latin-500.woff2) format("woff2"),url(/admin/static/inconsolata-v31-latin-500.woff) format("woff")}@font-face{font-family:Inconsolata;font-style:normal;font-weight:600;src:local(""),url(/admin/static/inconsolata-v31-latin-600.woff2) format("woff2"),url(/admin/static/inconsolata-v31-latin-600.woff) format("woff")}.container{width:100%}@media (min-width:100%){.container{max-width:100%}}@media (min-width:870px){.container{max-width:870px}}.pointer-events-auto{pointer-events:auto}.absolute{position:absolute}.relative{position:relative}.col-span-3{grid-column:span 3/span 3}.col-span-full{grid-column:1/-1}.col-span-2{grid-column:span 2/span 2}.col-auto{grid-column:auto}.my-1{margin-top:.25rem;margin-bottom:.25rem}.mx-auto{margin-left:auto;margin-right:auto}.mt-3{margin-top:.75rem}.ml-auto{margin-left:auto}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.grid{display:grid}.contents{display:contents}.hidden{display:none}.aspect-square{aspect-ratio:1/1}.h-\[8rem\]{height:8rem}.w-4{width:1rem}.w-\[400px\]{width:400px}.w-full{width:100%}.w-5{width:1.25rem}.w-\[8rem\]{width:8rem}.min-w-min{min-width:-moz-min-content;min-width:min-content}.max-w-\[700px\]{max-width:700px}.grid-cols-\[auto_min-content\]{grid-template-columns:auto min-content}.grid-cols-\[repeat\(3\2c auto\)_max-content\]{grid-template-columns:repeat(3,auto) max-content}.grid-cols-\[1fr\2c auto\]{grid-template-columns:1fr auto}.grid-cols-\[1fr_1fr_auto\]{grid-template-columns:1fr 1fr auto}.grid-cols-\[auto_auto_min-content\]{grid-template-columns:auto auto min-content}.grid-cols-\[1fr_1fr_min-content_min-content\]{grid-template-columns:1fr 1fr min-content min-content}.grid-cols-\[auto_1fr_auto\]{grid-template-columns:auto 1fr auto}.flex-col{flex-direction:column}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-items-end{justify-items:end}.gap-2{gap:.5rem}.gap-x-3{-moz-column-gap:.75rem;column-gap:.75rem}.gap-x-5{-moz-column-gap:1.25rem;column-gap:1.25rem}.gap-y-2{row-gap:.5rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.whitespace-nowrap{white-space:nowrap}.border-b-2{border-bottom-width:2px}.border-r-2{border-right-width:2px}.border-gray-300\/80{border-color:#d1d5dbcc}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity))}.bg-gray-900\/30{background-color:#1118274d}.bg-green-200{--tw-bg-opacity:1;background-color:rgb(187 247 208/var(--tw-bg-opacity))}.bg-red-200{--tw-bg-opacity:1;background-color:rgb(254 202 202/var(--tw-bg-opacity))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity))}.fill-current{fill:currentColor}.object-cover{-o-object-fit:cover;object-fit:cover}.p-4{padding:1rem}.p-5{padding:1.25rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:Inconsolata,monospace}.text-base{font-size:1rem;line-height:1.5rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.italic{font-style:italic}.leading-4{line-height:1rem}.text-gray-500\/80{color:#6b7280cc}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity))}.opacity-0{opacity:0}.shadow-sm{--tw-shadow:0 1px 2px 0 #0000000d;--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}a{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity))}input[type],select{box-sizing:border-box;height:1.5rem;width:100%;min-width:3rem;cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;border-width:0;--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity));padding-left:.5rem;padding-right:.5rem;line-height:1.5;--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity));--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-style:solid;outline-width:1px;outline-color:#9ca3af80}@media (min-width:870px){input[type],select{min-width:8rem}}input[type=button],input[type=submit]{width:6rem;text-align:center;font-weight:700}@media (min-width:870px){input[type=button],input[type=submit]{width:8rem}}.ellipsis{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}@media (min-width:870px){.md\:col-auto{grid-column:auto}.md\:col-span-2{grid-column:span 2/span 2}.md\:col-start-2{grid-column-start:2}.md\:block{display:block}.md\:inline{display:inline}.md\:contents{display:contents}.md\:grid-cols-\[auto_repeat\(5\2c min-content\)\]{grid-template-columns:auto repeat(5,min-content)}.md\:grid-cols-\[5fr_3fr_auto_auto\]{grid-template-columns:5fr 3fr auto auto}.md\:grid-cols-\[1fr_1fr_1fr_auto_auto\]{grid-template-columns:1fr 1fr 1fr auto auto}.md\:grid-cols-\[auto_repeat\(3\2c min-content\)\]{grid-template-columns:auto repeat(3,min-content)}.md\:flex-row{flex-direction:row}} \ No newline at end of file diff --git a/server/ctrladmin/adminui/style.css b/server/ctrladmin/adminui/style.css new file mode 100644 index 0000000..b64b7ed --- /dev/null +++ b/server/ctrladmin/adminui/style.css @@ -0,0 +1,52 @@ +@tailwind base; + +form, +input, +select { + all: unset; + appearance: none; + display: block; +} + +a { + text-decoration: none; +} + +@font-face { + font-family: "Inconsolata"; + font-style: normal; + font-weight: 500; + src: local(""), + url("/admin/static/inconsolata-v31-latin-500.woff2") format("woff2"), + url("/admin/static/inconsolata-v31-latin-500.woff") format("woff"); +} + +@font-face { + font-family: "Inconsolata"; + font-style: normal; + font-weight: 600; + src: local(""), + url("/admin/static/inconsolata-v31-latin-600.woff2") format("woff2"), + url("/admin/static/inconsolata-v31-latin-600.woff") format("woff"); +} + +@tailwind components; +@tailwind utilities; + +a { + @apply text-blue-500; +} + +input[type], +select { + @apply h-6 px-2 leading-[1.5] w-full min-w-[3rem] md:min-w-[8rem] box-border bg-white text-gray-600 shadow-none border-0 outline outline-1 outline-gray-400/50 cursor-pointer overflow-hidden whitespace-nowrap text-ellipsis; +} + +input[type="button"], +input[type="submit"] { + @apply text-center w-[6rem] md:w-[8rem] font-bold; +} + +.ellipsis { + @apply max-w-full overflow-hidden text-ellipsis whitespace-nowrap; +} diff --git a/server/ctrladmin/adminui/tailwind.config.js b/server/ctrladmin/adminui/tailwind.config.js new file mode 100644 index 0000000..da85aee --- /dev/null +++ b/server/ctrladmin/adminui/tailwind.config.js @@ -0,0 +1,13 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ["*.tmpl", "**/*.tmpl"], + theme: { + screens: { + sm: "100%", + md: `870px`, + }, + fontFamily: { + mono: ["Inconsolata", "monospace"], + }, + }, +}; diff --git a/server/ctrladmin/ctrl.go b/server/ctrladmin/ctrl.go index fb81861..e1b7998 100644 --- a/server/ctrladmin/ctrl.go +++ b/server/ctrladmin/ctrl.go @@ -4,27 +4,27 @@ package ctrladmin import ( "encoding/base64" "encoding/gob" + "encoding/json" "errors" "fmt" - "html/template" - "io/fs" "log" "net/http" "net/url" - "path/filepath" "strings" "time" "github.com/Masterminds/sprig" "github.com/dustin/go-humanize" + "github.com/fatih/structs" "github.com/gorilla/sessions" "github.com/oxtoacart/bpool" + "github.com/philippta/go-template/html/template" "github.com/sentriz/gormstore" "go.senan.xyz/gonic" "go.senan.xyz/gonic/db" "go.senan.xyz/gonic/podcasts" - "go.senan.xyz/gonic/server/assets" + "go.senan.xyz/gonic/server/ctrladmin/adminui" "go.senan.xyz/gonic/server/ctrlbase" ) @@ -37,6 +37,10 @@ const ( func funcMap() template.FuncMap { return template.FuncMap{ + "str": func(in any) string { + v, _ := json.Marshal(in) + return string(v) + }, "noCache": func(in string) string { parsed, _ := url.Parse(in) params := parsed.Query() @@ -49,53 +53,49 @@ func funcMap() template.FuncMap { }, "dateHuman": humanize.Time, "base64": base64.StdEncoding.EncodeToString, + "props": func(parent any, values ...any) map[string]any { + if len(values)%2 != 0 { + panic("uneven number of key/value pairs") + } + props := map[string]any{} + for i := 0; i < len(values); i += 2 { + k, v := fmt.Sprint(values[i]), values[i+1] + props[k] = v + } + merged := map[string]any{} + if structs.IsStruct(parent) { + merged = structs.Map(parent) + } + merged["Props"] = props + return merged + }, } } type Controller struct { *ctrlbase.Controller - buffPool *bpool.BufferPool - templates map[string]*template.Template - sessDB *gormstore.Store - Podcasts *podcasts.Podcasts + buffPool *bpool.BufferPool + template *template.Template + sessDB *gormstore.Store + Podcasts *podcasts.Podcasts } func New(b *ctrlbase.Controller, sessDB *gormstore.Store, podcasts *podcasts.Podcasts) (*Controller, error) { - tmpl := template. + tmpl, err := template. New("layout"). - Funcs(sprig.FuncMap()). + Funcs(template.FuncMap(sprig.FuncMap())). Funcs(funcMap()). // static Funcs(template.FuncMap{ // from base "path": b.Path, - }) - - var err error - tmpl, err = tmpl.ParseFS(assets.Partials, "**/*.tmpl") + }). + ParseFS(adminui.TemplatesFS, "*.tmpl", "**/*.tmpl") if err != nil { - return nil, fmt.Errorf("extend partials: %w", err) + return nil, fmt.Errorf("build template: %w", err) } - tmpl, err = tmpl.ParseFS(assets.Layouts, "**/*.tmpl") - if err != nil { - return nil, fmt.Errorf("extend layouts: %w", err) - } - - pagePaths, err := fs.Glob(assets.Pages, "**/*.tmpl") - if err != nil { - return nil, fmt.Errorf("parse pages: %w", err) - } - pages := map[string]*template.Template{} - for _, pagePath := range pagePaths { - pageBytes, _ := assets.Pages.ReadFile(pagePath) - page, _ := tmpl.Clone() - page, _ = page.Parse(string(pageBytes)) - pageName := filepath.Base(pagePath) - pages[pageName] = page - } - return &Controller{ Controller: b, buffPool: bpool.NewBufferPool(64), - templates: pages, + template: tmpl, sessDB: sessDB, Podcasts: podcasts, }, nil @@ -196,12 +196,7 @@ func (c *Controller) H(h handlerAdmin) http.Handler { 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 { + if err := c.template.ExecuteTemplate(buff, resp.template, resp.data); err != nil { http.Error(w, fmt.Sprintf("executing template: %v", err), 500) return } @@ -236,8 +231,9 @@ type Flash struct { Type FlashType } -//nolint:gochecknoinits // for now I think it's nice that our types and their // gob registrations are next to each other, in case there's more added later) +// +//nolint:gochecknoinits // for now I think it's nice that our types and their func init() { gob.Register(&Flash{}) } diff --git a/server/ctrladmin/handlers.go b/server/ctrladmin/handlers.go index c357896..06ac907 100644 --- a/server/ctrladmin/handlers.go +++ b/server/ctrladmin/handlers.go @@ -1,3 +1,4 @@ +//nolint:goerr113 package ctrladmin import ( @@ -41,6 +42,8 @@ func (c *Controller) ServeLogin(r *http.Request) *Response { } func (c *Controller) ServeHome(r *http.Request) *Response { + user := r.Context().Value(CtxUser).(*db.User) + data := &templateData{} // stats box c.DB.Model(&db.Artist{}).Count(&data.ArtistCount) @@ -50,8 +53,14 @@ func (c *Controller) ServeHome(r *http.Request) *Response { data.RequestRoot = c.BaseURL(r) data.CurrentLastFMAPIKey, _ = c.DB.GetSetting("lastfm_api_key") data.DefaultListenBrainzURL = listenbrainz.BaseURL + // users box - c.DB.Find(&data.AllUsers) + allUsersQ := c.DB.DB + if !user.IsAdmin { + allUsersQ = allUsersQ.Where("name=?", user.Name) + } + allUsersQ.Find(&data.AllUsers) + // recent folders box c.DB. Where("tag_artist_id IS NOT NULL"). @@ -64,8 +73,6 @@ func (c *Controller) ServeHome(r *http.Request) *Response { data.LastScanTime = time.Unix(i, 0) } - user := r.Context().Value(CtxUser).(*db.User) - // playlists box c.DB. Where("user_id=?", user.ID). @@ -91,74 +98,6 @@ func (c *Controller) ServeHome(r *http.Request) *Response { } } -func (c *Controller) ServeChangeOwnUsername(r *http.Request) *Response { - return &Response{template: "change_own_username.tmpl"} -} - -func (c *Controller) ServeChangeOwnUsernameDo(r *http.Request) *Response { - username := r.FormValue("username") - if err := validateUsername(username); err != nil { - return &Response{ - redirect: r.Referer(), - flashW: []string{err.Error()}, - } - } - user := r.Context().Value(CtxUser).(*db.User) - user.Name = username - c.DB.Save(user) - return &Response{redirect: "/admin/home"} -} - -func (c *Controller) ServeChangeOwnPassword(r *http.Request) *Response { - return &Response{template: "change_own_password.tmpl"} -} - -func (c *Controller) ServeChangeOwnPasswordDo(r *http.Request) *Response { - passwordOne := r.FormValue("password_one") - passwordTwo := r.FormValue("password_two") - if err := validatePasswords(passwordOne, passwordTwo); err != nil { - return &Response{ - redirect: r.Referer(), - flashW: []string{err.Error()}, - } - } - user := r.Context().Value(CtxUser).(*db.User) - user.Password = passwordOne - c.DB.Save(user) - return &Response{redirect: "/admin/home"} -} - -func (c *Controller) ServeChangeOwnAvatar(r *http.Request) *Response { - data := &templateData{} - user := r.Context().Value(CtxUser).(*db.User) - data.SelectedUser = user - return &Response{ - template: "change_own_avatar.tmpl", - data: data, - } -} - -func (c *Controller) ServeChangeOwnAvatarDo(r *http.Request) *Response { - user := r.Context().Value(CtxUser).(*db.User) - avatar, err := getAvatarFile(r) - if err != nil { - return &Response{ - redirect: r.Referer(), - flashW: []string{err.Error()}, - } - } - user.Avatar = avatar - c.DB.Save(user) - return &Response{redirect: "/admin/home"} -} - -func (c *Controller) ServeDeleteOwnAvatarDo(r *http.Request) *Response { - user := r.Context().Value(CtxUser).(*db.User) - user.Avatar = nil - c.DB.Save(user) - return &Response{redirect: "/admin/home"} -} - func (c *Controller) ServeLinkLastFMDo(r *http.Request) *Response { token := r.URL.Query().Get("token") if token == "" { @@ -166,11 +105,11 @@ func (c *Controller) ServeLinkLastFMDo(r *http.Request) *Response { } apiKey, err := c.DB.GetSetting("lastfm_api_key") if err != nil { - return &Response{code: 500, err: fmt.Sprintf("couldn't get api key: %v", err)} + return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("couldn't get api key: %v", err)}} } secret, err := c.DB.GetSetting("lastfm_secret") if err != nil { - return &Response{code: 500, err: fmt.Sprintf("couldn't get secret: %v", err)} + return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("couldn't get secret: %v", err)}} } sessionKey, err := lastfm.GetSession(apiKey, secret, token) if err != nil { @@ -181,14 +120,18 @@ func (c *Controller) ServeLinkLastFMDo(r *http.Request) *Response { } user := r.Context().Value(CtxUser).(*db.User) user.LastFMSession = sessionKey - c.DB.Save(&user) + if err := c.DB.Save(user).Error; err != nil { + return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("save user: %v", err)}} + } return &Response{redirect: "/admin/home"} } func (c *Controller) ServeUnlinkLastFMDo(r *http.Request) *Response { user := r.Context().Value(CtxUser).(*db.User) user.LastFMSession = "" - c.DB.Save(&user) + if err := c.DB.Save(user).Error; err != nil { + return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("save user: %v", err)}} + } return &Response{redirect: "/admin/home"} } @@ -204,7 +147,9 @@ func (c *Controller) ServeLinkListenBrainzDo(r *http.Request) *Response { user := r.Context().Value(CtxUser).(*db.User) user.ListenBrainzURL = url user.ListenBrainzToken = token - c.DB.Save(&user) + if err := c.DB.Save(user).Error; err != nil { + return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("save user: %v", err)}} + } return &Response{redirect: "/admin/home"} } @@ -212,18 +157,16 @@ func (c *Controller) ServeUnlinkListenBrainzDo(r *http.Request) *Response { user := r.Context().Value(CtxUser).(*db.User) user.ListenBrainzURL = "" user.ListenBrainzToken = "" - c.DB.Save(&user) + if err := c.DB.Save(user).Error; err != nil { + return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("save user: %v", err)}} + } return &Response{redirect: "/admin/home"} } func (c *Controller) ServeChangeUsername(r *http.Request) *Response { - username := r.URL.Query().Get("user") - if username == "" { - return &Response{code: 400, err: "please provide a username"} - } - user := c.DB.GetUserByName(username) - if user == nil { - return &Response{code: 400, err: "couldn't find a user with that name"} + user, err := selectedUserIfAdmin(c, r) + if err != nil { + return &Response{code: 400, err: err.Error()} } data := &templateData{} data.SelectedUser = user @@ -234,7 +177,10 @@ func (c *Controller) ServeChangeUsername(r *http.Request) *Response { } func (c *Controller) ServeChangeUsernameDo(r *http.Request) *Response { - username := r.URL.Query().Get("user") + user, err := selectedUserIfAdmin(c, r) + if err != nil { + return &Response{code: 400, err: err.Error()} + } usernameNew := r.FormValue("username") if err := validateUsername(usernameNew); err != nil { return &Response{ @@ -242,20 +188,17 @@ func (c *Controller) ServeChangeUsernameDo(r *http.Request) *Response { flashW: []string{err.Error()}, } } - user := c.DB.GetUserByName(username) user.Name = usernameNew - c.DB.Save(user) + if err := c.DB.Save(user).Error; err != nil { + return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("save username: %v", err)}} + } return &Response{redirect: "/admin/home"} } func (c *Controller) ServeChangePassword(r *http.Request) *Response { - username := r.URL.Query().Get("user") - if username == "" { - return &Response{code: 400, err: "please provide a username"} - } - user := c.DB.GetUserByName(username) - if user == nil { - return &Response{code: 400, err: "couldn't find a user with that name"} + user, err := selectedUserIfAdmin(c, r) + if err != nil { + return &Response{code: 400, err: err.Error()} } data := &templateData{} data.SelectedUser = user @@ -266,7 +209,10 @@ func (c *Controller) ServeChangePassword(r *http.Request) *Response { } func (c *Controller) ServeChangePasswordDo(r *http.Request) *Response { - username := r.URL.Query().Get("user") + user, err := selectedUserIfAdmin(c, r) + if err != nil { + return &Response{code: 400, err: err.Error()} + } passwordOne := r.FormValue("password_one") passwordTwo := r.FormValue("password_two") if err := validatePasswords(passwordOne, passwordTwo); err != nil { @@ -275,20 +221,17 @@ func (c *Controller) ServeChangePasswordDo(r *http.Request) *Response { flashW: []string{err.Error()}, } } - user := c.DB.GetUserByName(username) user.Password = passwordOne - c.DB.Save(user) + if err := c.DB.Save(user).Error; err != nil { + return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("save user: %v", err)}} + } return &Response{redirect: "/admin/home"} } func (c *Controller) ServeChangeAvatar(r *http.Request) *Response { - username := r.URL.Query().Get("user") - if username == "" { - return &Response{code: 400, err: "please provide a username"} - } - user := c.DB.GetUserByName(username) - if user == nil { - return &Response{code: 400, err: "couldn't find a user with that name"} + user, err := selectedUserIfAdmin(c, r) + if err != nil { + return &Response{code: 400, err: err.Error()} } data := &templateData{} data.SelectedUser = user @@ -299,8 +242,10 @@ func (c *Controller) ServeChangeAvatar(r *http.Request) *Response { } func (c *Controller) ServeChangeAvatarDo(r *http.Request) *Response { - username := r.URL.Query().Get("user") - user := c.DB.GetUserByName(username) + user, err := selectedUserIfAdmin(c, r) + if err != nil { + return &Response{code: 400, err: err.Error()} + } avatar, err := getAvatarFile(r) if err != nil { return &Response{ @@ -309,26 +254,34 @@ func (c *Controller) ServeChangeAvatarDo(r *http.Request) *Response { } } user.Avatar = avatar - c.DB.Save(user) - return &Response{redirect: "/admin/home"} + if err := c.DB.Save(user).Error; err != nil { + return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("save user: %v", err)}} + } + return &Response{ + redirect: r.Referer(), + flashN: []string{"avatar saved successfully"}, + } } func (c *Controller) ServeDeleteAvatarDo(r *http.Request) *Response { - username := r.URL.Query().Get("user") - user := c.DB.GetUserByName(username) + user, err := selectedUserIfAdmin(c, r) + if err != nil { + return &Response{code: 400, err: err.Error()} + } user.Avatar = nil - c.DB.Save(user) - return &Response{redirect: "/admin/home"} + if err := c.DB.Save(user).Error; err != nil { + return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("save user: %v", err)}} + } + return &Response{ + redirect: r.Referer(), + flashN: []string{"avatar deleted successfully"}, + } } func (c *Controller) ServeDeleteUser(r *http.Request) *Response { - username := r.URL.Query().Get("user") - if username == "" { - return &Response{code: 400, err: "please provide a username"} - } - user := c.DB.GetUserByName(username) - if user == nil { - return &Response{code: 400, err: "couldn't find a user with that name"} + user, err := selectedUserIfAdmin(c, r) + if err != nil { + return &Response{code: 400, err: err.Error()} } data := &templateData{} data.SelectedUser = user @@ -339,15 +292,19 @@ func (c *Controller) ServeDeleteUser(r *http.Request) *Response { } func (c *Controller) ServeDeleteUserDo(r *http.Request) *Response { - username := r.URL.Query().Get("user") - user := c.DB.GetUserByName(username) + user, err := selectedUserIfAdmin(c, r) + if err != nil { + return &Response{code: 400, err: err.Error()} + } if user.IsAdmin { return &Response{ redirect: "/admin/home", flashW: []string{"can't delete the admin user"}, } } - c.DB.Delete(user) + if err := c.DB.Delete(user).Error; err != nil { + return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("delete user: %v", err)}} + } return &Response{redirect: "/admin/home"} } @@ -388,10 +345,10 @@ func (c *Controller) ServeUpdateLastFMAPIKey(r *http.Request) *Response { data := &templateData{} var err error if data.CurrentLastFMAPIKey, err = c.DB.GetSetting("lastfm_api_key"); err != nil { - return &Response{code: 500, err: fmt.Sprintf("couldn't get api key: %v", err)} + return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("couldn't get api key: %v", err)}} } if data.CurrentLastFMAPISecret, err = c.DB.GetSetting("lastfm_secret"); err != nil { - return &Response{code: 500, err: fmt.Sprintf("couldn't get secret: %v", err)} + return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("couldn't get secret: %v", err)}} } return &Response{ template: "update_lastfm_api_key.tmpl", @@ -409,10 +366,10 @@ func (c *Controller) ServeUpdateLastFMAPIKeyDo(r *http.Request) *Response { } } if err := c.DB.SetSetting("lastfm_api_key", apiKey); err != nil { - return &Response{code: 500, err: fmt.Sprintf("couldn't set api key: %v", err)} + return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("couldn't set api key: %v", err)}} } if err := c.DB.SetSetting("lastfm_secret", secret); err != nil { - return &Response{code: 500, err: fmt.Sprintf("couldn't set secret: %v", err)} + return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("couldn't set secret: %v", err)}} } return &Response{redirect: "/admin/home"} } @@ -570,7 +527,7 @@ func (c *Controller) ServeInternetRadioStationAddDo(r *http.Request) *Response { station.Name = name station.HomepageURL = homepageURL if err := c.DB.Save(&station).Error; err != nil { - return &Response{code: 500, err: fmt.Sprintf("error saving station: %v", err)} + return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("error saving station: %v", err)}} } return &Response{ @@ -613,7 +570,7 @@ func (c *Controller) ServeInternetRadioStationUpdateDo(r *http.Request) *Respons var station db.InternetRadioStation if err := c.DB.Where("id=?", stationID).First(&station).Error; err != nil { - return &Response{code: 404, err: fmt.Sprintf("find station by id: %v", err)} + return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("find station by id: %v", err)}} } station.StreamURL = streamURL @@ -636,11 +593,11 @@ func (c *Controller) ServeInternetRadioStationDeleteDo(r *http.Request) *Respons var station db.InternetRadioStation if err := c.DB.Where("id=?", stationID).First(&station).Error; err != nil { - return &Response{code: 404, err: fmt.Sprintf("find station by id: %v", err)} + return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("find station by id: %v", err)}} } if err := c.DB.Where("id=?", stationID).Delete(&db.InternetRadioStation{}).Error; err != nil { - return &Response{code: 500, err: fmt.Sprintf("deleting radio station: %v", err)} + return &Response{redirect: r.Referer(), flashW: []string{fmt.Sprintf("deleting radio station: %v", err)}} } return &Response{ @@ -668,3 +625,16 @@ func getAvatarFile(r *http.Request) ([]byte, error) { } return buff.Bytes(), nil } + +func selectedUserIfAdmin(c *Controller, r *http.Request) (*db.User, error) { + selectedUsername := r.URL.Query().Get("user") + if selectedUsername == "" { + return nil, fmt.Errorf("please provide a username") + } + user := r.Context().Value(CtxUser).(*db.User) + if !user.IsAdmin && user.Name != selectedUsername { + return nil, fmt.Errorf("must be admin to perform actions for other users") + } + selectedUser := c.DB.GetUserByName(selectedUsername) + return selectedUser, nil +} diff --git a/server/server.go b/server/server.go index 7dd5a58..5a20f12 100644 --- a/server/server.go +++ b/server/server.go @@ -22,8 +22,8 @@ import ( "go.senan.xyz/gonic/scrobble" "go.senan.xyz/gonic/scrobble/lastfm" "go.senan.xyz/gonic/scrobble/listenbrainz" - "go.senan.xyz/gonic/server/assets" "go.senan.xyz/gonic/server/ctrladmin" + "go.senan.xyz/gonic/server/ctrladmin/adminui" "go.senan.xyz/gonic/server/ctrlbase" "go.senan.xyz/gonic/server/ctrlsubsonic" "go.senan.xyz/gonic/transcode" @@ -149,7 +149,7 @@ func setupAdmin(r *mux.Router, ctrl *ctrladmin.Controller) { r.Handle("/login", ctrl.H(ctrl.ServeLogin)) r.Handle("/login_do", ctrl.HR(ctrl.ServeLoginDo)) // "raw" handler, updates session - staticHandler := http.StripPrefix("/admin", http.FileServer(http.FS(assets.Static))) + staticHandler := http.StripPrefix("/admin", http.FileServer(http.FS(adminui.StaticFS))) r.PathPrefix("/static").Handler(staticHandler) // user routes (if session is valid) @@ -157,13 +157,15 @@ func setupAdmin(r *mux.Router, ctrl *ctrladmin.Controller) { routUser.Use(ctrl.WithUserSession) routUser.Handle("/logout", ctrl.HR(ctrl.ServeLogout)) // "raw" handler, updates session routUser.Handle("/home", ctrl.H(ctrl.ServeHome)) - routUser.Handle("/change_own_username", ctrl.H(ctrl.ServeChangeOwnUsername)) - routUser.Handle("/change_own_username_do", ctrl.H(ctrl.ServeChangeOwnUsernameDo)) - routUser.Handle("/change_own_password", ctrl.H(ctrl.ServeChangeOwnPassword)) - routUser.Handle("/change_own_password_do", ctrl.H(ctrl.ServeChangeOwnPasswordDo)) - routUser.Handle("/change_own_avatar", ctrl.H(ctrl.ServeChangeOwnAvatar)) - routUser.Handle("/change_own_avatar_do", ctrl.H(ctrl.ServeChangeOwnAvatarDo)) - routUser.Handle("/delete_own_avatar_do", ctrl.H(ctrl.ServeDeleteOwnAvatarDo)) + routUser.Handle("/change_username", ctrl.H(ctrl.ServeChangeUsername)) + routUser.Handle("/change_username_do", ctrl.H(ctrl.ServeChangeUsernameDo)) + routUser.Handle("/change_password", ctrl.H(ctrl.ServeChangePassword)) + routUser.Handle("/change_password_do", ctrl.H(ctrl.ServeChangePasswordDo)) + routUser.Handle("/change_avatar", ctrl.H(ctrl.ServeChangeAvatar)) + routUser.Handle("/change_avatar_do", ctrl.H(ctrl.ServeChangeAvatarDo)) + routUser.Handle("/delete_avatar_do", ctrl.H(ctrl.ServeDeleteAvatarDo)) + routUser.Handle("/delete_user", ctrl.H(ctrl.ServeDeleteUser)) + routUser.Handle("/delete_user_do", ctrl.H(ctrl.ServeDeleteUserDo)) routUser.Handle("/link_lastfm_do", ctrl.H(ctrl.ServeLinkLastFMDo)) routUser.Handle("/unlink_lastfm_do", ctrl.H(ctrl.ServeUnlinkLastFMDo)) routUser.Handle("/link_listenbrainz_do", ctrl.H(ctrl.ServeLinkListenBrainzDo)) @@ -176,15 +178,6 @@ func setupAdmin(r *mux.Router, ctrl *ctrladmin.Controller) { // admin routes (if session is valid, and is admin) routAdmin := routUser.NewRoute().Subrouter() routAdmin.Use(ctrl.WithAdminSession) - routAdmin.Handle("/change_username", ctrl.H(ctrl.ServeChangeUsername)) - routAdmin.Handle("/change_username_do", ctrl.H(ctrl.ServeChangeUsernameDo)) - routAdmin.Handle("/change_password", ctrl.H(ctrl.ServeChangePassword)) - routAdmin.Handle("/change_password_do", ctrl.H(ctrl.ServeChangePasswordDo)) - routAdmin.Handle("/change_avatar", ctrl.H(ctrl.ServeChangeAvatar)) - routAdmin.Handle("/change_avatar_do", ctrl.H(ctrl.ServeChangeAvatarDo)) - routAdmin.Handle("/delete_avatar_do", ctrl.H(ctrl.ServeDeleteAvatarDo)) - 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))