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 -
-
- change username… - | - change password… - | - change avatar… -
- {{ 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 0000000..3221f5f Binary files /dev/null and b/server/ctrladmin/adminui/static/inconsolata-v31-latin-500.woff differ 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 0000000..26b61e9 Binary files /dev/null and b/server/ctrladmin/adminui/static/inconsolata-v31-latin-500.woff2 differ diff --git a/server/ctrladmin/adminui/static/inconsolata-v31-latin-600.woff b/server/ctrladmin/adminui/static/inconsolata-v31-latin-600.woff new file mode 100644 index 0000000..0ff280a Binary files /dev/null and b/server/ctrladmin/adminui/static/inconsolata-v31-latin-600.woff differ 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 0000000..7e7f927 Binary files /dev/null and b/server/ctrladmin/adminui/static/inconsolata-v31-latin-600.woff2 differ diff --git a/server/ctrladmin/adminui/static/main.js b/server/ctrladmin/adminui/static/main.js new file mode 100644 index 0000000..e108539 --- /dev/null +++ b/server/ctrladmin/adminui/static/main.js @@ -0,0 +1,3 @@ +for (const input of document.querySelectorAll("input.auto-submit, select.auto-submit") || []) { + input.onchange = (e) => 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))