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 }}
-
-
-{{ 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 }}
-
-
-{{ 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 }}
-
- {{ $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 }}
-
-
-
-
- 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
-
-
-
-{{ if .User.IsAdmin }}
-
-
- podcasts
-
-
-
you can add podcasts rss feeds here
-
-
-
-{{ end }}
-{{ if .User.IsAdmin }}
-
-
- internet_radio_stations
-
-
-
you can add and update internet radio stations here
-
-
-
-{{ end }}
-
-
- playlists
-
-
- {{ if eq (len .Playlists) 0 }}
-
no playlists yet
- {{ end }}
-
-
-
-
-{{ 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" }}
-
-{{ 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" }}
-
-{{ 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
-
-
-
+{{ 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)
+
{{ $playlist.CreatedAt | dateHuman }}
+
+ {{ 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))